From b08aa442daeb4a14702faed012f75fcff47978ab Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 9 Dec 2025 12:22:21 -0800 Subject: [PATCH 01/34] chore: remove legacy backend and npm-app --- .agents/LESSONS.md | 91 - CONTRIBUTING.md | 9 +- backend/.gitignore | 4 - backend/README.md | 25 - backend/knowledge.md | 180 -- backend/package.json | 62 - .../src/__tests__/agent-id-resolution.test.ts | 202 -- backend/src/__tests__/agent-run.test.ts | 497 ----- backend/src/__tests__/auto-topup.test.ts | 245 --- .../cost-aggregation.integration.test.ts | 523 ----- .../src/__tests__/credit-conversion.test.ts | 48 - .../get-custom-file-picker-config.test.ts | 170 -- .../__tests__/main-prompt.integration.test.ts | 491 ----- .../test-data/dex-go/edit-snippet.go | 121 -- .../__tests__/test-data/dex-go/expected.go | 821 -------- .../__tests__/test-data/dex-go/original.go | 753 ------- .../src/__tests__/tool-call-schema.test.ts | 300 --- .../src/__tests__/usage-calculation.test.ts | 249 --- backend/src/admin/grade-runs.ts | 178 -- backend/src/admin/relabelRuns.ts | 503 ----- backend/src/agent-run.ts | 127 -- .../api/__tests__/validate-agent-name.test.ts | 215 -- backend/src/api/agents.ts | 22 - backend/src/api/org.ts | 71 - backend/src/api/usage.ts | 107 - backend/src/api/validate-agent-name.ts | 123 -- backend/src/client-wrapper.ts | 250 --- backend/src/context/app-context.ts | 110 - backend/src/get-documentation-for-query.ts | 274 --- backend/src/impl/agent-runtime.ts | 44 - backend/src/index.ts | 178 -- backend/src/llm-apis/knowledge.md | 39 - backend/src/llm-apis/message-cost-tracker.ts | 759 ------- backend/src/llm-apis/openrouter.ts | 47 - backend/src/llm-apis/relace-api.ts | 114 - backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts | 421 ---- .../src/llm-apis/vercel-ai-sdk/openrouter.ts | 15 - .../vercel-ai-sdk/vertex-finetuned.ts | 42 - backend/src/templates/agent-db.ts | 109 - backend/src/util/__tests__/object.test.ts | 131 -- backend/src/util/auth-helpers.ts | 10 - backend/src/util/check-auth.ts | 93 - backend/src/util/logger.ts | 108 - backend/src/util/object.ts | 35 - backend/src/websockets/auth.ts | 30 - backend/src/websockets/middleware.ts | 469 ----- backend/src/websockets/request-context.ts | 2 - backend/src/websockets/server.ts | 187 -- backend/src/websockets/switchboard.ts | 94 - backend/src/websockets/websocket-action.ts | 344 ---- backend/tsconfig.json | 8 - codebuff.json | 24 - .../utils => common/src/util}/system-info.ts | 0 common/src/websockets/websocket-client.ts | 257 --- common/src/websockets/websocket-schema.ts | 65 - evals/buffbench/gen-evals.ts | 3 +- evals/buffbench/pick-commits.ts | 2 +- evals/buffbench/run-buffbench.ts | 4 +- evals/package.json | 2 - evals/scaffolding.ts | 234 ++- evals/subagents/eval-planner.ts | 9 +- evals/test-setup.ts | 9 +- evals/tsconfig.json | 2 +- knowledge.md | 28 +- npm-app/.gitignore | 3 - npm-app/bunfig.toml | 15 - npm-app/knowledge.md | 3 - npm-app/package.json | 73 - npm-app/release-legacy/README.md | 69 - npm-app/release-legacy/index.js | 448 ---- npm-app/release-legacy/package.json | 42 - npm-app/release-staging/README.md | 73 - npm-app/release-staging/index.js | 440 ---- npm-app/release-staging/package.json | 39 - npm-app/release/README.md | 69 - npm-app/release/index.js | 440 ---- npm-app/release/package.json | 41 - npm-app/scripts/build-binary.js | 170 -- npm-app/scripts/patch-web-tree-sitter.ts | 200 -- npm-app/scripts/release-legacy.js | 101 - npm-app/scripts/release.js | 101 - npm-app/scripts/twitch-plays-codebuff.sh | 38 - npm-app/src/__tests__/display.test.ts | 226 -- npm-app/src/__tests__/glob-handler.test.ts | 344 ---- npm-app/src/__tests__/image-upload.test.ts | 288 --- .../src/__tests__/markdown-renderer.test.ts | 236 --- npm-app/src/__tests__/tool-handlers.test.ts | 431 ---- .../validate-agent-passthrough.test.ts | 54 - npm-app/src/agents/agent-utils.ts | 52 - npm-app/src/agents/load-agents.ts | 146 -- npm-app/src/background-process-manager.ts | 507 ----- npm-app/src/browser-runner.ts | 699 ------- npm-app/src/chat-storage.ts | 114 - npm-app/src/checkpoints/checkpoint-manager.ts | 433 ---- npm-app/src/checkpoints/file-manager.ts | 655 ------ npm-app/src/cli-definitions.ts | 107 - .../src/cli-handlers/agent-creation-chat.ts | 121 -- npm-app/src/cli-handlers/agents.ts | 649 ------ npm-app/src/cli-handlers/api-key.ts | 89 - npm-app/src/cli-handlers/checkpoint.ts | 289 --- npm-app/src/cli-handlers/confetti-demo.ts | 231 --- npm-app/src/cli-handlers/diff.ts | 30 - npm-app/src/cli-handlers/easter-egg.ts | 172 -- npm-app/src/cli-handlers/init-agents.ts | 115 -- .../src/cli-handlers/inititalization-flow.ts | 30 - npm-app/src/cli-handlers/mini-chat.ts | 331 --- npm-app/src/cli-handlers/publish.ts | 227 -- npm-app/src/cli-handlers/save-agent.ts | 94 - npm-app/src/cli-handlers/shims.ts | 143 -- npm-app/src/cli-handlers/subagent-list.ts | 472 ----- npm-app/src/cli-handlers/subagent.ts | 359 ---- npm-app/src/cli-handlers/traces.ts | 371 ---- npm-app/src/cli.ts | 1611 --------------- npm-app/src/client.ts | 1828 ----------------- npm-app/src/config.ts | 16 - npm-app/src/create-template-project.ts | 151 -- npm-app/src/dev-process-manager.ts | 72 - npm-app/src/diff-manager.ts | 52 - npm-app/src/display/markdown-renderer.ts | 589 ------ npm-app/src/display/overrides.ts | 9 - npm-app/src/display/print-mode.ts | 22 - npm-app/src/display/squash-newlines.ts | 269 --- npm-app/src/fingerprint.ts | 69 - npm-app/src/index.ts | 320 --- npm-app/src/json-config/hooks.ts | 97 - npm-app/src/json-config/parser.ts | 143 -- npm-app/src/menu.ts | 461 ----- npm-app/src/native/ripgrep.ts | 60 - npm-app/src/project-files.ts | 733 ------- npm-app/src/rage-detectors.ts | 120 -- npm-app/src/shell-dispatcher.ts | 897 -------- npm-app/src/startup-process-handler.ts | 29 - npm-app/src/subagent-headers.ts | 49 - npm-app/src/subagent-storage.ts | 249 --- npm-app/src/terminal/background.ts | 161 -- npm-app/src/terminal/run-command.ts | 539 ----- npm-app/src/tool-handlers.ts | 744 ------- npm-app/src/types.ts | 25 - .../background-process-manager.test.ts.snap | 141 -- .../frustration-detector.test.ts.snap | 137 -- .../__snapshots__/rage-detector.test.ts.snap | 137 -- .../xml-stream-parser.test.ts.snap | 137 -- .../background-process-manager.test.ts | 370 ---- .../src/utils/__tests__/rage-detector.test.ts | 722 ------- .../__tests__/response-example-4-files.txt | 76 - .../utils/__tests__/tool-renderers.test.ts | 91 - .../utils/__tests__/xml-stream-parser.test.ts | 386 ---- npm-app/src/utils/agent-validation.ts | 59 - npm-app/src/utils/analytics.ts | 135 -- npm-app/src/utils/auth-headers.ts | 45 - npm-app/src/utils/changes.ts | 65 - npm-app/src/utils/detect-shell.ts | 74 - npm-app/src/utils/git.ts | 156 -- npm-app/src/utils/image-handler.ts | 448 ---- npm-app/src/utils/logger.ts | 146 -- npm-app/src/utils/project-file-tree.ts | 113 - npm-app/src/utils/rage-detector.ts | 232 --- npm-app/src/utils/spinner.ts | 118 -- npm-app/src/utils/suppress-console.ts | 98 - npm-app/src/utils/terminal.ts | 29 - npm-app/src/utils/tool-renderers.ts | 379 ---- npm-app/src/utils/with-hang-detection.ts | 18 - npm-app/src/utils/xml-stream-parser.ts | 289 --- npm-app/src/web-scraper.ts | 65 - npm-app/src/workers/checkpoint-worker.ts | 75 - npm-app/src/workers/project-context.ts | 35 - npm-app/tsconfig.json | 9 - package.json | 6 +- .../src/__tests__/fast-rewrite.test.ts | 4 - .../src/__tests__/process-file-block.test.ts | 4 - scripts/fat-sdk-openrouter-example.ts | 4 +- scripts/package.json | 1 - sdk/src/agents/load-agents.ts | 87 + {npm-app => sdk}/src/credentials.ts | 35 +- sdk/src/index.ts | 5 +- sdk/src/run.ts | 3 +- sdk/src/websocket-client.ts | 184 -- sdk/test/test-sdk.ts | 3 +- tsconfig.json | 3 - 179 files changed, 276 insertions(+), 35232 deletions(-) delete mode 100644 backend/.gitignore delete mode 100644 backend/README.md delete mode 100644 backend/knowledge.md delete mode 100644 backend/package.json delete mode 100644 backend/src/__tests__/agent-id-resolution.test.ts delete mode 100644 backend/src/__tests__/agent-run.test.ts delete mode 100644 backend/src/__tests__/auto-topup.test.ts delete mode 100644 backend/src/__tests__/cost-aggregation.integration.test.ts delete mode 100644 backend/src/__tests__/credit-conversion.test.ts delete mode 100644 backend/src/__tests__/get-custom-file-picker-config.test.ts delete mode 100644 backend/src/__tests__/main-prompt.integration.test.ts delete mode 100644 backend/src/__tests__/test-data/dex-go/edit-snippet.go delete mode 100644 backend/src/__tests__/test-data/dex-go/expected.go delete mode 100644 backend/src/__tests__/test-data/dex-go/original.go delete mode 100644 backend/src/__tests__/tool-call-schema.test.ts delete mode 100644 backend/src/__tests__/usage-calculation.test.ts delete mode 100644 backend/src/admin/grade-runs.ts delete mode 100644 backend/src/admin/relabelRuns.ts delete mode 100644 backend/src/agent-run.ts delete mode 100644 backend/src/api/__tests__/validate-agent-name.test.ts delete mode 100644 backend/src/api/agents.ts delete mode 100644 backend/src/api/org.ts delete mode 100644 backend/src/api/usage.ts delete mode 100644 backend/src/api/validate-agent-name.ts delete mode 100644 backend/src/client-wrapper.ts delete mode 100644 backend/src/context/app-context.ts delete mode 100644 backend/src/get-documentation-for-query.ts delete mode 100644 backend/src/impl/agent-runtime.ts delete mode 100644 backend/src/index.ts delete mode 100644 backend/src/llm-apis/knowledge.md delete mode 100644 backend/src/llm-apis/message-cost-tracker.ts delete mode 100644 backend/src/llm-apis/openrouter.ts delete mode 100644 backend/src/llm-apis/relace-api.ts delete mode 100644 backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts delete mode 100644 backend/src/llm-apis/vercel-ai-sdk/openrouter.ts delete mode 100644 backend/src/llm-apis/vercel-ai-sdk/vertex-finetuned.ts delete mode 100644 backend/src/templates/agent-db.ts delete mode 100644 backend/src/util/__tests__/object.test.ts delete mode 100644 backend/src/util/auth-helpers.ts delete mode 100644 backend/src/util/check-auth.ts delete mode 100644 backend/src/util/logger.ts delete mode 100644 backend/src/util/object.ts delete mode 100644 backend/src/websockets/auth.ts delete mode 100644 backend/src/websockets/middleware.ts delete mode 100644 backend/src/websockets/request-context.ts delete mode 100644 backend/src/websockets/server.ts delete mode 100644 backend/src/websockets/switchboard.ts delete mode 100644 backend/src/websockets/websocket-action.ts delete mode 100644 backend/tsconfig.json rename {npm-app/src/utils => common/src/util}/system-info.ts (100%) delete mode 100644 common/src/websockets/websocket-client.ts delete mode 100644 common/src/websockets/websocket-schema.ts delete mode 100644 npm-app/.gitignore delete mode 100644 npm-app/bunfig.toml delete mode 100644 npm-app/knowledge.md delete mode 100644 npm-app/package.json delete mode 100644 npm-app/release-legacy/README.md delete mode 100644 npm-app/release-legacy/index.js delete mode 100644 npm-app/release-legacy/package.json delete mode 100644 npm-app/release-staging/README.md delete mode 100644 npm-app/release-staging/index.js delete mode 100644 npm-app/release-staging/package.json delete mode 100644 npm-app/release/README.md delete mode 100644 npm-app/release/index.js delete mode 100644 npm-app/release/package.json delete mode 100644 npm-app/scripts/build-binary.js delete mode 100644 npm-app/scripts/patch-web-tree-sitter.ts delete mode 100755 npm-app/scripts/release-legacy.js delete mode 100755 npm-app/scripts/release.js delete mode 100755 npm-app/scripts/twitch-plays-codebuff.sh delete mode 100644 npm-app/src/__tests__/display.test.ts delete mode 100644 npm-app/src/__tests__/glob-handler.test.ts delete mode 100644 npm-app/src/__tests__/image-upload.test.ts delete mode 100644 npm-app/src/__tests__/markdown-renderer.test.ts delete mode 100644 npm-app/src/__tests__/tool-handlers.test.ts delete mode 100644 npm-app/src/__tests__/validate-agent-passthrough.test.ts delete mode 100644 npm-app/src/agents/agent-utils.ts delete mode 100644 npm-app/src/agents/load-agents.ts delete mode 100644 npm-app/src/background-process-manager.ts delete mode 100644 npm-app/src/browser-runner.ts delete mode 100644 npm-app/src/chat-storage.ts delete mode 100644 npm-app/src/checkpoints/checkpoint-manager.ts delete mode 100644 npm-app/src/checkpoints/file-manager.ts delete mode 100644 npm-app/src/cli-definitions.ts delete mode 100644 npm-app/src/cli-handlers/agent-creation-chat.ts delete mode 100644 npm-app/src/cli-handlers/agents.ts delete mode 100644 npm-app/src/cli-handlers/api-key.ts delete mode 100644 npm-app/src/cli-handlers/checkpoint.ts delete mode 100644 npm-app/src/cli-handlers/confetti-demo.ts delete mode 100644 npm-app/src/cli-handlers/diff.ts delete mode 100644 npm-app/src/cli-handlers/easter-egg.ts delete mode 100644 npm-app/src/cli-handlers/init-agents.ts delete mode 100644 npm-app/src/cli-handlers/inititalization-flow.ts delete mode 100644 npm-app/src/cli-handlers/mini-chat.ts delete mode 100644 npm-app/src/cli-handlers/publish.ts delete mode 100644 npm-app/src/cli-handlers/save-agent.ts delete mode 100644 npm-app/src/cli-handlers/shims.ts delete mode 100644 npm-app/src/cli-handlers/subagent-list.ts delete mode 100644 npm-app/src/cli-handlers/subagent.ts delete mode 100644 npm-app/src/cli-handlers/traces.ts delete mode 100644 npm-app/src/cli.ts delete mode 100644 npm-app/src/client.ts delete mode 100644 npm-app/src/config.ts delete mode 100644 npm-app/src/create-template-project.ts delete mode 100644 npm-app/src/dev-process-manager.ts delete mode 100644 npm-app/src/diff-manager.ts delete mode 100644 npm-app/src/display/markdown-renderer.ts delete mode 100644 npm-app/src/display/overrides.ts delete mode 100644 npm-app/src/display/print-mode.ts delete mode 100644 npm-app/src/display/squash-newlines.ts delete mode 100644 npm-app/src/fingerprint.ts delete mode 100644 npm-app/src/index.ts delete mode 100644 npm-app/src/json-config/hooks.ts delete mode 100644 npm-app/src/json-config/parser.ts delete mode 100644 npm-app/src/menu.ts delete mode 100644 npm-app/src/native/ripgrep.ts delete mode 100644 npm-app/src/project-files.ts delete mode 100644 npm-app/src/rage-detectors.ts delete mode 100644 npm-app/src/shell-dispatcher.ts delete mode 100644 npm-app/src/startup-process-handler.ts delete mode 100644 npm-app/src/subagent-headers.ts delete mode 100644 npm-app/src/subagent-storage.ts delete mode 100644 npm-app/src/terminal/background.ts delete mode 100644 npm-app/src/terminal/run-command.ts delete mode 100644 npm-app/src/tool-handlers.ts delete mode 100644 npm-app/src/types.ts delete mode 100644 npm-app/src/utils/__tests__/__snapshots__/background-process-manager.test.ts.snap delete mode 100644 npm-app/src/utils/__tests__/__snapshots__/frustration-detector.test.ts.snap delete mode 100644 npm-app/src/utils/__tests__/__snapshots__/rage-detector.test.ts.snap delete mode 100644 npm-app/src/utils/__tests__/__snapshots__/xml-stream-parser.test.ts.snap delete mode 100644 npm-app/src/utils/__tests__/background-process-manager.test.ts delete mode 100644 npm-app/src/utils/__tests__/rage-detector.test.ts delete mode 100644 npm-app/src/utils/__tests__/response-example-4-files.txt delete mode 100644 npm-app/src/utils/__tests__/tool-renderers.test.ts delete mode 100644 npm-app/src/utils/__tests__/xml-stream-parser.test.ts delete mode 100644 npm-app/src/utils/agent-validation.ts delete mode 100644 npm-app/src/utils/analytics.ts delete mode 100644 npm-app/src/utils/auth-headers.ts delete mode 100644 npm-app/src/utils/changes.ts delete mode 100644 npm-app/src/utils/detect-shell.ts delete mode 100644 npm-app/src/utils/git.ts delete mode 100644 npm-app/src/utils/image-handler.ts delete mode 100644 npm-app/src/utils/logger.ts delete mode 100644 npm-app/src/utils/project-file-tree.ts delete mode 100644 npm-app/src/utils/rage-detector.ts delete mode 100644 npm-app/src/utils/spinner.ts delete mode 100644 npm-app/src/utils/suppress-console.ts delete mode 100644 npm-app/src/utils/terminal.ts delete mode 100644 npm-app/src/utils/tool-renderers.ts delete mode 100644 npm-app/src/utils/with-hang-detection.ts delete mode 100644 npm-app/src/utils/xml-stream-parser.ts delete mode 100644 npm-app/src/web-scraper.ts delete mode 100644 npm-app/src/workers/checkpoint-worker.ts delete mode 100644 npm-app/src/workers/project-context.ts delete mode 100644 npm-app/tsconfig.json create mode 100644 sdk/src/agents/load-agents.ts rename {npm-app => sdk}/src/credentials.ts (65%) delete mode 100644 sdk/src/websocket-client.ts diff --git a/.agents/LESSONS.md b/.agents/LESSONS.md index 5a30a4ca0f..05e0f7ba53 100644 --- a/.agents/LESSONS.md +++ b/.agents/LESSONS.md @@ -71,8 +71,6 @@ Add comprehensive unit tests to verify that the spawn_agents tool enforces paren - **Issue:** Omitted the 'agent template not found' scenario in handler tests, missing a key error path. **Fix:** Add a test where localAgentTemplates lacks the requested agent; assert the error message and no loopAgentSteps call. -- **Issue:** Reimplemented ProjectFileContext and MockWebSocket in tests instead of reusing shared utils. - **Fix:** Import mockFileContext and MockWebSocket from backend/src/__tests__/test-utils to avoid drift and boilerplate. - **Issue:** Assertions tightly coupled to exact report header strings, making tests brittle to formatting changes. **Fix:** Assert via displayName-derived headers or use regex/contains on content while verifying loopAgentSteps calls for success. @@ -92,7 +90,6 @@ Add comprehensive unit tests to verify that the spawn_agents tool enforces paren ## 2025-10-21T02:27:58.739Z — extract-agent-parsing (998b585) ### Original Agent Prompt -Please consolidate agent ID parsing across the backend by introducing a shared util and updating the registry and spawn logic: - Add a common parser that can handle both published and local agent IDs, and a strict parser that only passes when a publisher is present. - Update the agent registry to rely on the strict parser for DB lookups and to prefix with the default org when needed. - Update the spawn-agents handler to use the shared general parser, with guards for optional fields, so that unprefixed, prefixed, and versioned forms are all matched correctly against the parent’s spawnable agents. @@ -201,7 +198,6 @@ Add end-to-end support for user-defined custom tools alongside the built-in tool - **Issue:** CodebuffToolCall stays tied to ToolName; custom names break typing and casts to any in stream-parser/tool-executor. **Fix:** Broaden types to string tool names. Update CodebuffToolCall/clientTool schemas to accept custom names and map to runtime schemas. -- **Issue:** AgentTemplate lacks customTools in backend/types, yet code references AgentTemplate['customTools'] in strings.ts/tool-executor. **Fix:** Add customTools to AgentTemplate (record by name). Ensure assembleLocalAgentTemplates builds this map from agent defs. - **Issue:** convertJsonSchemaToZod used in common/src/templates/agent-validation.ts without import/impl; likely compile error. @@ -210,8 +206,6 @@ Add end-to-end support for user-defined custom tools alongside the built-in tool - **Issue:** customTools defined as array in dynamic-agent-template, but prompts expect a record (customTools[name]). **Fix:** Normalize to Record during validation. Store the record on AgentTemplate; use it everywhere. -- **Issue:** Changed sdk/src/client.ts for overrides, but actual routing uses sdk/src/websocket-client.ts handleToolCall. - **Fix:** Document that clients must implement handleToolCall for custom tools or extend websocket-client to dispatch overrides. - **Issue:** Example inputs aren’t rendered in tool docs; requirement asked for example inputs in prompts. **Fix:** Enhance getToolsInstructions/getShortToolInstructions to render exampleInputs blocks under each tool description. @@ -222,13 +216,11 @@ Add end-to-end support for user-defined custom tools alongside the built-in tool - **Issue:** Loosened toolNames to string[] without validating built-ins vs custom; invalid names can slip silently. **Fix:** Validate toolNames: each must be built-in or exist in customTools. Emit clear validation errors with file context. -- **Issue:** Duplicate import of renderToolResults added in backend/src/tools/tool-executor.ts. **Fix:** Remove duplicate import and run the build/tests locally to catch such issues early. - **Issue:** processStreamWithTags autocompletes with cb_easp: true always; may invalidate non-end tools’ schemas. **Fix:** Only append cb_easp for tools marked endsAgentStep or relax schema to ignore unknown fields on autocomplete. -- **Issue:** Didn’t ensure custom tool defs are transported to backend prompts. strings.ts expects customTools but assembler not updated. **Fix:** Plumb customTools through fileContext->assembleLocalAgentTemplates->AgentTemplate so prompts receive full definitions. - **Issue:** Types in common/src/tools/list still restrict CodebuffToolCall to ToolName; executeToolCall changed to string. @@ -303,7 +295,6 @@ Update the tool type generator to write its output into the initial agents templ - **Issue:** Didn’t fully verify consumers of old path common/src/util/types/tools.d.ts beyond the generator script. **Fix:** Search repo-wide (incl. non-TS files) for tools.d.ts and update imports/docs; then run a typecheck/build to confirm. -- **Issue:** Made depth optional but didn’t normalize at use sites (backend/src/tools/handlers/tool/web-search.ts). **Fix:** Default at usage: const d = depth ?? 'standard'; pass { depth: d } to searchWeb and use d for credit calc/logging. - **Issue:** Used ripgrep -t flags for unrecognized types (e.g., mjs/tsx), risking missed matches during verification. @@ -315,7 +306,6 @@ Update the tool type generator to write its output into the initial agents templ ## 2025-10-21T02:42:27.076Z — enforce-agent-auth (27d87d7) ### Original Agent Prompt -Secure the agent name validation flow and improve UX. Require an API key for the backend agent validation endpoint, return the agent display name when a match is found (both built-in and published), and have the CLI print the selected agent name immediately after successful validation. Remove the early startup agent name print to avoid duplicate/racing messages. Update tests to cover the new auth requirement and the displayName in responses. ### Lessons - **Issue:** Used API_KEY_ENV_VAR in npm-app/src/index.ts without importing it, causing a compile/runtime error. @@ -330,7 +320,6 @@ Secure the agent name validation flow and improve UX. Require an API key for the - **Issue:** Agent name printing used plain 'Using agent:' without colors/format; inconsistent with CLI style. **Fix:** Print with project style: console.log(green(`\nAgent: ${bold(displayName)}`)) for consistency and readability. -- **Issue:** Backend tests assert 401 and {error} for missing API key, diverging from intended contract. **Fix:** Update tests to expect 403 and {valid:false,message:'API key required'} and keep displayName checks for success. - **Issue:** validateAgent returns void; misses chance to return displayName for downstream use/tests. @@ -369,7 +358,6 @@ Unify the default for the agent step limit and fix SDK behavior so that the conf ## 2025-10-21T02:46:25.999Z — type-client-tools (af3f741) ### Original Agent Prompt -Strengthen and centralize typing for tool calls across the monorepo. Move the tool call types to the shared common package, define a discriminated union for client-invokable tools, and update the backend to consume these shared types. Remove the backend-local duplicates, ensure the main prompt API no longer exposes toolCalls, and align the eval scaffolding code with the new types. Keep runtime behavior unchanged—this is a typing and import refactor focused on safety and clarity. ### Lessons - **Issue:** Added common/src/types/tools.ts duplicating schemas; lost Zod-backed runtime validation and created a second source of truth. @@ -382,7 +370,6 @@ Strengthen and centralize typing for tool calls across the monorepo. Move the to **Fix:** Narrow requestClientToolCall to ClientInvokableToolName and update all handlers to pass precise union members. - **Issue:** Handlers/stream-parser/tool-executor still rely on local types; partial migration weakens type safety. - **Fix:** Import CodebuffToolCall/ClientToolCall from common everywhere and delete backend-local type exports. - **Issue:** Changed loop-main-prompt to a single call, altering runtime behavior against the refactor-only requirement. **Fix:** Preserve loop semantics; only remove toolCalls from types/returns. If unused, delete file without logic changes. @@ -393,7 +380,6 @@ Strengthen and centralize typing for tool calls across the monorepo. Move the to - **Issue:** Evals scaffolding updated imports only; logic ignores client-invokable subset and special input shapes. **Fix:** Type toolCalls as ClientToolCall, restrict to client tools, and adapt FileChange and run_terminal_command modes. -- **Issue:** websocket requestToolCall path not constrained to client tools; accepts arbitrary tool names/params. **Fix:** Type requestToolCall and all callers to ClientInvokableToolName with params inferred from schema. - **Issue:** tool-executor/parseRawToolCall kept local types; not wired to shared unions or client-call constraints. @@ -405,7 +391,6 @@ Strengthen and centralize typing for tool calls across the monorepo. Move the to ## 2025-10-21T02:48:00.593Z — unify-api-auth (12511ca) ### Original Agent Prompt -Unify HTTP authentication between the CLI and backend by standardizing on a single API key header. Introduce small utilities to construct this header on the CLI and to extract it on the server, then update the agent validation and repository coverage endpoints, as well as the admin middleware, to use it. Keep existing response shapes and behaviors intact and ensure tests still pass. ### Lessons - **Issue:** Used header name 'X-Codebuff-API-Key' vs canonical 'x-codebuff-api-key', causing inconsistency across CLI/server and tests. @@ -418,9 +403,7 @@ Unify HTTP authentication between the CLI and backend by standardizing on a sing **Fix:** Only accept x-codebuff-api-key on HTTP endpoints. Remove Bearer fallback from server extractor used by routes. - **Issue:** Placed extractor in common/src, increasing cross-package coupling; task called for a small server utility. - **Fix:** Create a backend-local helper (e.g., backend/src/util/auth-helpers.ts) and use it in API routes/middleware. -- **Issue:** Modified backend/src/api/usage.ts, which was not within the requested endpoints, adding scope risk. **Fix:** Limit changes to the specified areas (agent validation, repo coverage, admin middleware) to reduce regression risk. - **Issue:** Logging used info-level for auth header presence in validate-agent handler, adding noise to logs. @@ -436,22 +419,16 @@ Add a lightweight agent validation system that prevents running with unknown age On the server, expose a GET endpoint to validate an agent identifier. It should accept a required agentId query parameter, respond with whether it's valid, and include a short-lived cache for positive results. A valid agent can be either a built-in agent or a published agent, and the response should clarify which source it came from and return a normalized identifier. Handle invalid input with a 400 status and structured error. Log when authentication info is present. -On the CLI, when a specific agent is provided, validate it before starting the session. If the agent is already loaded locally, skip remote validation. Otherwise, call the backend endpoint, include any available credentials, show a spinner while checking, and exit early with a helpful message when the agent is unknown. If there is a network problem, warn and continue. Add minimal tests to cover pass-through and short-circuit cases. ### Lessons -- **Issue:** Used getBuiltInAgents (not in repo) in backend/npm utils/tests; will not compile. **Fix:** Use AGENT_PERSONAS/AGENT_IDS from common/src/constants/agents to detect built-ins by ID. - **Issue:** Client only sent Authorization; ignored API key env. Missed 'include any credentials'. - **Fix:** Attach both Authorization (authToken) and X-API-Key from API_KEY_ENV_VAR when calling backend. - **Issue:** Server logs only noted Authorization presence; didn’t log X-API-Key as requested. **Fix:** In handler, log hasAuthHeader and hasApiKey (no secrets) alongside agentId for auditability. -- **Issue:** No backend tests added for the new GET endpoint; regressions unguarded. - **Fix:** Add tests under backend/src/api/__tests__ covering 400, builtin, published, unknown, cache hit. -- **Issue:** CLI tests didn’t verify agentId pass-through in query string to backend. **Fix:** Add a test asserting URLSearchParams agentId equals the original (publisher/name@version). - **Issue:** Redundant loadLocalAgents call before session; duplicates earlier startup loading. @@ -472,13 +449,11 @@ On the CLI, when a specific agent is provided, validate it before starting the s ## 2025-10-21T02:48:36.995Z — refactor-agent-validation (90f0246) ### Original Agent Prompt -Refactor the CLI agent validation so that the agent name resolution happens in the CLI module rather than the main index entrypoint. Move the agent validation function into the CLI code, have it return the resolved display name without printing, and adjust the CLI startup to display the resolved agent name before the greeting. Remove the old validation function and its usage from the entry file, clean up unused imports, and update the corresponding unit test to import from the new location. Keep the existing backend endpoint contract intact. ### Lessons - **Issue:** CLI.validateAgent returns undefined for local agents, so the caller can’t print the resolved name. **Fix:** On local hit, return the displayName (id->config or name match), e.g., localById?.displayName || localByDisplay?.displayName || agent. -- **Issue:** printInitialPrompt uses loadedAgents without ensuring they’re loaded, risking race/unnecessary backend calls. **Fix:** await loadLocalAgents({verbose:false}) before validateAgent; pass agents into it, then print name, then displayGreeting. - **Issue:** validateAgent defaults to getCachedLocalAgentInfo which may be empty/stale, breaking local resolution. @@ -535,7 +510,6 @@ Update the agent publishing pipeline so the publish API accepts raw agent defini ## 2025-10-21T02:56:18.897Z — centralize-placeholders (29d8f3f) ### Original Agent Prompt -Unify agent prompt placeholders by centralizing PLACEHOLDER and its types in the secret agent definitions and updating all agent prompt/factory modules to import from there. Remove the old backend prompt files that duplicated this logic. Make sure there are no dangling references and that prompt formatting still injects the same values at runtime. ### Lessons - **Issue:** Imported PLACEHOLDER from a non-existent path (@codebuff/common/.../secret-agent-definition), causing dangling refs. @@ -544,22 +518,15 @@ Unify agent prompt placeholders by centralizing PLACEHOLDER and its types in the - **Issue:** Changed common/agent-definition.ts to re-export from './secret-agent-definition' which doesn’t exist in common. **Fix:** Either add common/.../secret-agent-definition.ts or re-export from an existing module. Don’t point to files that aren’t there. -- **Issue:** Left duplicated backend prompt files (backend/src/templates/{ask-prompts,base-prompts}.ts) instead of removing them. - **Fix:** Delete the old backend prompt files per spec. Ensure all references point to the unified agents prompts; no duplicate logic remains. -- **Issue:** Modified backend prompt files that were meant to be removed, increasing diff noise and risk of drift. **Fix:** Avoid editing files scheduled for deletion. Remove them and update imports/usage sites to the single source of truth. - **Issue:** Centralized across packages without a clear plan, introducing cross-package breakage and unresolved imports. - **Fix:** Pick one canonical location (common). Add the file there, then re-export via backend/src/templates/types.ts to keep consumers stable. -- **Issue:** Did not ensure all backend consumers import via a single re-export point; mixed direct and central imports. - **Fix:** Make backend/src/templates/types.ts the sole backend import point. Update backend files to import PLACEHOLDER/typing from './types'. - **Issue:** Did not validate the repo after refactor (no typecheck/build), so broken imports slipped in. **Fix:** Run a full typecheck/build after edits. Fix any unresolved modules before concluding to meet the “no dangling refs” requirement. -- **Issue:** Changed import paths in backend/strings.ts to a path that wasn’t created, risking runtime failures. **Fix:** Update strings.ts only after the target module exists. If centralizing, add the module first, then adjust imports. - **Issue:** Did not verify that prompt formatting still injects the same values at runtime post-refactor. @@ -586,8 +553,6 @@ Add first-class SDK support for running terminal commands via the run_terminal_c - **Issue:** When returning a terminal_command_error payload, success stayed true and error field was empty. **Fix:** If output contains a terminal_command_error, also populate error (and optionally set success=false) for clearer signaling. -- **Issue:** handleToolCall lacked an explicit return type tied to WebSocketHandler, risking drift from schema. - **Fix:** Annotate return type as ReturnType to lock to the expected schema. - **Issue:** Timeout/termination status omitted the signal, reducing diagnostic clarity on killed processes. **Fix:** Include res.signal (e.g., 'Terminated by signal: SIGTERM') in status when present to improve parity and debuggability. @@ -595,7 +560,6 @@ Add first-class SDK support for running terminal commands via the run_terminal_c ## 2025-10-21T02:59:05.311Z — align-agent-types (ea45eda) ### Original Agent Prompt -Unify the .agents local agent typing and examples with the repository’s established tool call and schema shapes. Ensure all tool calls use an input object (not args), and require JsonObjectSchema for input/output object schemas. Align the documentation comments and the three example agents under .agents/examples with these conventions without changing backend or common packages. ### Lessons - **Issue:** Example 01 used find_files with input.prompt; param name likely mismatched the tool schema, risking runtime/type errors. @@ -754,7 +718,6 @@ Update the agent builder and example agents to support a new starter custom agen - **Issue:** diff-reviewer-3 example text wasn’t aligned with streamlined flow (kept separate review message step). **Fix:** Merge intent into step 4 message (spawn explorer then review) and end with a single 'yield STEP_ALL'. -- **Issue:** Left unused symbols (e.g., DEFAULT_MODEL) in backend/src/templates/agents/agent-builder.ts. **Fix:** Remove or use unused constants/imports to avoid noUnusedLocals warnings after refactors. ## 2025-10-21T03:13:39.771Z — overhaul-agent-examples (bf5872d) @@ -808,7 +771,6 @@ Simplify the agent validation flow to not require authentication and to use an a ## 2025-10-21T03:17:32.159Z — migrate-agents (02ef7c0) ### Original Agent Prompt -Migrate custom agent scaffolding to a first-class .agents directory and shift file generation to the CLI. Add TypeScript type modules for agent definitions and tools under .agents/types, include a starter agent and three example diff reviewers, and provide a concise README for users. Update the backend agent builder to be model-only (no file I/O) and embed the type content for reference in its instructions. Remove legacy type/example copies in common, fix imports across common and sdk to point at the canonical types exported by common/src/types, and adjust the CLI to create the .agents directories/files using bundled text imports. Ensure the example agents use the modern model and spawnable agent IDs, and streamline their step flow. ### Lessons - **Issue:** Did not add .agents/types modules; used inline .d.ts strings from CLI scaffolding. @@ -860,10 +822,8 @@ Migrate the AgentState structure to use a 'subagents' array instead of 'spawnabl ### Lessons - **Issue:** Missed migrating async spawn handler: spawn-agents-async.ts still sets AgentState.spawnableAgents: []. - **Fix:** Update backend/src/tools/handlers/tool/spawn-agents-async.ts to set subagents: [] when constructing child AgentState. - **Issue:** Tests not updated: sandbox-generator.test.ts still builds AgentState with spawnableAgents: []. - **Fix:** Change mock AgentState in backend/src/__tests__/sandbox-generator.test.ts to use subagents: [] to match AgentStateSchema. - **Issue:** JSDoc for spawnable agent IDs is vague; doesn’t mandate fully-qualified IDs with publisher and version. **Fix:** Update docs to require 'publisher/name@version' or local '.agents' id. Mirror this in common/src/util/types/agent-config.d.ts. @@ -913,7 +873,6 @@ We need to let our internal .agents declare a superset of tools (including some ## 2025-10-21T03:26:22.005Z — migrate-agent-validation (2b5651f) ### Original Agent Prompt -Move dynamic agent validation out of the WebSocket init path and into a dedicated authenticated web API, and have the CLI validate locally loaded agents through that API when a user is logged in. Introduce a small CLI utility to call the API and print any validation warnings. Update the project file context to load local agent configs directly at initialization and avoid mixing agent templates into knowledge files. Finally, simplify the server init response to just usage data so the CLI no longer expects WebSocket-delivered agent names or validation messages. ### Lessons - **Issue:** API route expects 'agents' but CLI util posts 'agentConfigs' (utils/agent-validation.ts) → 400s get swallowed. @@ -926,7 +885,6 @@ Move dynamic agent validation out of the WebSocket init path and into a dedicate **Fix:** Send Cookie: next-auth.session-token (like other CLI calls); drop authToken from body. - **Issue:** dynamic-agents.knowledge.md was not removed; stale doc risks being ingested as knowledge. - **Fix:** Delete backend/src/templates/dynamic-agents.knowledge.md to avoid mixing templates into knowledge. - **Issue:** ProjectFileContext still sources agentTemplates from global loadedAgents (implicit state). **Fix:** Assign agentTemplates from await loadLocalAgents(...) return; avoid globals to prevent staleness. @@ -940,11 +898,9 @@ Move dynamic agent validation out of the WebSocket init path and into a dedicate ## 2025-10-21T03:30:33.249Z — relocate-ws-errors (70239cb) ### Original Agent Prompt -Move WebSocket action send error handling out of the shared library and into the CLI app. The shared WebSocket client should no longer terminate the process on send failures; it should just propagate errors. In the CLI, add a small wrapper around action sends that logs a concise error, prints a helpful update message telling the user to update to the latest version, and exits. Replace the direct action send calls in the CLI with this wrapper so all action sends are covered. Leave the SDK and backend untouched. ### Lessons - **Issue:** Wrapper sendActionOrExit initially called itself, causing infinite recursion and potential stack overflow. - **Fix:** Call this.webSocket.sendAction(action) inside the wrapper; run a quick manual/test call to catch recursion bugs early. - **Issue:** Wrapper returned Promise|void with a thenable check, making behavior/contract unclear and harder to reason about. **Fix:** Implement wrapper as async and always await sendAction; explicitly return Promise and catch/exit on errors. @@ -953,10 +909,8 @@ Move WebSocket action send error handling out of the shared library and into the **Fix:** Stop the spinner before exiting: Spinner.get().stop(); then log the error, print update guidance, and process.exit(1). - **Issue:** No explicit verification that all CLI sendAction call sites were wrapped (only client.ts was updated). - **Fix:** Run a CLI-wide search for sendAction/webSocket.sendAction and replace all with the wrapper to ensure total coverage. - **Issue:** If socket isn’t OPEN, sendAction returns undefined; wrapper gives no feedback, so failed sends silently noop. - **Fix:** Detect non-Promise return and log a warning (e.g., logger.warn) that the WebSocket isn’t open and the action wasn’t sent. ## 2025-10-21T03:34:04.751Z — bundle-agent-types (5484add) @@ -991,7 +945,6 @@ Internalize the AgentConfig definition and related tool type definitions within ## 2025-10-21T03:34:42.036Z — fork-read-files (349a140) ### Original Agent Prompt -Decouple the SDK’s file reading from the npm app. Add an internal SDK helper that reads files relative to the client’s working directory, enforces a reasonable size limit, and returns standardized status markers for missing, too-large, out-of-bounds, or error cases. Update the SDK client to use this helper and pass its cwd. Preserve the response shape and status values expected by the backend. Avoid introducing dependencies on the npm app. ### Lessons - **Issue:** sdk/src/tools/read-files.ts keyed results by originalPath, risking mismatch if server sends absolute paths. @@ -1036,7 +989,6 @@ In the SDK package, move the agent/tool type definitions into a new src/types di ## 2025-10-21T03:37:19.438Z — stream-event-bridge (e3c563e) ### Original Agent Prompt -Enhance the SDK client so that callers can optionally receive streamed structured events during a run. Add an optional event handler to the run API that gets called with structured streaming events, and wire the WebSocket response streaming to deliver those events for the corresponding prompt. Ensure WebSocket errors are surfaced via the provided error callback. Also fix the file change tool to import the patch utility using the correct relative path in this monorepo. ### Lessons - **Issue:** Event handlers aren’t cleared on non-success paths (schema fail, action-error, cancel, reconnect), risking leaks in promptIdToEventHandler. @@ -1051,7 +1003,6 @@ Enhance the SDK client so that callers can optionally receive streamed structure ## 2025-10-21T03:37:33.756Z — spawn-inline-agent (dac33f3) ### Original Agent Prompt -Add a new tool that lets an agent spawn a child agent inline, sharing the current conversation history and returning control after the child ends its turn. Register the tool across shared schemas and backend registries, implement the handler to run the child agent within the same message list, and ensure no separate tool result is emitted—the shared history updates are the effect. Update tests to cover inline spawning, message deletion via set_messages, and TTL-based expiration of temporary prompts. Preserve subagent permission checks and schema validation for prompt and params. ### Lessons - **Issue:** Inline handler didn’t expire 'userPrompt' TTL after child finishes, leaving temporary prompts in history. @@ -1111,7 +1062,6 @@ Unify the agent-builder system into a single builder, update agent type definiti - **Issue:** Unified the wrong builder: removed agent_builder and kept base_agent_builder across registry/types/personas. **Fix:** Keep agent_builder as the single builder, remove base_agent_builder and update all refs to AgentTemplateTypes.agent_builder. -- **Issue:** Registry removed agent_builder entry, leaving no unified builder registered (backend/src/templates/agent-list.ts). **Fix:** In agent-list.ts, import and register ./agents/agent-builder as AgentTemplateTypes.agent_builder; drop base_agent_builder. - **Issue:** CLI flows still target base_agent_builder (npm-app/src/cli-handlers/agent-creation-chat.ts, agents.ts). @@ -1136,7 +1086,6 @@ Unify the agent-builder system into a single builder, update agent type definiti **Fix:** Create .agents/examples/diff-reviewer-{1,2,3}.ts; ensure correct imports; builder should copy them into that folder. - **Issue:** Builder didn’t reliably prepare .agents/examples and copy correct example set from common. - **Fix:** In backend agent-builder, mkdir -p .agents/types and .agents/examples; copy diff-reviewer-* from common into examples. - **Issue:** Builder/types sync gap: updated common and sdk types but not the local .agents/types used by user agents. **Fix:** Have the builder write current common types into .agents/types (agent-config.d.ts, tools.d.ts) so locals compile. @@ -1187,7 +1136,6 @@ Remove the inter-agent messaging capability and references from the codebase. El ### Lessons - **Issue:** Left send-agent-message files as empty modules (export {}) instead of deleting them. - **Fix:** Physically delete these files: backend/.../definitions/tool/send-agent-message.ts, handlers/.../send-agent-message.ts, common/.../params/tool/send-agent-message.ts. - **Issue:** AsyncAgentManager still contains messaging scaffolding (AsyncAgentMessage, messageQueues, send/get methods). **Fix:** Remove messaging types/methods entirely; keep only spawn/lifecycle tracking. Update triggerAgentIfIdle to not depend on message paths. @@ -1207,17 +1155,12 @@ Remove the inter-agent messaging capability and references from the codebase. El ## 2025-10-21T03:46:07.716Z — add-input-apis (958f296) ### Original Agent Prompt -Add high-level SDK support for sending and canceling user inputs over WebSocket. Provide methods to submit a user prompt (including default metadata like fingerprint and auth) and to cancel an in-flight prompt using its ID. Also remove any unused client action related to commit message generation from the shared action schema, ensuring only the supported client actions remain. ### Lessons - **Issue:** sendPrompt/cancelUserInput depend on init() to set auth/fingerprint. If init isn’t called, auth is missing and cancel throws. - **Fix:** Accept apiKey (auth) at WebSocketHandler construction and merge defaults per call (like getInputDefaultOptions). Don’t rely on init. -- **Issue:** WebSocketHandlerOptions lacks an apiKey param; SDK cannot set auth defaults unless init is called first. - **Fix:** Add apiKey to WebSocketHandlerOptions, store it on the class, and use it for all prompt/cancel actions by default. - **Issue:** Removed export * from './types' in sdk/src/index.ts, an unrelated API change that can break consumers. - **Fix:** Only add the WebSocketHandler export. Avoid removing existing exports unless proven broken via build/tests or code search. - **Issue:** init-response unsubscribe uses an awkward self-reference with try/catch; easy to get wrong and hard to read. **Fix:** Capture unsubscribe with let and call unsubscribe?.() in the callback. Avoid self-referential try/catch for cleaner, safer code. @@ -1269,11 +1212,9 @@ Update the agent selection and loading behavior so that choosing a specific agen ### Original Agent Prompt Refactor the agent loading and validation flow. -Backend: When fetching an agent from the database, validate the raw template using the simple agent ID (not the composite publisher/agent@version) to satisfy the schema, then set the full composite ID on the final template before returning it. Adjust logs accordingly so validation errors don’t log a full ID and successes log the correct full ID. CLI: Load local agents only when no specific --agent is requested. Ensure the configuration is loaded at the right time and avoid referencing it before it exists. Display loaded agents only after the config is read in that conditional path. Keep the overall startup sequence intact. -Also, apply a small readability improvement to the assembleLocalAgentTemplates destructuring in the WebSocket action without changing behavior. ### Lessons - **Issue:** Validated DB agents with raw template.id unchanged; if DB stored a composite id, schema validation/logging would use the full ID. @@ -1294,25 +1235,15 @@ Also, apply a small readability improvement to the assembleLocalAgentTemplates d ## 2025-10-21T03:53:43.724Z — simplify-sdk-api (3960e5f) ### Original Agent Prompt -Expose the primary realtime client and session initializer directly from the SDK entrypoint, and simplify the WebSocket client’s consumption by making its callback hooks optional with sensible defaults. Update typings so internals never see undefined callbacks, and ensure imports/exports align with the shared types in the common package. Keep runtime behavior consistent while reducing required boilerplate for SDK consumers. ### Lessons -- **Issue:** sdk/src/index.ts exported APIRealtimeClient (low-level) and omitted WebSocketHandler, the intended primary client. - **Fix:** Re-export WebSocketHandler from './websocket-client' and getInitialSessionState; do not expose APIRealtimeClient from the SDK. -- **Issue:** Leaked common internals via SDK by re-exporting '../../common/src/websockets/websocket-client'. - **Fix:** Keep APIRealtimeClient internal to SDK; expose only the high-level WebSocketHandler to guide consumers to the right abstraction. - **Issue:** Primary client exposure diverged from the shared SDK surface expected by consumers, increasing misuse risk. - **Fix:** Treat WebSocketHandler as the public realtime client. Make it the single entrypoint export for realtime usage from the SDK. -- **Issue:** SDK entrypoint doesn’t export WebSocketHandler, forcing deep imports to use the simplified handler. - **Fix:** Add `export { WebSocketHandler } from './websocket-client'` in sdk/src/index.ts to provide a clean public API. -## 2025-10-21T03:54:01.674Z — sdk-websocket-integration (a9fe09f) ### Original Agent Prompt -Refactor the SDK to support first-class WebSocket-based interactions and remove deprecated action flows across the codebase. Introduce environment-based URLs in the SDK, add a WebSocket handler that integrates with the shared realtime client, and clean up the npm app to stop listening for removed events. Align shared action schemas to drop legacy message types and update websocket error typing. Keep the public surface minimal and strongly typed, and deprecate the old process-based SDK client methods. ### Lessons - **Issue:** common/src/actions.ts: Only removed some legacy server actions; left ResponseCompleteSchema, 'tool-call', 'commit-message-response'. @@ -1321,11 +1252,7 @@ Refactor the SDK to support first-class WebSocket-based interactions and remove - **Issue:** npm-app/src/client.ts still defines generateCommitMessage and listens for 'commit-message-response' (removed action). **Fix:** Delete generateCommitMessage and its 'commit-message-response' subscription. Remove any sendAction('generate-commit-message'). -- **Issue:** Updated ack.error to accept objects in common/websockets/websocket-schema.ts but client still assumes string in websocket-client.ts. - **Fix:** Update common/websockets/websocket-client.ts receiveMessage to handle string|{code,message}, constructing Error from object.message. -- **Issue:** SDK lacks a WebSocket handler integrating init/read-files/tool-call with APIRealtimeClient as requested. - **Fix:** Add sdk/src/websocket-client.ts (WebSocketHandler) wiring: init, read-files, tool-call-request/response, response-chunk, prompt-response. - **Issue:** Breaking SDK changes (process APIs deprecated) published without version bump (sdk/package.json unchanged). **Fix:** Bump SDK semver (e.g., 0.1.0) in sdk/package.json to signal breaking changes and update changelog/README with migration notes. @@ -1333,8 +1260,6 @@ Refactor the SDK to support first-class WebSocket-based interactions and remove - **Issue:** SDK public surface still exports legacy types via './types' (ChatContext/NewChatOptions), inflating API. **Fix:** Limit exports to needed types (ClientAction/ServerAction). Remove or mark legacy types @deprecated and stop exporting them publicly. -- **Issue:** common/websockets/websocket-client.ts onError signature not aligned to event payload (GT uses WebSocket.ErrorEvent). - **Fix:** Change onError to (event: WebSocket.ErrorEvent) and propagate types to SDK options; pass event through from ws.onerror. ## 2025-10-21T03:58:40.843Z — server-agent-validation (926a98c) @@ -1342,8 +1267,6 @@ Refactor the SDK to support first-class WebSocket-based interactions and remove Move dynamic agent template validation to the server. Accept raw agent templates from the client without local validation, and perform all schema parsing, normalization, and error reporting on the server before use. Ensure error messages are concise and include the agent context, enforce that spawning subagents requires the appropriate tool, and make IDs and tests consistent with the schema. Remove validation from the npm-side loader while still stringifying any handleSteps function so the server can validate it. ### Lessons -- **Issue:** Validation was split: backend pre-parsed with DynamicAgentTemplateSchema then validateAgents, duplicating logic and diverging paths. - **Fix:** Centralize parsing/normalization in validateSingleAgent; let validateAgents accept raw objects and call it from backend directly. - **Issue:** validateSingleAgent didn't parse via DynamicAgentConfigSchema or stringify handleSteps; relied on external pre-parse. **Fix:** Inside validateSingleAgent: parse with DynamicAgentConfigSchema, stringify handleSteps, then apply DynamicAgentTemplateSchema. @@ -1363,8 +1286,6 @@ Move dynamic agent template validation to the server. Accept raw agent templates - **Issue:** validateAgents kept type Record, forcing pre-parse and blocking raw acceptance. **Fix:** Update validateAgents signature to Record, then parse inside validateSingleAgent with agent-context errors. -- **Issue:** Backend assembleLocalAgentTemplates did schema parsing then called validateAgents, causing double validation. - **Fix:** Remove backend pre-parse; pass raw templates to common validateAgents which fully parses and validates once. - **Issue:** Introduced unrelated bun.lock/version changes (e.g., @codebuff/sdk), risking regressions and noisy diffs. **Fix:** Avoid lockfile/version updates unless required by the change; keep the PR minimal and scoped to validation move. @@ -1426,7 +1347,6 @@ Bring agent, type, and rendering behavior into alignment across the project. Upd Add a new suite of open‑source–only agents for orchestration, coding, file discovery, research, review, and deep thinking under a dedicated namespace, using appropriate open‑source model IDs. Update the OpenRouter integration so that provider fallbacks are enabled for non‑explicit model strings but disabled for known, explicitly defined models. Introduce a small shared utility to detect whether a model is explicitly defined and use it to make cache‑control decisions. Keep changes minimal and consistent with existing agent patterns and prompts. ### Lessons -- **Issue:** Added OSS agents via backend/src/templates/agent-list.ts, not as .agents/opensource/* files; diverged from existing agent pattern. **Fix:** Create .agents/opensource/{base,coder,file-picker,researcher,reviewer,thinker}.ts configs following the .agents file-based template style. - **Issue:** No dedicated 'coder' agent was added despite the request for a coding role. @@ -1442,7 +1362,6 @@ Add a new suite of open‑source–only agents for orchestration, coding, file d **Fix:** Author systemPrompt/instructionsPrompt/stepPrompt per OSS agent mirroring .agents prompts for coding, research, review, thinking. - **Issue:** Registry edits changed core template map; not minimal compared to additive .agents files. - **Fix:** Prefer additive .agents/opensource files and leave backend/src/templates/agent-list.ts unchanged to keep changes minimal. - **Issue:** Cache-control utility wasn’t a standalone shared helper as requested; lives inside constants. **Fix:** Expose a tiny shared util (common/src/util/model-utils.ts) and make supportsCacheControl delegate to it to centralize logic. @@ -1501,7 +1420,6 @@ Refactor programmatic agent step handling so that generators receive only the la - **Issue:** Altered the broader tool pipeline by changing generator input shape everywhere, violating minimal-change intent. **Fix:** Limit refactor to runner passing latest result text while keeping other APIs and tool execution pipeline intact. -- **Issue:** Updated backend/src/templates/agents/researcher.ts instead of .agents/researcher.ts for web_search defaults. **Fix:** Modify .agents/researcher.ts to default query to '' and depth to 'standard' per requirement. - **Issue:** In run-programmatic-step, passed toolResult?.result but left toolResult typed/used as object, causing inconsistency. @@ -1519,8 +1437,6 @@ Refactor programmatic agent step handling so that generators receive only the la - **Issue:** Mixed result wrapper and string semantics in tests and code, creating ambiguity and potential runtime errors. **Fix:** Adopt a single convention: latest result as string; remove wrapper field access and adjust all call sites coherently. -- **Issue:** Edited both backend and .agents file-explorer templates inconsistently, risking duplicate or conflicting logic. - **Fix:** Update the canonical template (backend/src/templates/agents/file-explorer.ts) and avoid redundant .agents edits. - **Issue:** Did not verify/update QuickJS sandbox tests/usages that expect the old wrapper shape. **Fix:** Audit sandbox-related tests/usages and either keep wrapper for sandbox or update sandbox + tests to string input. @@ -1565,10 +1481,8 @@ High-level goals: - Adjust tests to use the new validation approach (spy on validateAgents/validateSingleAgent) and to expect full agent IDs in subagents. - Clean up docs/examples to reflect subagents-only and explicit IDs. -Do not include implementation details in your response; focus on ensuring all locations using the old model are migrated to the new one consistently across backend, common, and web. ### Lessons -- **Issue:** No code changes were applied; migration not executed across backend/common/web. **Fix:** Implement required removals/updates and commit diffs; verify via updated tests and docs. - **Issue:** Overrides schema and references remained (e.g., common/src/types/agent-overrides.ts, docs UI). @@ -1589,8 +1503,6 @@ Do not include implementation details in your response; focus on ensuring all lo - **Issue:** Tests didn’t adopt new validation approach (no spying on validateAgents/validateSingleAgent). **Fix:** Update tests to spy/mock validateAgents/validateSingleAgent and assert new behavior. -- **Issue:** Backend registry tests weren’t updated for new flow (static agent-list, DB validation, caching). - **Fix:** Update backend/src/__tests__/agent-registry.test.ts to spy on validation, mock DB, add malformed/caching cases. - **Issue:** Tests still expected normalized subagent IDs (e.g., 'git-committer'). **Fix:** Expect full agent IDs with publisher prefix (e.g., 'CodebuffAI/git-committer') in tests. @@ -1601,8 +1513,6 @@ Do not include implementation details in your response; focus on ensuring all lo - **Issue:** Web schema-display still exposed AgentOverrideSchemaDisplay. **Fix:** Remove override schema display and its imports/exports; keep only DynamicAgentTemplate/Config schemas. -- **Issue:** Backend runtime strings still referenced parentInstructions plumbing. - **Fix:** Remove collection/injection of parentInstructions in backend templates/strings. - **Issue:** Agent-name resolver still normalized IDs when listing/resolving. **Fix:** Return IDs verbatim in resolver; drop normalization; ensure mapping uses exact IDs. @@ -1624,4 +1534,3 @@ Do not include implementation details in your response; focus on ensuring all lo - **Issue:** Docs/examples not fixed to valid JSON after removing parentInstructions. **Fix:** Remove dangling keys/braces and ensure examples compile; replace spawnableAgents with subagents. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00cdf9e881..49b637f90d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ Before you begin, you'll need to install a few tools: Now, you should be able to run the CLI and send commands, but it will error out because you don't have any credits. - **Note**: CLI requires both backend and web server running for authentication. + **Note**: CLI requires the web server running for authentication. 6. **Giving yourself credits**: @@ -123,9 +123,8 @@ In order to run the CLI from other directories, you need to first publish the ag Codebuff is organized as a monorepo with these main packages: -- **backend/**: WebSocket server, LLM integration, agent orchestration -- **npm-app/**: CLI application that users interact with - **web/**: Next.js web application and dashboard +- **cli/**: CLI application that users interact with - **python-app/**: Python version of the CLI (experimental) - **common/**: Shared code, database schemas, utilities - **sdk/**: TypeScript SDK for programmatic usage @@ -225,7 +224,7 @@ Build specialized agents in `.agents/` for different languages, frameworks, or w ### 🔧 **Tool System** -Add new capabilities in `backend/src/tools.ts` - file operations, API integrations, development environment helpers. The sky's the limit! +Add new capabilities in `common/src/tools` and the SDK helpers - file operations, API integrations, development environment helpers. The sky's the limit! ### 📦 **SDK Improvements** @@ -233,7 +232,7 @@ Make the SDK in `sdk/` even more powerful with new methods, better TypeScript su ### 💻 **CLI Magic** -Enhance the user experience in `npm-app/` with smoother commands, better error messages, or interactive features that make developers smile. +Enhance the user experience in `cli/` with smoother commands, better error messages, or interactive features that make developers smile. ### 🌐 **Web Dashboard** diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index 86727290d3..0000000000 --- a/backend/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -.env -prompt.debug.json -debug.log \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 5e5858f36e..0000000000 --- a/backend/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Codebuff Backend - -## Deployment - -To deploy the Codebuff backend to Google Cloud Platform, follow these steps: - -1. Set up a Google Cloud Platform project and enable the necessary APIs. See: https://cloud.google.com/sdk/docs/install. -2. Login to the Google Cloud Platform CLI with the command: - `gcloud auth login`. -3. Set your project ID: to `manicode-430317` - `gcloud config set project manicode-430317` -4. Configure Docker to push to gcloud. - `gcloud auth configure-docker` -5. Run ./deploy.sh to build and push the Docker image to Google Container Registry. - -## Database - -### Environment Variable - -- `DATABASE_URL`: Set this environment variable in your .env file to connect to the database. - -### Migrations - -- Migrations are stored in the `./drizzle` folder. -- Run migrations using the `migrate` script in package.json. diff --git a/backend/knowledge.md b/backend/knowledge.md deleted file mode 100644 index bd26cc26fa..0000000000 --- a/backend/knowledge.md +++ /dev/null @@ -1,180 +0,0 @@ -# Backend Knowledge - -## Agent System - -### Agent Validation - -Users can now reference spawnable agents without org prefixes in their agent templates. For example: - -- ✅ `"spawnableAgents": ["git-committer", "brainstormer"]` -- ✅ `"spawnableAgents": ["CodebuffAI/git-committer", "brainstormer"]` - -Both formats are valid. The validation system in `common/src/util/agent-template-validation.ts` normalizes agent names by stripping the default `CodebuffAI/` prefix during validation, making it user-friendly while maintaining internal consistency. - -### Key Files - -- `common/src/util/agent-template-validation.ts`: Core validation logic for agent templates -- `backend/src/templates/dynamic-agent-service.ts`: Loads and validates user-defined agents -- `backend/src/templates/agent-registry.ts`: Global registry combining static and dynamic agents -- `common/src/util/agent-name-normalization.ts`: Utilities for normalizing agent names - -## Agent Template Override System - -The agent template override system allows users to customize agent behavior by placing configuration files in the `.agents/templates/` directory of their project. - -### Architecture Flow - -```mermaid -flowchart TD - A[User creates .agents/templates/override.json] --> B[npm-app: File Discovery] - B --> C[npm-app: Zod Schema Validation] - C --> D{Valid?} - D -->|No| E[Log Warning & Skip File] - D -->|Yes| F[Include in Project Context] - F --> G[Backend: processAgentOverrides] - G --> H[findOverrideFiles] - H --> I{File matches agent type?} - I -->|No| J[Skip Override] - I -->|Yes| K[applyOverride] - K --> L[Handler Mapping] - L --> M[Apply Model Override] - L --> N[Apply Prompt Overrides] - L --> O[Apply Array Overrides] - M --> P[Return Modified Template] - N --> P - O --> P - P --> Q[Agent Uses Customized Template] - - style A fill:#e1f5fe - style C fill:#fff3e0 - style G fill:#f3e5f5 - style L fill:#e8f5e8 - style Q fill:#fff8e1 -``` - -### Architecture - -- **Location**: Override files are stored in `.agents/templates/` directory -- **File Types**: JSON configuration files (`.json`) and Markdown content files (`.md`) -- **Validation**: All override files are validated using Zod schemas from `@codebuff/common/types/agent-overrides` -- **Processing**: Overrides are applied in file order, with later files taking precedence - -### Override Configuration Schema - -```typescript -{ - override: { - type: string, // e.g., "CodebuffAI/reviewer" - version: string, // e.g., "0.1.7" or "latest" - model?: string, // Override the model used - systemPrompt?: { // Modify system prompt - type: 'append' | 'prepend' | 'replace', - path?: string, // External file path - content?: string // Inline content - }, - instructionsPrompt?: { /* same structure */ }, - stepPrompt?: { /* same structure */ }, - spawnableAgents?: { // Modify spawnable agents list - type: 'append' | 'replace', - content: string | string[] - }, - toolNames?: { // Modify available tools - type: 'append' | 'replace', - content: string | string[] - } - } -} -``` - -### Implementation Details - -- **File Discovery**: `npm-app` validates override files before sending to backend -- **Type Matching**: Agent type matching uses simple suffix matching (e.g., "CodebuffAI/reviewer" → "reviewer") -- **External Files**: Prompt overrides can reference external `.md` files using relative paths -- **Error Handling**: Invalid files are logged and skipped, falling back to base templates -- **Data-Driven**: Uses handler mapping pattern for maintainable override application - -### Usage Example - -```json -// .agents/templates/custom-reviewer.json -{ - "override": { - "type": "CodebuffAI/reviewer", - "version": "0.1.7", - "systemPrompt": { - "type": "append", - "path": "./custom-review-instructions.md" - }, - "spawnableAgents": { - "type": "append", - "content": ["thinker"] - } - } -} -``` - -This system provides a flexible way for users to customize agent behavior without modifying core code. - -## Auto Top-up System - -The backend implements automatic credit top-up for users and organizations: - -- Triggers when balance falls below configured threshold -- Purchases credits to reach target balance -- Only activates if enabled and configured -- Automatically disables on payment failure -- Grants credits immediately while waiting for Stripe confirmation - -Key files: - -- `packages/billing/src/auto-topup.ts`: Core auto top-up logic -- `backend/src/websockets/middleware.ts`: Integration with request flow - -Middleware checks auto top-up eligibility when users run out of credits. If successful, the action proceeds automatically. - -Notifications: - -- Success: Send via usage-response with autoTopupAdded field -- Failure: Send via action-error with specific error type -- Both CLI and web UI handle these notifications appropriately - -## Billing System - -Credits are managed through: - -- Local credit grants in database -- Stripe for payment processing -- WebSocket actions for real-time updates - -### Transaction Isolation - -Critical credit operations use SERIALIZABLE isolation with automatic retries: - -- Credit consumption prevents "double spending" -- Monthly resets prevent duplicate grants -- Both retry on serialization failures (error code 40001) -- Helper: `withSerializableTransaction` in `common/src/db/transaction.ts` - -Other operations use default isolation (READ COMMITTED). - -## WebSocket Middleware System - -The middleware stack: - -1. Authenticates requests -2. Checks credit balance -3. Handles auto top-up if needed -4. Manages quota resets - -Each middleware can allow continuation, return an action, or throw an error. - -## Important Constants - -Key configuration values are in `common/src/constants.ts`. - -## Testing - -Run type checks: `bun run --cwd backend typecheck` - -For integration tests, change to backend directory to reuse environment variables from `env.mjs`. diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index 4232c3f296..0000000000 --- a/backend/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@codebuff/backend", - "version": "1.0.0", - "description": "Backend server for Codebuff", - "private": true, - "type": "module", - "exports": { - "./*": { - "bun": "./src/*.ts", - "import": "./src/*.ts", - "types": "./src/*.ts", - "default": "./src/*.ts" - } - }, - "scripts": { - "start": "bun src/index.ts", - "dev": "bun src/index.ts", - "typecheck": "tsc --noEmit -p .", - "test": "bun test" - }, - "sideEffects": false, - "engines": { - "bun": "^1.3.0" - }, - "dependencies": { - "@ai-sdk/google-vertex": "3.0.6", - "@ai-sdk/openai": "2.0.11", - "@codebuff/agent-runtime": "workspace:*", - "@codebuff/billing": "workspace:*", - "@codebuff/common": "workspace:*", - "@codebuff/internal": "workspace:*", - "@google-cloud/vertexai": "1.10.0", - "@google/generative-ai": "0.24.1", - "@jitl/quickjs-wasmfile-release-sync": "0.31.0", - "@openrouter/ai-sdk-provider": "1.1.2", - "ai": "5.0.0", - "cors": "^2.8.5", - "diff": "5.2.0", - "dotenv": "16.4.5", - "express": "4.19.2", - "gpt-tokenizer": "2.8.1", - "ignore": "5.3.2", - "lodash": "*", - "micromatch": "^4.0.8", - "openai": "^4.78.1", - "pino": "9.4.0", - "postgres": "3.4.4", - "posthog-node": "^4.14.0", - "quickjs-emscripten-core": "0.31.0", - "ts-pattern": "5.3.1", - "ws": "8.18.0", - "zod": "3.25.67", - "zod-from-json-schema": "0.4.2" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/diff": "^5.0.3", - "@types/express": "^4.17.13", - "@types/micromatch": "^4.0.9", - "@types/ws": "^8.5.5" - } -} diff --git a/backend/src/__tests__/agent-id-resolution.test.ts b/backend/src/__tests__/agent-id-resolution.test.ts deleted file mode 100644 index ee9c3f0cdc..0000000000 --- a/backend/src/__tests__/agent-id-resolution.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { AgentTemplateTypes } from '@codebuff/common/types/session-state' -import { - DEFAULT_ORG_PREFIX, - resolveAgentId, -} from '@codebuff/common/util/agent-name-normalization' -import { describe, expect, it, beforeEach } from 'bun:test' - -import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' - -describe('Agent ID Resolution', () => { - let mockRegistry: Record - beforeEach(() => { - mockRegistry = { - // Built-in agents - base: { - id: 'base', - displayName: 'Buffy', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - }, - [AgentTemplateTypes.file_picker]: { - id: AgentTemplateTypes.file_picker, - displayName: 'Fletcher', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['find_files'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - }, - // Spawnable agents with org prefix - [`${DEFAULT_ORG_PREFIX}git-committer`]: { - id: `${DEFAULT_ORG_PREFIX}git-committer`, - displayName: 'Git Committer', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'google/gemini-2.5-pro', - spawnerPrompt: 'Test', - inputSchema: {}, - }, - [`${DEFAULT_ORG_PREFIX}example-agent`]: { - id: `${DEFAULT_ORG_PREFIX}example-agent`, - displayName: 'Example Agent', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - }, - // Custom user agent without prefix - 'my-custom-agent': { - id: 'my-custom-agent', - displayName: 'My Custom Agent', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - }, - } - }) - - describe('Direct ID Resolution', () => { - it('should resolve built-in agent IDs directly', () => { - expect(resolveAgentId('base', mockRegistry)).toBe('base') - expect(resolveAgentId('file-picker', mockRegistry)).toBe('file-picker') - }) - - it('should resolve custom agent IDs directly', () => { - expect(resolveAgentId('my-custom-agent', mockRegistry)).toBe( - 'my-custom-agent', - ) - }) - - it('should resolve prefixed agent IDs directly', () => { - expect( - resolveAgentId(`${DEFAULT_ORG_PREFIX}git-committer`, mockRegistry), - ).toBe(`${DEFAULT_ORG_PREFIX}git-committer`) - }) - }) - - describe('Prefixed ID Resolution', () => { - it('should resolve unprefixed spawnable agent IDs by adding the default org prefix', () => { - expect(resolveAgentId('git-committer', mockRegistry)).toBe( - `${DEFAULT_ORG_PREFIX}git-committer`, - ) - expect(resolveAgentId('example-agent', mockRegistry)).toBe( - `${DEFAULT_ORG_PREFIX}example-agent`, - ) - }) - - it('should not add prefix to built-in agents', () => { - // Built-in agents should be found directly, not with prefix - expect(resolveAgentId('base', mockRegistry)).toBe('base') - expect(resolveAgentId('file-picker', mockRegistry)).toBe('file-picker') - }) - }) - - describe('Error Cases', () => { - it('should return null for non-existent agents', () => { - expect(resolveAgentId('non-existent', mockRegistry)).toBeNull() - expect( - resolveAgentId(`${DEFAULT_ORG_PREFIX}non-existent`, mockRegistry), - ).toBeNull() - }) - - it('should return null for empty agent ID', () => { - expect(resolveAgentId('', mockRegistry)).toBeNull() - }) - }) - - describe('Edge Cases', () => { - it('should handle agent IDs that already have different org prefixes', () => { - // Add an agent with a different org prefix - mockRegistry['OtherOrg/special-agent'] = { - id: 'OtherOrg/special-agent', - displayName: 'Special Agent', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - } - - // Should find it directly - expect(resolveAgentId('OtherOrg/special-agent', mockRegistry)).toBe( - 'OtherOrg/special-agent', - ) - - // Should not add default org prefix to it - expect(resolveAgentId('special-agent', mockRegistry)).toBeNull() - }) - - it('should handle agents with slashes in their names but no org prefix', () => { - // This is an edge case - an agent ID that contains a slash but isn't an org prefix - mockRegistry['weird/agent-name'] = { - id: 'weird/agent-name', - displayName: 'Weird Agent', - systemPrompt: 'Test', - instructionsPrompt: 'Test', - stepPrompt: 'Test', - mcpServers: {}, - toolNames: ['end_turn'], - spawnableAgents: [], - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - model: 'anthropic/claude-4-sonnet-20250522', - spawnerPrompt: 'Test', - inputSchema: {}, - } - - expect(resolveAgentId('weird/agent-name', mockRegistry)).toBe( - 'weird/agent-name', - ) - }) - }) -}) diff --git a/backend/src/__tests__/agent-run.test.ts b/backend/src/__tests__/agent-run.test.ts deleted file mode 100644 index d2c4bd7b76..0000000000 --- a/backend/src/__tests__/agent-run.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { - mockModule, - clearMockedModules, -} from '@codebuff/common/testing/mock-modules' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { - spyOn, - beforeEach, - afterEach, - afterAll, - describe, - expect, - it, - mock, - beforeAll, -} from 'bun:test' - -import { startAgentRun, finishAgentRun, addAgentStep } from '../agent-run' - -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '@codebuff/common/types/contracts/agent-runtime' - -describe('Agent Run Database Functions', () => { - let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps - - beforeEach(() => { - agentRuntimeImpl = { - ...TEST_AGENT_RUNTIME_IMPL, - } - agentRuntimeImpl.logger.error = mock(() => {}) - - // Setup spies for database operations - spyOn(db, 'insert').mockReturnValue({ - values: mock(() => Promise.resolve({ id: 'test-run-id' })), - } as any) - spyOn(db, 'update').mockReturnValue({ - set: mock(() => ({ - where: mock(() => Promise.resolve()), - })), - } as any) - }) - - afterEach(() => { - mock.restore() - }) - - // Mock drizzle-orm module - beforeAll(async () => { - await mockModule('drizzle-orm', () => ({ - eq: mock(() => 'eq-result'), - })) - }) - - afterAll(() => { - clearMockedModules() - }) - - describe('startAgentRun', () => { - it('should create a new agent run with generated ID when runId not provided', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - - // Mock crypto.randomUUID - spyOn(crypto, 'randomUUID').mockReturnValue('generated-uuid') - - const result = await startAgentRun({ - ...agentRuntimeImpl, - userId: 'user-123', - agentId: 'test-agent', - ancestorRunIds: ['parent-run-1', 'parent-run-2'], - }) - - expect(result).toBe('generated-uuid') - expect(db.insert).toHaveBeenCalledWith(schema.agentRun) - expect(mockValues).toHaveBeenCalledWith({ - id: 'generated-uuid', - user_id: 'user-123', - agent_id: 'test-agent', - ancestor_run_ids: ['parent-run-1', 'parent-run-2'], - status: 'running', - created_at: expect.any(Date), - }) - }) - - it('should handle missing userId gracefully', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('generated-uuid') - - await startAgentRun({ - ...agentRuntimeImpl, - agentId: 'test-agent', - ancestorRunIds: [], - }) - - expect(mockValues).toHaveBeenCalledWith({ - id: 'generated-uuid', - user_id: undefined, - agent_id: 'test-agent', - ancestor_run_ids: null, - status: 'running', - created_at: expect.any(Date), - }) - }) - - it('should convert empty ancestorRunIds to null', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('generated-uuid') - - await startAgentRun({ - ...agentRuntimeImpl, - agentId: 'test-agent', - ancestorRunIds: [], - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - ancestor_run_ids: null, - }), - ) - }) - - it('should preserve non-empty ancestorRunIds array', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('generated-uuid') - - await startAgentRun({ - ...agentRuntimeImpl, - agentId: 'test-agent', - ancestorRunIds: ['root-run', 'parent-run'], - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - ancestor_run_ids: ['root-run', 'parent-run'], - }), - ) - }) - - it('should handle database errors and log them', async () => { - const mockError = new Error('Database connection failed') - const mockValues = mock(() => Promise.reject(mockError)) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('generated-uuid') - - expect( - startAgentRun({ - ...agentRuntimeImpl, - agentId: 'test-agent', - ancestorRunIds: [], - }), - ).rejects.toThrow('Database connection failed') - - expect(agentRuntimeImpl.logger.error).toHaveBeenCalledWith( - { - error: mockError, - runId: 'generated-uuid', - userId: undefined, - agentId: 'test-agent', - ancestorRunIds: [], - }, - 'Failed to start agent run', - ) - }) - }) - - describe('finishAgentRun', () => { - it('should update agent run with completion data', async () => { - const mockWhere = mock(() => Promise.resolve()) - const mockSet = mock(() => ({ where: mockWhere })) - spyOn(db, 'update').mockReturnValue({ set: mockSet } as any) - - await finishAgentRun({ - ...agentRuntimeImpl, - userId: undefined, - runId: 'test-run-id', - status: 'completed', - totalSteps: 5, - directCredits: 150.5, - totalCredits: 300.75, - }) - - expect(db.update).toHaveBeenCalledWith(schema.agentRun) - expect(mockSet).toHaveBeenCalledWith({ - status: 'completed', - completed_at: expect.any(Date), - total_steps: 5, - direct_credits: '150.5', // Should be converted to string - total_credits: '300.75', // Should be converted to string - error_message: undefined, - }) - expect(mockWhere).toHaveBeenCalledWith('eq-result') - }) - - it('should handle failed status with error message', async () => { - const mockWhere = mock(() => Promise.resolve()) - const mockSet = mock(() => ({ where: mockWhere })) - spyOn(db, 'update').mockReturnValue({ set: mockSet } as any) - - await finishAgentRun({ - ...agentRuntimeImpl, - userId: undefined, - runId: 'test-run-id', - status: 'failed', - totalSteps: 3, - directCredits: 75.25, - totalCredits: 125.5, - errorMessage: 'Agent execution failed', - }) - - expect(mockSet).toHaveBeenCalledWith({ - status: 'failed', - completed_at: expect.any(Date), - total_steps: 3, - direct_credits: '75.25', - total_credits: '125.5', - error_message: 'Agent execution failed', - }) - }) - - it('should handle cancelled status', async () => { - const mockSet = mock(() => ({ where: mock(() => Promise.resolve()) })) - const mockWhere = mock(() => Promise.resolve()) - spyOn(db, 'update').mockReturnValue({ set: mockSet } as any) - mockSet.mockReturnValue({ where: mockWhere }) - - await finishAgentRun({ - ...agentRuntimeImpl, - userId: undefined, - runId: 'test-run-id', - status: 'cancelled', - totalSteps: 2, - directCredits: 50, - totalCredits: 100, - }) - - expect(mockSet).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'cancelled', - }), - ) - }) - - it('should handle database errors and log them', async () => { - const mockError = new Error('Update failed') - const mockSet = mock(() => ({ - where: mock(() => Promise.reject(mockError)), - })) - spyOn(db, 'update').mockReturnValue({ set: mockSet } as any) - - expect( - finishAgentRun({ - ...agentRuntimeImpl, - userId: undefined, - runId: 'test-run-id', - status: 'completed', - totalSteps: 5, - directCredits: 150, - totalCredits: 300, - }), - ).rejects.toThrow('Update failed') - - expect(agentRuntimeImpl.logger.error).toHaveBeenCalledWith( - { - error: mockError, - runId: 'test-run-id', - status: 'completed', - }, - 'Failed to finish agent run', - ) - }) - }) - - describe('addAgentStep', () => { - it('should create a new agent step with all optional fields', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - const result = await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 1, - credits: 25.5, - childRunIds: ['child-1', 'child-2'], - messageId: 'msg-456', - status: 'completed', - startTime, - }) - - expect(result).toBe('step-uuid') - expect(db.insert).toHaveBeenCalledWith(schema.agentStep) - expect(mockValues).toHaveBeenCalledWith({ - id: 'step-uuid', - agent_run_id: 'run-123', - step_number: 1, - status: 'completed', - credits: '25.5', // Should be converted to string - child_run_ids: ['child-1', 'child-2'], - message_id: 'msg-456', - error_message: undefined, - created_at: startTime, - completed_at: expect.any(Date), - }) - }) - - it('should handle minimal required fields only', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 2, - startTime, - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith({ - id: 'step-uuid', - agent_run_id: 'run-123', - step_number: 2, - status: 'completed', // Default status - credits: undefined, - child_run_ids: undefined, - message_id: null, - error_message: undefined, - created_at: startTime, - completed_at: expect.any(Date), - }) - }) - - it('should handle skipped status with error message', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 3, - status: 'skipped', - errorMessage: 'Step failed validation', - startTime, - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'skipped', - error_message: 'Step failed validation', - }), - ) - }) - - it('should handle running status', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 4, - status: 'running', - startTime, - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'running', - }), - ) - }) - - it('should handle credits as number and convert to string', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 5, - credits: 0, // Zero credits - startTime, - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - credits: '0', - }), - ) - }) - - it('should handle database errors and log them', async () => { - const mockError = new Error('Insert failed') - const mockValues = mock(() => Promise.reject(mockError)) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const startTime = new Date('2023-01-01T10:00:00Z') - - expect( - addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 6, - startTime, - messageId: null, - }), - ).rejects.toThrow('Insert failed') - - expect(agentRuntimeImpl.logger.error).toHaveBeenCalledWith( - { - error: mockError, - agentRunId: 'run-123', - stepNumber: 6, - }, - 'Failed to add agent step', - ) - }) - }) - - describe('Data Type Conversions', () => { - it('should properly convert numeric credits to strings for database storage', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 1, - credits: 123.456789, // High precision number - startTime: new Date(), - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - credits: '123.456789', // Should preserve precision as string - }), - ) - }) - - it('should handle timestamp conversion properly', async () => { - const mockValues = mock(() => Promise.resolve()) - spyOn(db, 'insert').mockReturnValue({ values: mockValues } as any) - spyOn(crypto, 'randomUUID').mockReturnValue('step-uuid') - - const specificStartTime = new Date('2023-01-01T10:30:45.123Z') - - await addAgentStep({ - ...agentRuntimeImpl, - userId: undefined, - agentRunId: 'run-123', - stepNumber: 1, - startTime: specificStartTime, - messageId: null, - }) - - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - created_at: specificStartTime, - completed_at: expect.any(Date), - }), - ) - }) - }) -}) diff --git a/backend/src/__tests__/auto-topup.test.ts b/backend/src/__tests__/auto-topup.test.ts deleted file mode 100644 index be6d767b9d..0000000000 --- a/backend/src/__tests__/auto-topup.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import * as billing from '@codebuff/billing' -import { checkAndTriggerAutoTopup } from '@codebuff/billing' -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' - -import type { Logger } from '@codebuff/common/types/contracts/logger' - -describe('Auto Top-up System', () => { - describe('checkAndTriggerAutoTopup', () => { - // Create fresh mocks for each test - let dbMock: ReturnType - let balanceMock: ReturnType - let validateAutoTopupMock: ReturnType - let grantCreditsMock: ReturnType - const logger: Logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, - } - - beforeAll(async () => { - // Set up default mocks - dbMock = mock(() => - Promise.resolve({ - auto_topup_enabled: true, - auto_topup_threshold: 100, - auto_topup_amount: 500, - stripe_customer_id: 'cus_123', - next_quota_reset: new Date(), - }), - ) - - // Mock the database - await mockModule('@codebuff/internal/db', () => ({ - default: { - query: { - user: { - findFirst: dbMock, - }, - }, - update: mock(() => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - })), - }, - })) - - // Mock Stripe payment intent creation - await mockModule('@codebuff/internal/util/stripe', () => ({ - stripeServer: { - paymentIntents: { - create: mock(() => - Promise.resolve({ - status: 'succeeded', - id: 'pi_123', - }), - ), - }, - }, - })) - }) - - beforeEach(() => { - balanceMock = mock(() => - Promise.resolve({ - usageThisCycle: 0, - balance: { - totalRemaining: 50, // Below threshold - totalDebt: 0, - netBalance: 50, - breakdown: {}, - }, - }), - ) - - validateAutoTopupMock = mock(() => - Promise.resolve({ - blockedReason: null, - validPaymentMethod: { - id: 'pm_123', - type: 'card', - card: { - exp_year: 2030, - exp_month: 12, - }, - }, - }), - ) - - grantCreditsMock = mock(() => Promise.resolve()) - - spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock) - spyOn(billing, 'validateAutoTopupStatus').mockImplementation( - validateAutoTopupMock, - ) - spyOn(billing, 'processAndGrantCredit').mockImplementation( - grantCreditsMock, - ) - }) - - afterEach(() => { - mock.restore() - }) - - afterAll(() => { - clearMockedModules() - }) - - it('should trigger top-up when balance below threshold', async () => { - // Replace direct call with capture of returned amount - const amount = await checkAndTriggerAutoTopup({ - userId: 'test-user', - logger, - }) - - // Should check user settings - expect(dbMock).toHaveBeenCalled() - - // Should check balance - expect(balanceMock).toHaveBeenCalled() - - // Should validate auto top-up status - expect(validateAutoTopupMock).toHaveBeenCalled() - - // Assert the top-up amount was as configured (500) - expect(amount).toBe(500) - }) - - it('should not trigger top-up when balance above threshold', async () => { - // Set up balance mock before the test - balanceMock = mock(() => - Promise.resolve({ - usageThisCycle: 0, - balance: { - totalRemaining: 200, // Above threshold - totalDebt: 0, - netBalance: 200, - breakdown: {}, - }, - }), - ) - - // Update the spies with the new mock implementations - spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock) - spyOn(billing, 'validateAutoTopupStatus').mockImplementation( - validateAutoTopupMock, - ) - spyOn(billing, 'processAndGrantCredit').mockImplementation( - grantCreditsMock, - ) - - // Capture return value (should be undefined) - const amount = await checkAndTriggerAutoTopup({ - userId: 'test-user', - logger, - }) - - // Should still check settings and balance - expect(dbMock).toHaveBeenCalled() - expect(balanceMock).toHaveBeenCalled() - - // Should not validate auto top-up when not needed - expect(validateAutoTopupMock.mock.calls.length).toBe(0) - - // No top-up triggered - expect(amount).toBeUndefined() - }) - - it('should handle debt by topping up max(debt, configured amount)', async () => { - // Set up balance mock before the test - balanceMock = mock(() => - Promise.resolve({ - usageThisCycle: 0, - balance: { - totalRemaining: 0, - totalDebt: 600, // More than configured amount - netBalance: -600, - breakdown: {}, - }, - }), - ) - - // Update the spies with the new mock implementations - spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock) - spyOn(billing, 'validateAutoTopupStatus').mockImplementation( - validateAutoTopupMock, - ) - spyOn(billing, 'processAndGrantCredit').mockImplementation( - grantCreditsMock, - ) - - // Capture the returned amount and assert debt coverage - const amount = await checkAndTriggerAutoTopup({ - userId: 'test-user', - logger, - }) - - expect(amount).toBe(600) - }) - - it('should disable auto-topup when validation fails', async () => { - // Set up validation failure mock - validateAutoTopupMock = mock(() => - Promise.resolve({ - blockedReason: 'No valid payment method found', - validPaymentMethod: null, - }), - ) - - // Update the spy with the new mock implementation - spyOn(billing, 'validateAutoTopupStatus').mockImplementation( - validateAutoTopupMock, - ) - - await expect( - checkAndTriggerAutoTopup({ - userId: 'test-user', - logger, - }), - ).rejects.toThrow('No valid payment method found') - - // Should have called validation - expect(validateAutoTopupMock).toHaveBeenCalled() - }) - - afterEach(() => { - mock.restore() - }) - }) -}) diff --git a/backend/src/__tests__/cost-aggregation.integration.test.ts b/backend/src/__tests__/cost-aggregation.integration.test.ts deleted file mode 100644 index 19aa11e740..0000000000 --- a/backend/src/__tests__/cost-aggregation.integration.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { disableLiveUserInputCheck } from '@codebuff/agent-runtime/live-user-inputs' -import { callMainPrompt, mainPrompt } from '@codebuff/agent-runtime/main-prompt' -import * as agentRegistry from '@codebuff/agent-runtime/templates/agent-registry' -import { TEST_USER_ID } from '@codebuff/common/old-constants' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { getInitialSessionState } from '@codebuff/common/types/session-state' -import { generateCompactId } from '@codebuff/common/util/string' -import { - spyOn, - beforeEach, - afterEach, - describe, - expect, - it, - mock, - beforeAll, -} from 'bun:test' - -import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' -import type { ServerAction } from '@codebuff/common/actions' -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '@codebuff/common/types/contracts/agent-runtime' -import type { SendActionFn } from '@codebuff/common/types/contracts/client' -import type { StreamChunk } from '@codebuff/common/types/contracts/llm' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' -import type { ProjectFileContext } from '@codebuff/common/util/file' -import type { Mock } from 'bun:test' - -const mockFileContext: ProjectFileContext = { - projectRoot: '/test', - cwd: '/test', - fileTree: [], - fileTokenScores: {}, - knowledgeFiles: {}, - gitChanges: { - status: '', - diff: '', - diffCached: '', - lastCommitMessages: '', - }, - changesSinceLastChat: {}, - shellConfigFiles: {}, - agentTemplates: { - base: { - id: 'base', - displayName: 'Base Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: false, - inheritParentSystemPrompt: false, - toolNames: ['spawn_agents'], - spawnableAgents: ['editor'], - systemPrompt: 'Base agent system prompt', - instructionsPrompt: 'Base agent instructions', - stepPrompt: 'Base agent step prompt', - }, - editor: { - id: 'editor', - displayName: 'Editor Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - toolNames: ['write_file'], - spawnableAgents: [], - systemPrompt: '', - instructionsPrompt: 'Editor agent instructions', - stepPrompt: 'Editor agent step prompt', - }, - }, - customToolDefinitions: {}, - systemInfo: { - platform: 'test', - shell: 'test', - nodeVersion: 'test', - arch: 'test', - homedir: '/home/test', - cpus: 1, - }, -} - -describe('Cost Aggregation Integration Tests', () => { - let mockLocalAgentTemplates: Record - let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps - let mainPromptBaseParams: ParamsExcluding - let callMainPromptBaseParams: ParamsExcluding - - beforeAll(() => { - disableLiveUserInputCheck() - }) - - beforeEach(async () => { - // Setup mock agent templates - mockLocalAgentTemplates = { - base: { - id: 'base', - displayName: 'Base Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: false, - inheritParentSystemPrompt: false, - mcpServers: {}, - toolNames: ['spawn_agents'], - spawnableAgents: ['editor'], - systemPrompt: 'Base agent system prompt', - instructionsPrompt: 'Base agent instructions', - stepPrompt: 'Base agent step prompt', - } satisfies AgentTemplate, - editor: { - id: 'editor', - displayName: 'Editor Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - mcpServers: {}, - toolNames: ['write_file'], - spawnableAgents: [], - systemPrompt: '', - instructionsPrompt: 'Editor agent instructions', - stepPrompt: 'Editor agent step prompt', - } satisfies AgentTemplate, - } - - // Mock LLM streaming - let callCount = 0 - const creditHistory: number[] = [] - agentRuntimeImpl = { - ...TEST_AGENT_RUNTIME_IMPL, - sendAction: mock(() => {}), - promptAiSdkStream: async function* (options) { - callCount++ - const credits = callCount === 1 ? 10 : 7 // Main agent vs subagent costs - creditHistory.push(credits) - - if (options.onCostCalculated) { - await options.onCostCalculated(credits) - } - - // Simulate different responses based on call - if (callCount === 1) { - // Main agent spawns a subagent - yield { - type: 'tool-call', - toolName: 'spawn_agents', - toolCallId: generateCompactId('test-id-'), - input: { - agents: [ - { - agent_type: 'editor', - prompt: 'Write a simple hello world file', - }, - ], - }, - } satisfies StreamChunk - } else { - // Subagent writes a file - yield { - type: 'tool-call', - toolName: 'write_file', - toolCallId: generateCompactId('test-id-'), - input: { - path: 'hello.txt', - instructions: 'Create hello world file', - content: 'Hello, World!', - }, - } satisfies StreamChunk - } - return 'mock-message-id' - }, - // Mock tool call execution - requestToolCall: async ({ toolName, input }) => { - if (toolName === 'write_file') { - return { - output: [ - { - type: 'json', - value: { - message: `File ${input.path} created successfully`, - }, - }, - ], - } - } - return { - output: [ - { - type: 'json', - value: { - message: 'Tool executed successfully', - }, - }, - ], - } - }, - // Mock file reading - requestFiles: async (params: { filePaths: string[] }) => { - const results: Record = {} - params.filePaths.forEach((path) => { - results[path] = path === 'hello.txt' ? 'Hello, World!' : null - }) - return results - }, - } - - mainPromptBaseParams = { - ...agentRuntimeImpl, - repoId: undefined, - repoUrl: undefined, - userId: TEST_USER_ID, - clientSessionId: 'test-session', - onResponseChunk: () => {}, - localAgentTemplates: mockLocalAgentTemplates, - signal: new AbortController().signal, - tools: {}, - } - - callMainPromptBaseParams = { - ...agentRuntimeImpl, - repoId: undefined, - repoUrl: undefined, - userId: TEST_USER_ID, - promptId: 'test-prompt', - clientSessionId: 'test-session', - signal: new AbortController().signal, - tools: {}, - } - - // Mock getAgentTemplate to return our mock templates - spyOn(agentRegistry, 'getAgentTemplate').mockImplementation( - async ({ agentId, localAgentTemplates }) => { - return localAgentTemplates[agentId] || null - }, - ) - }) - - afterEach(() => { - mock.restore() - }) - - it('should correctly aggregate costs across the entire main prompt flow', async () => { - const sessionState = getInitialSessionState(mockFileContext) - // Set the main agent to use the 'base' type which is defined in our mock templates - sessionState.mainAgentState.stepsRemaining = 10 - sessionState.mainAgentState.agentType = 'base' - - const action = { - type: 'prompt' as const, - prompt: 'Create a hello world file using a subagent', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - const result = await mainPrompt({ - ...mainPromptBaseParams, - action, - }) - - // Verify the total cost includes both main agent and subagent costs - const finalCreditsUsed = result.sessionState.mainAgentState.creditsUsed - // 10 for the first call, 7 for the subagent, 7*9 for the next 9 calls - expect(finalCreditsUsed).toEqual(80) - - // Verify the cost breakdown makes sense - expect(finalCreditsUsed).toBeGreaterThan(0) - expect(Number.isInteger(finalCreditsUsed)).toBe(true) - }) - - it('should include final cost in prompt response message', async () => { - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.agentType = 'base' - - const action = { - type: 'prompt' as const, - prompt: 'Simple task', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - // Call through websocket action handler to test full integration - await callMainPrompt({ - ...callMainPromptBaseParams, - action, - }) - - // Verify final cost is included in prompt response - const promptResponse = ( - callMainPromptBaseParams.sendAction as Mock - ).mock.calls - .map((call) => call[0].action) - .find((action: ServerAction) => action.type === 'prompt-response') as any - - expect(promptResponse).toBeDefined() - expect(promptResponse.promptId).toBe('test-prompt') - expect( - promptResponse.sessionState.mainAgentState.creditsUsed, - ).toBeGreaterThan(0) - }) - - it('should handle multi-level subagent hierarchies correctly', async () => { - // Mock a more complex scenario with nested subagents - let callCount = 0 - mainPromptBaseParams.promptAiSdkStream = async function* (options) { - callCount++ - - if (options.onCostCalculated) { - await options.onCostCalculated(5) // Each call costs 5 credits - } - - if (callCount === 1) { - // Main agent spawns first-level subagent - yield { - type: 'tool-call', - toolName: 'spawn_agents', - toolCallId: generateCompactId('test-id-'), - input: { - agents: [{ agent_type: 'editor', prompt: 'Create files' }], - }, - } satisfies StreamChunk - } else if (callCount === 2) { - // First-level subagent spawns second-level subagent - yield { - type: 'tool-call', - toolName: 'spawn_agents', - toolCallId: generateCompactId('test-id-'), - input: { - agents: [{ agent_type: 'editor', prompt: 'Write specific file' }], - }, - } satisfies StreamChunk - } else { - // Second-level subagent does actual work - yield { - type: 'tool-call', - toolName: 'write_file', - toolCallId: generateCompactId('test-id-'), - input: { - path: 'nested.txt', - instructions: 'Create nested file', - content: 'Nested content', - }, - } satisfies StreamChunk - } - - return 'mock-message-id' - } - - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.stepsRemaining = 10 - sessionState.mainAgentState.agentType = 'base' - - const action = { - type: 'prompt' as const, - prompt: 'Create a complex nested structure', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - const result = await mainPrompt({ - ...mainPromptBaseParams, - action, - }) - - // Should aggregate costs from all levels: main + sub1 + sub2 - const finalCreditsUsed = result.sessionState.mainAgentState.creditsUsed - // 10 calls from base agent, 1 from first subagent, 1 from second subagent: 12 calls total - expect(finalCreditsUsed).toEqual(60) - }) - - it('should maintain cost integrity when subagents fail', async () => { - // Mock scenario where subagent fails after incurring partial costs - let callCount = 0 - mainPromptBaseParams.promptAiSdkStream = async function* (options) { - callCount++ - - if (options.onCostCalculated) { - await options.onCostCalculated(6) // Each call costs 6 credits - } - - if (callCount === 1) { - // Main agent spawns subagent - yield { - type: 'tool-call', - toolName: 'spawn_agents', - toolCallId: generateCompactId('test-id-'), - input: { - agents: [{ agent_type: 'editor', prompt: 'This will fail' }], - }, - } satisfies StreamChunk - } else { - // Subagent fails after incurring cost - yield { - type: 'text', - text: 'Some response', - } satisfies StreamChunk - throw new Error('Subagent execution failed') - } - - return 'mock-message-id' - } - - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.agentType = 'base' - - const action = { - type: 'prompt' as const, - prompt: 'Task that will partially fail', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - let result - try { - result = await mainPrompt({ - ...mainPromptBaseParams, - action, - }) - } catch (error) { - // Expected to fail, but costs may still be tracked - } - - // Check costs - they should be captured even if execution fails - const finalCreditsUsed = result - ? result.sessionState.mainAgentState.creditsUsed - : sessionState.mainAgentState.creditsUsed - // Even if the test fails, some cost should be incurred by the main agent - expect(finalCreditsUsed).toBeGreaterThanOrEqual(0) // At minimum, no negative costs - }) - - it('should not double-count costs in complex scenarios', async () => { - // Track all saveMessage calls to ensure no duplication - const saveMessageCalls: any[] = [] - - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.agentType = 'base' - - const action = { - type: 'prompt' as const, - prompt: 'Complex multi-agent task', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - await mainPrompt({ - ...mainPromptBaseParams, - action, - }) - - // Verify no duplicate message IDs (no double-counting) - const messageIds = saveMessageCalls.map((call) => call.messageId) - const uniqueMessageIds = new Set(messageIds) - expect(messageIds.length).toBe(uniqueMessageIds.size) - - // Verify that costs are reasonable (not zero, not extremely high) - const finalCreditsUsed = sessionState.mainAgentState.creditsUsed - // Since we're using the websocket callMainPrompt which resets credits to 0, costs will be 0 - // This test verifies that the credit reset mechanism works as expected - expect(finalCreditsUsed).toBe(0) - }) - - it('should respect server-side state authority', async () => { - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.agentType = 'base' - - // Simulate malicious client sending manipulated creditsUsed - sessionState.mainAgentState.creditsUsed = 999999 - - const action = { - type: 'prompt' as const, - prompt: 'Simple task', - sessionState, - fingerprintId: 'test-fingerprint', - costMode: 'normal' as const, - promptId: 'test-prompt', - toolResults: [], - } - - // Call through websocket action to test server-side reset - await callMainPrompt({ - ...callMainPromptBaseParams, - action, - }) - - // Server should have reset the malicious value and calculated correct cost - const promptResponse = ( - agentRuntimeImpl.sendAction as Mock - ).mock.calls - .map((call) => call[0].action) - .find((action) => action.type === 'prompt-response') as any - - expect(promptResponse).toBeDefined() - expect(promptResponse.sessionState.mainAgentState.creditsUsed).toBeLessThan( - 1000, - ) // Reasonable value, not manipulated - expect( - promptResponse.sessionState.mainAgentState.creditsUsed, - ).toBeGreaterThan(0) // But still tracked correctly - }) -}) diff --git a/backend/src/__tests__/credit-conversion.test.ts b/backend/src/__tests__/credit-conversion.test.ts deleted file mode 100644 index e6e575eea7..0000000000 --- a/backend/src/__tests__/credit-conversion.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - convertCreditsToUsdCents, - convertStripeGrantAmountToCredits, -} from '@codebuff/common/util/currency' -import { describe, it, expect } from 'bun:test' - -describe('Credit Conversion System', () => { - describe('convertCreditsToUsdCents', () => { - it('should convert credits to cents correctly', () => { - expect(convertCreditsToUsdCents(100, 1)).toBe(100) // 100 credits at 1¢ each - expect(convertCreditsToUsdCents(50, 2)).toBe(100) // 50 credits at 2¢ each - expect(convertCreditsToUsdCents(1000, 0.5)).toBe(500) // 1000 credits at 0.5¢ each - }) - - it('should round up to nearest cent', () => { - expect(convertCreditsToUsdCents(3, 0.5)).toBe(2) // 1.5 rounds to 2 - expect(convertCreditsToUsdCents(1, 0.1)).toBe(1) // 0.1 rounds to 1 - }) - - it('should handle zero credits', () => { - expect(convertCreditsToUsdCents(0, 1)).toBe(0) - }) - - it('should handle fractional credit costs', () => { - expect(convertCreditsToUsdCents(10, 0.33)).toBe(4) // 3.3 rounds to 4 - }) - }) - - describe('convertStripeGrantAmountToCredits', () => { - it('should convert Stripe amount to credits correctly', () => { - expect(convertStripeGrantAmountToCredits(100, 1)).toBe(100) // $1.00 = 100 credits at 1¢ - expect(convertStripeGrantAmountToCredits(100, 2)).toBe(50) // $1.00 = 50 credits at 2¢ - }) - - it('should handle zero amount', () => { - expect(convertStripeGrantAmountToCredits(0, 1)).toBe(0) - }) - - it('should round down to nearest whole credit', () => { - expect(convertStripeGrantAmountToCredits(150, 1)).toBe(150) // $1.50 = 150 credits - expect(convertStripeGrantAmountToCredits(155, 1)).toBe(155) // $1.55 = 155 credits - }) - - it('should handle fractional credit costs', () => { - expect(convertStripeGrantAmountToCredits(100, 0.5)).toBe(200) // $1.00 = 200 credits at 0.5¢ - }) - }) -}) diff --git a/backend/src/__tests__/get-custom-file-picker-config.test.ts b/backend/src/__tests__/get-custom-file-picker-config.test.ts deleted file mode 100644 index 5a6089ce01..0000000000 --- a/backend/src/__tests__/get-custom-file-picker-config.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, mock as bunMockFn, beforeEach } from 'bun:test' - -// Test data -const validConfigString = JSON.stringify({ - modelName: 'ft_filepicker_005', - maxFilesPerRequest: 20, - customFileCounts: { normal: 10 }, -}) - -const invalidConfigString = JSON.stringify({ - modelName: 'this-is-definitely-not-a-valid-model-name-for-the-enum', - maxFilesPerRequest: 'not-a-number', -}) - -// Create a completely isolated test suite that doesn't depend on other modules -describe('getCustomFilePickerConfigForOrg', () => { - // Mock database query functions - const mockLimitFn = bunMockFn().mockResolvedValue([]) - const mockWhereFn = bunMockFn(() => ({ limit: mockLimitFn })) - const mockFromFn = bunMockFn(() => ({ where: mockWhereFn })) - const mockSelectFn = bunMockFn(() => ({ from: mockFromFn })) - const mockDb = { select: mockSelectFn } - - // Mock logger with proper types to accept any arguments - const mockLogger = { - info: bunMockFn((...args: any[]) => {}), - error: bunMockFn((...args: any[]) => {}), - warn: bunMockFn((...args: any[]) => {}), - debug: bunMockFn((...args: any[]) => {}), - } - - // Mock context - const mockGetRequestContext = bunMockFn(() => ({ - approvedOrgIdForRepo: 'org123', - isRepoApprovedForUserInOrg: true, - })) - - // Create a direct implementation of the function we're testing - // This avoids having to mock all dependencies and import the actual function - // Define a simple type for our config object - type CustomFilePickerConfig = { - modelName: string - maxFilesPerRequest?: number - customFileCounts?: Record - } - - async function getCustomFilePickerConfigForOrg( - orgId: string, - isRepoApprovedForUserInOrg: boolean | undefined, - ): Promise { - // Treat empty string as undefined for compatibility with the original function - if (!orgId || orgId === '' || !isRepoApprovedForUserInOrg) { - return null - } - - try { - const orgFeature = await mockDb - .select() - .from(/* schema.orgFeature */) - .where(/* conditions */) - .limit(1) - .then((rows: any[]) => rows[0]) - - if (orgFeature?.config && typeof orgFeature.config === 'string') { - try { - const parsedConfigObject = JSON.parse(orgFeature.config) - // Simulate validation - we'll just check if it has a valid modelName - if (parsedConfigObject.modelName === 'ft_filepicker_005') { - mockLogger.info('Using custom file picker configuration', { orgId }) - return parsedConfigObject - } else { - mockLogger.error('Invalid custom file picker configuration', { - parsedConfigObject, - }) - return null - } - } catch (jsonParseError) { - mockLogger.error('Failed to parse config', { error: jsonParseError }) - return null - } - } - } catch (error) { - mockLogger.error('Error fetching config', { error }) - } - return null - } - - beforeEach(() => { - // Reset all mocks before each test - mockSelectFn.mockClear() - mockFromFn.mockClear() - mockWhereFn.mockClear() - mockLimitFn.mockClear().mockResolvedValue([]) - mockLogger.info.mockClear() - mockLogger.error.mockClear() - mockLogger.warn.mockClear() - mockLogger.debug.mockClear() - mockGetRequestContext.mockClear().mockReturnValue({ - approvedOrgIdForRepo: 'org123', - isRepoApprovedForUserInOrg: true, - }) - }) - - it('should return null if orgId is empty', async () => { - mockGetRequestContext.mockReturnValue({ - approvedOrgIdForRepo: '', - isRepoApprovedForUserInOrg: true, - }) - // Pass empty string instead of undefined to satisfy TypeScript - const result = await getCustomFilePickerConfigForOrg('', true) - expect(result).toBeNull() - expect(mockSelectFn).not.toHaveBeenCalled() - }) - - it('should return null if isRepoApprovedForUserInOrg is false', async () => { - const result = await getCustomFilePickerConfigForOrg('org123', false) - expect(result).toBeNull() - expect(mockSelectFn).not.toHaveBeenCalled() - }) - - it('should return null if isRepoApprovedForUserInOrg is undefined', async () => { - const result = await getCustomFilePickerConfigForOrg('org123', undefined) - expect(result).toBeNull() - expect(mockSelectFn).not.toHaveBeenCalled() - }) - - it('should return null if orgFeature is not found', async () => { - // Ensure mockLimitFn returns empty array - mockLimitFn.mockResolvedValueOnce([]) - - const result = await getCustomFilePickerConfigForOrg('org123', true) - expect(result).toBeNull() - expect(mockSelectFn).toHaveBeenCalledTimes(1) - expect(mockFromFn).toHaveBeenCalledTimes(1) - expect(mockWhereFn).toHaveBeenCalledTimes(1) - expect(mockLimitFn).toHaveBeenCalledTimes(1) - }) - - it('should return null if orgFeature has no config', async () => { - mockLimitFn.mockResolvedValueOnce([{ config: null }]) - const result = await getCustomFilePickerConfigForOrg('org123', true) - expect(result).toBeNull() - expect(mockSelectFn).toHaveBeenCalledTimes(1) - }) - - it('should return parsed config if orgFeature has valid config', async () => { - mockLimitFn.mockResolvedValueOnce([{ config: validConfigString }]) - const result = await getCustomFilePickerConfigForOrg('org123', true) - const expectedParsedConfig = JSON.parse(validConfigString) - expect(result).toEqual(expectedParsedConfig) - expect(mockSelectFn).toHaveBeenCalledTimes(1) - expect(mockFromFn).toHaveBeenCalledTimes(1) - expect(mockWhereFn).toHaveBeenCalledTimes(1) - expect(mockLimitFn).toHaveBeenCalledTimes(1) - }) - - it('should return null and log error if orgFeature has invalid config', async () => { - mockLimitFn.mockResolvedValueOnce([{ config: invalidConfigString }]) - const result = await getCustomFilePickerConfigForOrg('org123', true) - expect(result).toBeNull() - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('should return null and log error if db query fails', async () => { - mockLimitFn.mockRejectedValueOnce(new Error('DB Error')) - const result = await getCustomFilePickerConfigForOrg('org123', true) - expect(result).toBeNull() - expect(mockLogger.error).toHaveBeenCalled() - }) -}) diff --git a/backend/src/__tests__/main-prompt.integration.test.ts b/backend/src/__tests__/main-prompt.integration.test.ts deleted file mode 100644 index 5bfd40df1d..0000000000 --- a/backend/src/__tests__/main-prompt.integration.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { mainPrompt } from '@codebuff/agent-runtime/main-prompt' -import { TEST_USER_ID } from '@codebuff/common/old-constants' - -// Mock imports needed for setup within the test -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { getToolCallString } from '@codebuff/common/tools/utils' -import { getInitialSessionState } from '@codebuff/common/types/session-state' -import { - assistantMessage, - jsonToolResult, -} from '@codebuff/common/util/messages' -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' - -import { BACKEND_AGENT_RUNTIME_IMPL } from '../impl/agent-runtime' - -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '@codebuff/common/types/contracts/agent-runtime' -import type { RequestToolCallFn } from '@codebuff/common/types/contracts/client' -import type { ParamsOf } from '@codebuff/common/types/function-params' -import type { PrintModeEvent } from '@codebuff/common/types/print-mode' -import type { ProjectFileContext } from '@codebuff/common/util/file' - -// --- Shared Mocks & Helpers --- - -const mockFileContext: ProjectFileContext = { - projectRoot: '/test', - cwd: '/test', - fileTree: [], - fileTokenScores: {}, - knowledgeFiles: {}, - gitChanges: { - status: '', - diff: '', - diffCached: '', - lastCommitMessages: '', - }, - changesSinceLastChat: {}, - shellConfigFiles: {}, - systemInfo: { - platform: 'test', - shell: 'test', - nodeVersion: 'test', - arch: 'test', - homedir: '/home/test', - cpus: 1, - }, - agentTemplates: {}, - customToolDefinitions: {}, -} - -// --- Integration Test with Real LLM Call --- -describe.skip('mainPrompt (Integration)', () => { - let mockLocalAgentTemplates: Record - let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps - - beforeEach(() => { - agentRuntimeImpl = { - ...TEST_AGENT_RUNTIME_IMPL, - ...BACKEND_AGENT_RUNTIME_IMPL, - } - - // Setup common mock agent templates - mockLocalAgentTemplates = { - base: { - id: 'base', - displayName: 'Base Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - toolNames: ['write_file', 'run_terminal_command'], - spawnableAgents: [], - systemPrompt: '', - instructionsPrompt: '', - stepPrompt: '', - }, - } - - agentRuntimeImpl.requestToolCall = mock( - async ({ - toolName, - input, - }: ParamsOf): ReturnType => ({ - output: [ - { - type: 'json', - value: `Tool call success: ${{ toolName, input }}`, - }, - ], - }), - ) - }) - - afterEach(() => { - mock.restore() - }) - - it('should delete a specified function while preserving other code', async () => { - const initialContent = `import { Message } from '@codebuff/common/types/message' -import { withCacheControl } from '@codebuff/common/util/messages' - -import { System } from '../llm-apis/claude' -import { OpenAIMessage } from '../llm-apis/openai-api' -import { logger } from './logger' -import { simplifyTerminalCommandResults } from './simplify-tool-results' -import { countTokensJson } from './token-counter' - -/** - * Wraps an array of messages with a system prompt for LLM API calls - * @param messages - Array of messages to wrap - * @param system - System prompt to prepend - * @returns Array with system message followed by provided messages - */ -export const messagesWithSystem = (messages: Message[], system: System) => - [{ role: 'system', content: system }, ...messages] as OpenAIMessage[] - -export function asSystemInstruction(str: string): string { - return \`\${str}\` -} - -export function asSystemMessage(str: string): string { - return \`\${str}\` -} - -export function isSystemInstruction(str: string): boolean { - return ( - str.startsWith('') && - str.endsWith('') - ) -} - -export function isSystemMessage(str: string): boolean { - return str.startsWith('') && str.endsWith('') -} - -/** - * Extracts the text content from a message, handling both string and array content types - * @param message - Message to extract text from - * @returns Combined text content of the message, or undefined if no text content - */ -export function getMessageText(message: Message): string | undefined { - if (typeof message.content === 'string') { - return message.content - } - return message.content.map((c) => ('text' in c ? c.text : '')).join('\\n') -} - -export function castAssistantMessage(message: Message): Message { - if (message.role !== 'assistant') { - return message - } - if (typeof message.content === 'string') { - return { - content: \`\${message.content}\`, - role: 'user' as const, - } - } - return { - role: 'user' as const, - content: message.content.map((m) => { - if (m.type === 'text') { - return { - ...m, - text: \`\${m.text}\`, - } - } - return m - }), - } -} - -// Number of terminal command outputs to keep in full form before simplifying -const numTerminalCommandsToKeep = 5 - -/** - * Helper function to simplify terminal command output while preserving some recent ones - * @param text - Terminal output text to potentially simplify - * @param numKept - Number of terminal outputs already kept in full form - * @returns Object containing simplified result and updated count of kept outputs - */ -function simplifyTerminalHelper( - text: string, - numKept: number -): { result: string; numKept: number } { - const simplifiedText = simplifyTerminalCommandResults(text) - - // Keep the full output for the N most recent commands - if (numKept < numTerminalCommandsToKeep && simplifiedText !== text) { - return { result: text, numKept: numKept + 1 } - } - - return { - result: simplifiedText, - numKept, - } -} - -// Factor to reduce token count target by, to leave room for new messages -const shortenedMessageTokenFactor = 0.5 - -/** - * Trims messages from the beginning to fit within token limits while preserving - * important content. Also simplifies terminal command outputs to save tokens. - * - * The function: - * 1. Processes messages from newest to oldest - * 2. Simplifies terminal command outputs after keeping N most recent ones - * 3. Stops adding messages when approaching token limit - * - * @param messages - Array of messages to trim - * @param systemTokens - Number of tokens used by system prompt - * @param maxTotalTokens - Maximum total tokens allowed, defaults to 200k - * @returns Trimmed array of messages that fits within token limit - */ -export function trimMessagesToFitTokenLimit( - messages: Message[], - systemTokens: number, - maxTotalTokens: number = 200_000 -): Message[] { - const MAX_MESSAGE_TOKENS = maxTotalTokens - systemTokens - - // Check if we're already under the limit - const initialTokens = countTokensJson(messages) - - if (initialTokens < MAX_MESSAGE_TOKENS) { - return messages - } - - let totalTokens = 0 - const targetTokens = MAX_MESSAGE_TOKENS * shortenedMessageTokenFactor - const results: Message[] = [] - let numKept = 0 - - // Process messages from newest to oldest - for (let i = messages.length - 1; i >= 0; i--) { - const { role, content } = messages[i] - let newContent: typeof content - - // Handle string content (usually terminal output) - if (typeof content === 'string') { - if (isSystemInstruction(content)) { - continue - } - const result = simplifyTerminalHelper(content, numKept) - newContent = result.result - numKept = result.numKept - } else { - // Handle array content (mixed content types) - newContent = [] - // Process content parts from newest to oldest - for (let j = content.length - 1; j >= 0; j--) { - const messagePart = content[j] - // Preserve non-text content (i.e. images) - if (messagePart.type !== 'text') { - newContent.push(messagePart) - continue - } - - const result = simplifyTerminalHelper(messagePart.text, numKept) - newContent.push({ ...messagePart, text: result.result }) - numKept = result.numKept - } - newContent.reverse() - } - - // Check if adding this message would exceed our token target - const message = { role, content: newContent } - const messageTokens = countTokensJson(message) - - if (totalTokens + messageTokens <= targetTokens) { - results.push({ role, content: newContent }) - totalTokens += messageTokens - } else { - break - } - } - - results.reverse() - return results -} - -export function getMessagesSubset(messages: Message[], otherTokens: number) { - const indexLastSubgoalComplete = messages.findLastIndex(({ content }) => { - JSON.stringify(content).includes('COMPLETE') - }) - - const messagesSubset = trimMessagesToFitTokenLimit( - indexLastSubgoalComplete === -1 - ? messages - : messages.slice(indexLastSubgoalComplete), - otherTokens - ) - - // Remove cache_control from all messages - for (const message of messagesSubset) { - if (typeof message.content === 'object' && message.content.length > 0) { - delete message.content[message.content.length - 1].cache_control - } - } - - // Cache up to the last message! - const lastMessage = messagesSubset[messagesSubset.length - 1] - if (lastMessage) { - messagesSubset[messagesSubset.length - 1] = withCacheControl(lastMessage) - } else { - logger.debug( - { - messages, - messagesSubset, - otherTokens, - }, - 'No last message found in messagesSubset!' - ) - } - - return messagesSubset -} -` - - agentRuntimeImpl.requestFiles = async () => ({ - 'src/util/messages.ts': initialContent, - }) - agentRuntimeImpl.requestOptionalFile = async () => initialContent - - // Mock LLM calls - agentRuntimeImpl.promptAiSdk = async function () { - return 'Mocked non-stream AiSdk' - } - - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.messageHistory.push( - assistantMessage( - getToolCallString('read_files', { - paths: ['src/util/messages.ts'], - }), - ), - { - role: 'tool', - toolName: 'read_files', - toolCallId: 'test-id', - content: jsonToolResult({ - path: 'src/util/messages.ts', - content: initialContent, - }), - }, - ) - - const action = { - type: 'prompt' as const, - prompt: 'Delete the castAssistantMessage function', - sessionState, - fingerprintId: 'test-delete-function-integration', - costMode: 'normal' as const, - promptId: 'test-delete-function-id-integration', - toolResults: [], - } - - const { output, sessionState: finalSessionState } = await mainPrompt({ - ...agentRuntimeImpl, - repoId: undefined, - repoUrl: undefined, - action, - userId: TEST_USER_ID, - clientSessionId: 'test-session-delete-function-integration', - localAgentTemplates: mockLocalAgentTemplates, - onResponseChunk: (chunk: string | PrintModeEvent) => { - if (typeof chunk !== 'string') { - return - } - process.stdout.write(chunk) - }, - signal: new AbortController().signal, - tools: {}, - }) - const requestToolCallSpy = agentRuntimeImpl.requestToolCall as any - - // Find the write_file tool call - const writeFileCall = requestToolCallSpy.mock.calls.find( - (call: any) => call[1] === 'write_file', - ) - expect(writeFileCall).toBeDefined() - expect(writeFileCall[2].path).toBe('src/util/messages.ts') - expect(writeFileCall[2].content.trim()).toBe( - `@@ -46,32 +46,8 @@\n }\n return message.content.map((c) => ('text' in c ? c.text : '')).join('\\n')\n }\n \n-export function castAssistantMessage(message: Message): Message {\n- if (message.role !== 'assistant') {\n- return message\n- }\n- if (typeof message.content === 'string') {\n- return {\n- content: \`\${message.content}\`,\n- role: 'user' as const,\n- }\n- }\n- return {\n- role: 'user' as const,\n- content: message.content.map((m) => {\n- if (m.type === 'text') {\n- return {\n- ...m,\n- text: \`\${m.text}\`,\n- }\n- }\n- return m\n- }),\n- }\n-}\n-\n // Number of terminal command outputs to keep in full form before simplifying\n const numTerminalCommandsToKeep = 5\n \n /**`.trim(), - ) - }, 60000) // Increase timeout for real LLM call - - describe.skip('Real world example', () => { - it('should specify deletion comment while deleting single character', async () => { - const initialContent = - "import express from 'express';\nimport session from 'express-session';\nimport cors from 'cors';\nimport TelegramBot, { User, ChatMember, MessageEntity } from 'node-telegram-bot-api';\nimport { connectDB } from './config/database';\nimport authRouter from './api/auth';\nimport blacklistPhrasesRouter from './api/blacklistPhrases';\nimport whitelistUsersRouter from './api/whitelistUsers';\nimport whitelistPhrasesRouter from './api/whitelistPhrases';\nimport statsRouter from './api/stats';\nimport ocrRouter from './api/ocr';\nimport settingsRouter from './api/settings';\nimport impersonationRouter from './api/impersonation';\nimport botActionsRouter from './api/botActions';\nimport { impersonationService } from './services/ImpersonationService';\nimport {\n AdminUser,\n AuditLogAction,\n ChatPermissions,\n compareModActions,\n ModAction,\n} from '@buff-bot/shared';\nimport { blacklistPhraseService } from './services/BlacklistPhraseService';\nimport { whitelistUserService } from './services/WhitelistUserService';\nimport { OCRService } from './services/OCRService';\nimport { AuditLog } from './models/AuditLog';\nimport { ActiveChat } from './models/ActiveChat';\nimport { RawMessage } from './models/RawMessage';\nimport { updateRecentMember } from './models/RecentMember';\nimport { addRecentMessage } from './models/RecentMessage';\nimport { whitelistPhraseService } from './services/WhitelistPhraseService';\nimport { handleModerationAction } from './services/moderationActions';\nimport { Admin } from './models/Admin';\n\ninterface PendingModeration {\n action: ModAction;\n userId?: number;\n detailsForLog: string;\n phraseForLog?: string;\n messageContent: string | undefined;\n}\n\ndeclare module 'express-session' {\n interface SessionData {\n user?: AdminUser;\n }\n}\n\n// Temporary type definitions until @types/node-telegram-bot-api is updated\ninterface BotMessage extends TelegramBot.Message {\n story?: Story;\n external_reply?: any;\n}\n\ninterface Story {\n chat: TelegramBot.Chat;\n id: number;\n}\n\n/**\n * Extend the built-in Error to carry an optional HTTP status code.\n */\nexport interface HttpError {\n message: string;\n status?: number;\n error?: Error;\n}\n\nconst token = process.env.BOT_TOKEN;\nif (!token) {\n throw new Error('BOT_TOKEN must be provided in environment variables');\n}\n\nconst DEFAULT_MUTE_DURATION = parseInt(process.env.DEFAULT_MUTE_DURATION || '3600', 10);\nconst ADMIN_CACHE_DURATION_MS = 15 * 60 * 1000; // Cache Telegram admins for 15 minutes\n\nconst bot = new TelegramBot(token, {\n polling: {\n params: {\n // Type definitions are incorrect here; need to pass array as json string form\n allowed_updates: JSON.stringify(['message', 'edited_message', 'chat_member']) as any,\n },\n },\n});\n\nconst app = express();\napp.use(\n cors({\n origin: process.env.FRONTEND_URL || 'http://localhost:5173',\n credentials: true,\n })\n);\napp.use(express.json());\napp.use(\n session({\n secret: process.env.SESSION_SECRET || 'your-secret-key',\n resave: false,\n saveUninitialized: false,\n cookie: { secure: process.env.NODE_ENV === 'production' },\n })\n);\n\nfunction errorHandler(\n err: HttpError,\n req: express.Request,\n res: express.Response,\n next: express.NextFunction\n) {\n const status = err.status || 500;\n const message = err.message || 'Internal Server Error';\n\n console.error(`[${new Date().toISOString()}]`, {\n status,\n message,\n // include stack in logs, but not in production responses\n stack: err.error?.stack,\n path: req.originalUrl,\n method: req.method,\n });\n\n const payload = { error: { message } };\n\n res.status(status).json(payload);\n}\n\napp.set('bot', bot);\n\napp.use('/api/auth', authRouter);\napp.use('/api/blacklist-phrases', blacklistPhrasesRouter);\napp.use('/api/whitelist-users', whitelistUsersRouter);\napp.use('/api/whitelist-phrases', whitelistPhrasesRouter);\napp.use('/api/ocr', ocrRouter);\napp.use('/api/stats', statsRouter);\napp.use('/api/settings', settingsRouter);\napp.use('/api/impersonation', impersonationRouter);\napp.use('/api/bot', botActionsRouter);\n\napp.use(errorHandler);\n\nlet botInfo: TelegramBot.User | null = null;\n\ninterface AdminCacheEntry {\n admins: ChatMember[];\n expiresAt: number;\n}\n\nconst telegramAdminCache = new Map();\n\nasync function getTelegramAdmin(\n senderId: number,\n chatId: number,\n botInstance: TelegramBot\n): Promise {\n const now = Date.now();\n const cachedEntry = telegramAdminCache.get(chatId);\n\n if (cachedEntry && cachedEntry.expiresAt > now) {\n return cachedEntry.admins.find((admin) => admin.user.id === senderId);\n }\n\n try {\n const chatAdmins = await botInstance.getChatAdministrators(chatId);\n telegramAdminCache.set(chatId, {\n admins: chatAdmins,\n expiresAt: now + ADMIN_CACHE_DURATION_MS,\n });\n\n return chatAdmins.find((admin) => admin.user.id === senderId);\n } catch (error: any) {\n if (error.response?.statusCode !== 403 && error.response?.statusCode !== 400) {\n console.error(`Error fetching chat admins for chat ${chatId}:`, error.message);\n }\n return cachedEntry?.admins.find((admin) => admin.user.id === senderId);\n }\n}\n\nasync function isAuthorizedToModerate(\n sender: TelegramBot.User,\n chatId: number,\n botInstance: TelegramBot,\n action: ModAction\n): Promise {\n // Check if user is a super admin\n const adminUser = await Admin.findOne({ telegramId: sender.id });\n if (adminUser?.isSuperAdmin) {\n return true;\n }\n\n // Check if user is a bot admin for this chat with MANAGE_CHANNEL permission\n if (\n adminUser?.chatPermissions?.some(\n (cp: ChatPermissions) => cp.chatId === chatId && cp.permissions.MANAGE_CHANNEL\n )\n ) {\n return true;\n }\n\n // Check if user is a Telegram chat admin with appropriate permissions\n const telegramAdmin = await getTelegramAdmin(sender.id, chatId, botInstance);\n if (!telegramAdmin) {\n return false;\n }\n\n if (action === 'delete') {\n return telegramAdmin.can_delete_messages || false;\n }\n\n if (action === 'mute' || action === 'ban') {\n return telegramAdmin.can_restrict_members || false;\n }\n\n return false;\n}\n\nasync function init() {\n await connectDB();\n await blacklistPhraseService.init();\n await OCRService.getInstance().init(bot);\n await impersonationService.init();\n await whitelistUserService.init();\n await whitelistPhraseService.init();\n\n botInfo = await bot.getMe();\n if (!botInfo) {\n throw new Error('Failed to get bot information');\n }\n console.log(`Bot initialized: ${botInfo.username} (ID: ${botInfo.id})`);\n\n setInterval(\n () => {\n const now = Date.now();\n for (const [chatId, entry] of telegramAdminCache.entries()) {\n if (entry.expiresAt <= now) {\n telegramAdminCache.delete(chatId);\n }\n }\n },\n 60 * 60 * 1000\n );\n\n async function handleMessageChecks(msg: BotMessage, isEdited: boolean = false): Promise {\n if (!botInfo) {\n console.error('Bot info not available in handleMessageChecks');\n return false;\n }\n\n const text = msg.text || msg.caption || undefined;\n const chatId = msg.chat.id;\n const messageId = msg.message_id;\n const sender = msg.from;\n\n const activeChat = await ActiveChat.findOne({ chatId });\n if (!activeChat) {\n return false;\n }\n\n const muteDuration = activeChat.muteDuration || DEFAULT_MUTE_DURATION;\n const linkAction = activeChat.linkModerationAction || 'none';\n const fakeSlashAction = activeChat.fakeSlashModerationAction || 'none';\n const storyAction = activeChat.forwardedStoryAction || 'none';\n const replyMarkupAction = activeChat.replyMarkupAction || 'none';\n const forwardedPollAction = activeChat.forwardedPollAction || 'none';\n const externalReplyAction = activeChat?.externalReplyAction || 'none';\n\n // Initialize tracking for the most severe action\n let pendingModAction: PendingModeration | null = null;\n\n // Helper to build context string\n const getContextHint = () => {\n let context = '';\n if (isEdited) context += '(edited message)';\n if (msg.forward_date) {\n if (context) context += ' ';\n context += '(forwarded message)';\n }\n return context;\n };\n\n // Helper to update pending moderation if the new action is more severe\n const tryUpdatePendingModeration = (\n potentialAction: ModAction,\n userIdToMod: number | undefined,\n logDetails: string,\n logPhrase?: string,\n msgContent?: string\n ) => {\n if (\n pendingModAction === null ||\n compareModActions(potentialAction, pendingModAction.action) > 0\n ) {\n pendingModAction = {\n action: potentialAction,\n userId: userIdToMod,\n detailsForLog: logDetails,\n phraseForLog: logPhrase,\n messageContent: msgContent,\n };\n }\n };\n\n if (sender) {\n // Check Sender is whitelisted; skip all moderation if applicable\n const isWhitelisted = await whitelistUserService.isWhitelisted(chatId, sender.id);\n if (isWhitelisted) {\n return false; // No moderation actions taken\n }\n\n // Check for impersonation by sender\n const matchedImpersonationRule = await impersonationService.checkUser(chatId, sender);\n if (matchedImpersonationRule) {\n const displayName = [sender.first_name, sender.last_name].filter(Boolean).join(' ');\n const userNames = `${sender.username ? `\"@${sender.username}\" ` : `ID:${sender.id}`} ${displayName?.length > 0 ? `[[\"${displayName}\"]]` : ''}`;\n const rulePattern = `${matchedImpersonationRule.username ? `\"@${matchedImpersonationRule.username}\" ` : ''} ${matchedImpersonationRule.displayName ? `[[\"${matchedImpersonationRule.displayName}\"]]` : ''}`;\n const details =\n `Impersonation attempt ${userNames} matching rule \"${rulePattern}\" ${getContextHint()}`.trim();\n\n tryUpdatePendingModeration(\n matchedImpersonationRule.action,\n sender.id,\n details,\n undefined,\n text\n );\n }\n }\n\n // Check for forwarded story\n if (msg.story && msg.chat.id !== msg.story.chat.id && storyAction !== 'none') {\n const details = 'Forwarded content: Story';\n tryUpdatePendingModeration(storyAction, sender?.id, details, undefined, '[Forwarded Story]');\n }\n\n if (msg.forward_from) {\n // Check the Original Sender is whitelisted; skip all moderation if applicable\n const isWhitelisted = await whitelistUserService.isWhitelisted(chatId, msg.forward_from.id);\n if (isWhitelisted) {\n return false; // No moderation actions taken\n }\n\n // Check impersonation by author of forwarded message\n const matchedImpersonationRule = await impersonationService.checkUser(\n chatId,\n msg.forward_from\n );\n if (matchedImpersonationRule) {\n const displayName = [msg.forward_from.first_name, msg.forward_from.last_name]\n .filter(Boolean)\n .join(' ');\n const userNames = `${msg.forward_from.username ? `\"@${msg.forward_from.username}\" ` : `ID:${msg.forward_from.id}`} ${displayName?.length > 0 ? `[[\"${displayName}\"]]` : ''}`;\n const rulePattern = `${matchedImpersonationRule.username ? `\"@${matchedImpersonationRule.username}\" ` : ''} ${matchedImpersonationRule.displayName ? `[[\"${matchedImpersonationRule.displayName}\"]]` : ''}`;\n const details =\n `Impersonation attempt by original author ${userNames} of forwarded message, matching rule \"${rulePattern}\" ${getContextHint()}`.trim();\n\n tryUpdatePendingModeration(\n matchedImpersonationRule.action,\n sender?.id, // Action is on the forwarder\n details,\n undefined,\n text\n );\n }\n }\n\n // Check text for whitelist match first - if matched, skip all other text checks\n if (text) {\n const whitelistMatch = await whitelistPhraseService.checkMessage(text, chatId);\n if (whitelistMatch) {\n return false; // No action was taken\n }\n\n const matchedPhrase = await blacklistPhraseService.checkMessage(text, chatId);\n if (matchedPhrase) {\n const contextHint = getContextHint();\n const details = `Blacklisted phrase detected ${contextHint}`.trim();\n\n tryUpdatePendingModeration(\n matchedPhrase.action,\n sender?.id,\n details,\n matchedPhrase.phrase,\n text\n );\n }\n }\n\n if (fakeSlashAction !== 'none' && msg.entities && msg.entities.length > 0) {\n const hasFakeSlash = msg.entities.some(\n (entity: MessageEntity) => entity.type === 'text_link' && msg.text![entity.offset] === '/'\n );\n\n if (hasFakeSlash) {\n const details = `Fake slash command detected ${getContextHint()}`.trim();\n tryUpdatePendingModeration(fakeSlashAction, sender?.id, details, undefined, text);\n }\n }\n\n if (externalReplyAction !== 'none' && msg.external_reply) {\n const details = `Message has external reply ${getContextHint()}`.trim();\n tryUpdatePendingModeration(externalReplyAction, sender?.id, details, undefined, text);\n }\n\n if (linkAction !== 'none' && msg.entities && msg.entities.length > 0) {\n const hasLink = msg.entities.some(\n (entity: MessageEntity) => entity.type === 'url' || entity.type === 'text_link'\n );\n\n if (hasLink) {\n const details = `Link detected ${getContextHint()}`.trim();\n tryUpdatePendingModeration(linkAction, sender?.id, details, undefined, text);\n }\n }\n\n if (msg.reply_markup && replyMarkupAction !== 'none') {\n const details = `Message contains reply markup ${getContextHint()}`.trim();\n tryUpdatePendingModeration(replyMarkupAction, sender?.id, details, undefined, text);\n }\n\n if (msg.poll && msg.forward_date && forwardedPollAction !== 'none') {\n const details = `Forwarded poll detected ${getContextHint()}`.trim();\n tryUpdatePendingModeration(\n forwardedPollAction,\n sender?.id,\n details,\n undefined,\n `[Forwarded Poll: ${msg.poll.question}]`\n );\n }\n\n // ToDo check is OCR enabled?\n if (msg.photo || msg.sticker) {\n const ocrResult = await OCRService.getInstance().handleImage(msg);\n if (ocrResult && ocrResult.confidence > activeChat.ocrMinConfidence) {\n const whitelistMatch = await whitelistPhraseService.checkMessage(ocrResult.text, chatId);\n if (whitelistMatch) {\n return false; // No action was taken\n }\n\n const matchedPhrase = await blacklistPhraseService.checkMessage(ocrResult.text, chatId);\n if (matchedPhrase) {\n const details = `Blacklisted phrase found in image (OCR) ${getContextHint()}`.trim();\n\n tryUpdatePendingModeration(\n matchedPhrase.action,\n sender?.id,\n details,\n matchedPhrase.phrase,\n text\n );\n }\n }\n }\n\n // Finally, execute the most severe action if one was determined\n if (pendingModAction) {\n pendingModAction = pendingModAction as PendingModeration; // hack around TS:strictNullChecks\n await handleModerationAction(\n bot,\n chatId,\n messageId,\n pendingModAction.userId,\n pendingModAction.action,\n muteDuration,\n msg.chat.type,\n botInfo,\n pendingModAction.detailsForLog,\n pendingModAction.phraseForLog,\n pendingModAction.messageContent\n );\n return true; // An action was taken\n }\n\n return false; // No action was taken\n }\n\n bot.on('chat_member', async (chatMember: TelegramBot.ChatMemberUpdated) => {\n if (!botInfo) {\n console.error('Bot info not available in chat_member handler');\n return;\n }\n\n const chat = chatMember.chat;\n const user = chatMember.new_chat_member.user;\n const displayName = [user.first_name, user.last_name].filter(Boolean).join(' ');\n const oldStatus = chatMember.old_chat_member.status;\n const newStatus = chatMember.new_chat_member.status;\n\n if (user.id === botInfo.id) {\n console.log('bot member status change?!?!');\n let action: AuditLogAction | null = null;\n if (oldStatus === 'left' && (newStatus === 'member' || newStatus === 'administrator')) {\n action = 'bot_joined';\n } else if (\n (oldStatus === 'member' || oldStatus === 'administrator') &&\n (newStatus === 'left' || newStatus === 'kicked')\n ) {\n action = 'bot_left';\n } else if (oldStatus === 'member' && newStatus === 'administrator') {\n action = 'bot_promoted';\n } else if (oldStatus === 'administrator' && newStatus === 'member') {\n action = 'bot_demoted';\n }\n\n if (action) {\n await AuditLog.create({\n action,\n adminUser: { id: chatMember.from.id, username: chatMember.from.username },\n chatId: chat.id,\n details: `Bot ${action.replace('_', ' ')} by ${chatMember.from.username || chatMember.from.id}`,\n });\n }\n\n if (newStatus === 'member' || newStatus === 'administrator') {\n await ActiveChat.findOneAndUpdate(\n { chatId: chat.id },\n {\n chatId: chat.id,\n title: chat.title,\n type: chat.type,\n joinedAt: new Date(),\n lastActivityAt: new Date(),\n },\n { upsert: true, new: true }\n );\n } else {\n await ActiveChat.findOneAndDelete({ chatId: chat.id });\n }\n } else if ((oldStatus === 'left' || oldStatus === 'kicked') && newStatus === 'member') {\n await updateRecentMember(chat.id, user);\n\n const activeChat = await ActiveChat.findOne({ chatId: chat.id });\n const muteDuration = activeChat?.muteDuration || DEFAULT_MUTE_DURATION;\n\n console.log('checking impersonation');\n const matchedImpersonationRule = await impersonationService.checkUser(chat.id, user);\n if (matchedImpersonationRule) {\n const userNames = `${user.username ? `\"@${user.username}\" ` : `ID:${user.id}`} ${displayName?.length > 0 ? `[[\"${displayName}\"]]` : ''}`;\n const rulePattern = `${matchedImpersonationRule.username ? `\"@${matchedImpersonationRule.username}\" ` : ''} ${matchedImpersonationRule.displayName ? `[[\"${matchedImpersonationRule.displayName}\"]]` : ''}`;\n const details = `Impersonation attempt by new user ${userNames} matching rule \"${rulePattern}\"`;\n console.log(details);\n\n await AuditLog.create({\n action: matchedImpersonationRule.action,\n targetUser: { id: user.id, username: user.username },\n adminUser: { id: botInfo!.id, username: botInfo!.username },\n chatId: chat.id,\n details: details,\n });\n\n await handleModerationAction(\n bot,\n chat.id,\n undefined,\n user.id,\n matchedImpersonationRule.action,\n muteDuration,\n chat.type,\n botInfo!,\n details\n );\n }\n }\n });\n\n bot.on('edited_message', async (msg) => {\n if (!botInfo) {\n console.error('Bot info not available in edited_message handler');\n return;\n }\n\n await handleMessageChecks(msg as BotMessage, true);\n });\n\n bot.on('message', async (msg) => {\n if (!botInfo) {\n console.error('Bot info not available in message handler');\n return;\n }\n\n await RawMessage.create({\n chatId: msg.chat.id,\n messageId: msg.message_id,\n rawData: msg,\n timestamp: new Date(),\n });\n\n let activeChat = await ActiveChat.findOneAndUpdate(\n { chatId: msg.chat.id },\n { lastActivityAt: new Date() },\n { new: true }\n );\n if (!activeChat) {\n activeChat = await ActiveChat.create({\n chatId: msg.chat.id,\n title: msg.chat.title,\n type: msg.chat.type,\n joinedAt: new Date(),\n lastActivityAt: new Date(),\n });\n }\n\n await addRecentMessage(msg.chat.id, msg.message_id, msg.text || msg.caption, msg.from);\n\n if (msg.from) {\n await updateRecentMember(msg.chat.id, msg.from);\n }\n\n const botMsg = msg as BotMessage;\n await handleMessageChecks(botMsg, false);\n });\n\n bot.onText(/^\\/md$/, async (msg) => {\n await moderationCommand(msg, 'delete');\n });\n\n bot.onText(/^\\/mm$/, async (msg) => {\n await moderationCommand(msg, 'mute');\n });\n\n bot.onText(/^\\/mb$/, async (msg) => {\n await moderationCommand(msg, 'ban');\n });\n\n async function moderationCommand(msg: TelegramBot.Message, action: ModAction) {\n if (!msg.reply_to_message) return; // Command must be a reply\n\n const chatId = msg.chat.id;\n const sender = msg.from;\n if (!sender) return;\n\n // Check if sender is authorized\n if (!(await isAuthorizedToModerate(sender, chatId, bot, action))) {\n return;\n }\n\n const targetMessage = msg.reply_to_message;\n const targetUser = targetMessage.from;\n if (!targetUser) return;\n\n // Delete the command message\n try {\n await bot.deleteMessage(chatId, msg.message_id);\n } catch (error) {\n console.error('Failed to delete command message:', error);\n }\n\n let detail: string;\n switch (action) {\n case 'ban':\n detail = `Admin command: /mb`;\n break;\n case 'mute':\n detail = `Admin command: /mm`;\n break;\n case 'delete':\n detail = `Admin command: /md`;\n break;\n default:\n detail = `Admin command: ${action}`;\n }\n\n const activeChat = await ActiveChat.findOne({ chatId });\n const muteDuration = activeChat?.muteDuration || DEFAULT_MUTE_DURATION;\n\n await handleModerationAction(\n bot,\n chatId,\n targetMessage.message_id,\n targetUser.id,\n action,\n muteDuration,\n msg.chat.type,\n sender, // Use command sender as adminUser\n detail,\n undefined,\n targetMessage.text || targetMessage.caption\n );\n }\n\n const port = process.env.PORT || 3000;\n app.listen(port, () => {\n console.log(`Server running on port ${port}`);\n });\n\n console.log('Bot started successfully');\n\n process.on('SIGTERM', async () => {\n await OCRService.getInstance().cleanup();\n process.exit(0);\n });\n}\n\ninit().catch(console.error);\n\n}" - agentRuntimeImpl.requestFiles = async () => ({ - 'src/util/messages.ts': initialContent, - }) - agentRuntimeImpl.requestOptionalFile = async () => initialContent - - // Mock LLM calls - agentRuntimeImpl.promptAiSdk = async function () { - return 'Mocked non-stream AiSdk' - } - - const sessionState = getInitialSessionState(mockFileContext) - sessionState.mainAgentState.messageHistory.push( - assistantMessage( - getToolCallString('read_files', { - paths: ['packages/backend/src/index.ts'], - }), - ), - { - role: 'tool', - toolName: 'read_files', - toolCallId: 'test-id', - content: jsonToolResult({ - path: 'packages/backend/src/index.ts', - content: initialContent, - }), - }, - ) - - const action = { - type: 'prompt' as const, - prompt: "There's a syntax error. Delete the last } in the file", - sessionState, - fingerprintId: 'test-delete-function-integration', - costMode: 'normal' as const, - promptId: 'test-delete-function-id-integration', - toolResults: [], - } - - await mainPrompt({ - ...agentRuntimeImpl, - repoId: undefined, - repoUrl: undefined, - action, - userId: TEST_USER_ID, - clientSessionId: 'test-session-delete-function-integration', - localAgentTemplates: { - base: { - id: 'base', - displayName: 'Base Agent', - outputMode: 'last_message', - inputSchema: {}, - spawnerPrompt: '', - model: 'gpt-4o-mini', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - mcpServers: {}, - toolNames: ['write_file', 'run_terminal_command'], - spawnableAgents: [], - systemPrompt: '', - instructionsPrompt: '', - stepPrompt: '', - }, - }, - onResponseChunk: (chunk: string | PrintModeEvent) => { - if (typeof chunk !== 'string') { - return - } - process.stdout.write(chunk) - }, - signal: new AbortController().signal, - tools: {}, - }) - - const requestToolCallSpy = agentRuntimeImpl.requestToolCall as any - - // Find the write_file tool call - const writeFileCall = requestToolCallSpy.mock.calls.find( - (call: any) => call[1] === 'write_file', - ) - expect(writeFileCall).toBeDefined() - expect(writeFileCall[2].path).toBe('packages/backend/src/index.ts') - expect(writeFileCall[2].content.trim()).toBe( - ` -@@ -689,6 +689,4 @@ - }); - } - - init().catch(console.error); -- --} -\\ No newline at end of file - `.trim(), - ) - }, 60000) // Increase timeout for real LLM call - }) -}) diff --git a/backend/src/__tests__/test-data/dex-go/edit-snippet.go b/backend/src/__tests__/test-data/dex-go/edit-snippet.go deleted file mode 100644 index f7847590d3..0000000000 --- a/backend/src/__tests__/test-data/dex-go/edit-snippet.go +++ /dev/null @@ -1,121 +0,0 @@ -package taskruntoolcall - -import ( - // ... existing imports ... - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" -) - -// ... existing code ... - -// initializeTRTC initializes the TaskRunToolCall status if not already set -// Returns true if initialization was done, false otherwise -func (r *TaskRunToolCallReconciler) initializeTRTC(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (bool, error) { - logger := log.FromContext(ctx) - - if trtc.Status.Phase == "" { - // Start tracing the TaskRunToolCall - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - ctx, span := tracer.Start(ctx, "TaskRunToolCall") - - // Store span context in status - spanCtx := span.SpanContext() - trtc.Status.SpanContext = &kubechainv1alpha1.SpanContext{ - TraceID: spanCtx.TraceID().String(), - SpanID: spanCtx.SpanID().String(), - } - - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhasePending - trtc.Status.Status = "Pending" - trtc.Status.StatusDetail = "Initializing" - trtc.Status.StartTime = &metav1.Time{Time: time.Now()} - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update initial status on TaskRunToolCall") - return true, err - } - return true, nil - } - - return false, nil -} - -// endTaskRunToolCallRootSpan ends the root span of a TaskRunToolCall using its stored span context -func (r *TaskRunToolCallReconciler) endTaskRunToolCallRootSpan(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) { - if trtc.Status.SpanContext == nil { - return - } - - // Create a new span context from the stored trace and span IDs - parentTraceID, _ := trace.TraceIDFromHex(trtc.Status.SpanContext.TraceID) - parentSpanID, _ := trace.SpanIDFromHex(trtc.Status.SpanContext.SpanID) - parentSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{ - TraceID: parentTraceID, - SpanID: parentSpanID, - Remote: true, - TraceFlags: trace.FlagsSampled, - }) - - // Create a new span with the parent context to end it - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - _, span := tracer.Start(trace.ContextWithSpanContext(ctx, parentSpanCtx), "TaskRunToolCall") - span.End() -} - -// initializeTaskRunToolCallChildSpan creates a new span for tool execution using the parent span context -func (r *TaskRunToolCallReconciler) initializeTaskRunToolCallChildSpan(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, name string) (context.Context, trace.Span) { - if trtc.Status.SpanContext == nil { - return ctx, nil - } - - // Create a new span context from the stored trace and span IDs - parentTraceID, _ := trace.TraceIDFromHex(trtc.Status.SpanContext.TraceID) - parentSpanID, _ := trace.SpanIDFromHex(trtc.Status.SpanContext.SpanID) - parentSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{ - TraceID: parentTraceID, - SpanID: parentSpanID, - Remote: true, - TraceFlags: trace.FlagsSampled, - }) - - // Create a new span with the parent context - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - return tracer.Start(trace.ContextWithSpanContext(ctx, parentSpanCtx), name) -} - -// ... existing code ... - -// processBuiltinFunction handles built-in function execution -func (r *TaskRunToolCallReconciler) processBuiltinFunction(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool, args map[string]interface{}) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // Create a child span for function execution - ctx, span := r.initializeTaskRunToolCallChildSpan(ctx, trtc, "ExecuteBuiltinFunction") - if span != nil { - defer span.End() - } - - logger.Info("Tool call arguments", "toolName", tool.Name, "arguments", args) - - var res float64 - // ... existing function execution code ... - - // Update TaskRunToolCall status with the function result - trtc.Status.Result = fmt.Sprintf("%v", res) - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - trtc.Status.CompletionTime = &metav1.Time{Time: time.Now()} - - // End the root span since execution is complete - r.endTaskRunToolCallRootSpan(ctx, trtc) - - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status after execution") - return ctrl.Result{}, err - } - logger.Info("Direct execution completed", "result", res) - r.recorder.Event(trtc, corev1.EventTypeNormal, "ExecutionSucceeded", fmt.Sprintf("Tool %q executed successfully", tool.Name)) - return ctrl.Result{}, nil -} - -// ... rest of existing code ... \ No newline at end of file diff --git a/backend/src/__tests__/test-data/dex-go/expected.go b/backend/src/__tests__/test-data/dex-go/expected.go deleted file mode 100644 index 08daaf6bc1..0000000000 --- a/backend/src/__tests__/test-data/dex-go/expected.go +++ /dev/null @@ -1,821 +0,0 @@ -package taskruntoolcall - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/google/uuid" - kubechainv1alpha1 "github.com/humanlayer/smallchain/kubechain/api/v1alpha1" - externalapi "github.com/humanlayer/smallchain/kubechain/internal/externalAPI" - "github.com/humanlayer/smallchain/kubechain/internal/humanlayer" - "github.com/humanlayer/smallchain/kubechain/internal/mcpmanager" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" -) - -const ( - StatusReady = "Ready" - StatusError = "Error" - DetailToolExecutedSuccess = "Tool executed successfully" - DetailInvalidArgsJSON = "Invalid arguments JSON" -) - -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=taskruntoolcalls,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=taskruntoolcalls/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=tools,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch - -// TaskRunToolCallReconciler reconciles a TaskRunToolCall object. -type TaskRunToolCallReconciler struct { - client.Client - Scheme *runtime.Scheme - recorder record.EventRecorder - server *http.Server - MCPManager *mcpmanager.MCPServerManager -} - -func (r *TaskRunToolCallReconciler) webhookHandler(w http.ResponseWriter, req *http.Request) { - logger := log.FromContext(context.Background()) - var webhook humanlayer.FunctionCall - if err := json.NewDecoder(req.Body).Decode(&webhook); err != nil { - logger.Error(err, "Failed to decode webhook payload") - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - logger.Info("Received webhook", "webhook", webhook) - - if webhook.Status != nil && webhook.Status.Approved != nil { - if *webhook.Status.Approved { - logger.Info("Email approved", "comment", webhook.Status.Comment) - } else { - logger.Info("Email request denied") - } - - // Update TaskRunToolCall status - if err := r.updateTaskRunToolCall(context.Background(), webhook); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - http.Error(w, "Failed to update status", http.StatusInternalServerError) - return - } - } - - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte(`{"status": "ok"}`)); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - return - } -} - -func (r *TaskRunToolCallReconciler) updateTaskRunToolCall(ctx context.Context, webhook humanlayer.FunctionCall) error { - logger := log.FromContext(ctx) - var trtc kubechainv1alpha1.TaskRunToolCall - - if err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: webhook.RunID}, &trtc); err != nil { - return fmt.Errorf("failed to get TaskRunToolCall: %w", err) - } - - logger.Info("Webhook received", - "runID", webhook.RunID, - "status", webhook.Status, - "approved", *webhook.Status.Approved, - "comment", webhook.Status.Comment) - - if webhook.Status != nil && webhook.Status.Approved != nil { - // Update the TaskRunToolCall status with the webhook data - if *webhook.Status.Approved { - trtc.Status.Result = "Approved" - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - } else { - trtc.Status.Result = "Rejected" - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = "Tool execution rejected" - } - - // if webhook.Status.RespondedAt != nil { - // trtc.Status.RespondedAt = &metav1.Time{Time: *webhook.Status.RespondedAt} - // } - - // if webhook.Status.Approved != nil { - // trtc.Status.Approved = webhook.Status.Approved - // } - - if err := r.Status().Update(ctx, &trtc); err != nil { - return fmt.Errorf("failed to update TaskRunToolCall status: %w", err) - } - logger.Info("TaskRunToolCall status updated", "name", trtc.Name, "phase", trtc.Status.Phase) - } - - return nil -} - -// Helper function to convert various value types to float64 -func convertToFloat(val interface{}) (float64, error) { - switch v := val.(type) { - case float64: - return v, nil - case int: - return float64(v), nil - case string: - return strconv.ParseFloat(v, 64) - default: - return 0, fmt.Errorf("cannot convert %T to float64", val) - } -} - -// checkIfMCPTool checks if a tool name follows the MCPServer tool pattern (serverName__toolName) -// and returns the serverName, toolName, and whether it's an MCP tool -func isMCPTool(toolName string) (serverName string, actualToolName string, isMCP bool) { - parts := strings.Split(toolName, "__") - if len(parts) == 2 { - return parts[0], parts[1], true - } - return "", toolName, false -} - -// executeMCPTool executes a tool call on an MCP server -func (r *TaskRunToolCallReconciler) executeMCPTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, serverName, toolName string, args map[string]interface{}) error { - logger := log.FromContext(ctx) - - if r.MCPManager == nil { - return fmt.Errorf("MCPManager is not initialized") - } - - // Call the MCP tool - result, err := r.MCPManager.CallTool(ctx, serverName, toolName, args) - if err != nil { - logger.Error(err, "Failed to call MCP tool", - "serverName", serverName, - "toolName", toolName) - return err - } - - // Update TaskRunToolCall status with the MCP tool result - trtc.Status.Result = result - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = "MCP tool executed successfully" - - return nil -} - -// initializeTRTC initializes the TaskRunToolCall status if not already set -// Returns true if initialization was done, false otherwise -func (r *TaskRunToolCallReconciler) initializeTRTC(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (bool, error) { - logger := log.FromContext(ctx) - - if trtc.Status.Phase == "" { - // Start tracing the TaskRunToolCall - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - ctx, span := tracer.Start(ctx, "TaskRunToolCall") - - // Store span context in status - spanCtx := span.SpanContext() - trtc.Status.SpanContext = &kubechainv1alpha1.SpanContext{ - TraceID: spanCtx.TraceID().String(), - SpanID: spanCtx.SpanID().String(), - } - - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhasePending - trtc.Status.Status = "Pending" - trtc.Status.StatusDetail = "Initializing" - trtc.Status.StartTime = &metav1.Time{Time: time.Now()} - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update initial status on TaskRunToolCall") - return true, err - } - return true, nil - } - - return false, nil -} - -// endTaskRunToolCallRootSpan ends the root span of a TaskRunToolCall using its stored span context -func (r *TaskRunToolCallReconciler) endTaskRunToolCallRootSpan(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) { - if trtc.Status.SpanContext == nil { - return - } - - // Create a new span context from the stored trace and span IDs - parentTraceID, _ := trace.TraceIDFromHex(trtc.Status.SpanContext.TraceID) - parentSpanID, _ := trace.SpanIDFromHex(trtc.Status.SpanContext.SpanID) - parentSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{ - TraceID: parentTraceID, - SpanID: parentSpanID, - Remote: true, - TraceFlags: trace.FlagsSampled, - }) - - // Create a new span with the parent context to end it - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - _, span := tracer.Start(trace.ContextWithSpanContext(ctx, parentSpanCtx), "TaskRunToolCall") - span.End() -} - -// initializeTaskRunToolCallChildSpan creates a new span for tool execution using the parent span context -func (r *TaskRunToolCallReconciler) initializeTaskRunToolCallChildSpan(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, name string) (context.Context, trace.Span) { - if trtc.Status.SpanContext == nil { - return ctx, nil - } - - // Create a new span context from the stored trace and span IDs - parentTraceID, _ := trace.TraceIDFromHex(trtc.Status.SpanContext.TraceID) - parentSpanID, _ := trace.SpanIDFromHex(trtc.Status.SpanContext.SpanID) - parentSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{ - TraceID: parentTraceID, - SpanID: parentSpanID, - Remote: true, - TraceFlags: trace.FlagsSampled, - }) - - // Create a new span with the parent context - tracer := otel.GetTracerProvider().Tracer("taskruntoolcall") - return tracer.Start(trace.ContextWithSpanContext(ctx, parentSpanCtx), name) -} - -// checkCompletedOrExisting checks if the TRTC is already complete or has a child TaskRun -func (r *TaskRunToolCallReconciler) checkCompletedOrExisting(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (bool, error) { - logger := log.FromContext(ctx) - - // Check if already completed - if trtc.Status.Phase == kubechainv1alpha1.TaskRunToolCallPhaseSucceeded || trtc.Status.Phase == kubechainv1alpha1.TaskRunToolCallPhaseFailed { - logger.Info("TaskRunToolCall already completed, nothing to do", "phase", trtc.Status.Phase) - return true, nil - } - - // Check if a child TaskRun already exists for this tool call - var taskRunList kubechainv1alpha1.TaskRunList - if err := r.List(ctx, &taskRunList, client.InNamespace(trtc.Namespace), client.MatchingLabels{"kubechain.humanlayer.dev/taskruntoolcall": trtc.Name}); err != nil { - logger.Error(err, "Failed to list child TaskRuns") - return true, err - } - if len(taskRunList.Items) > 0 { - logger.Info("Child TaskRun already exists", "childTaskRun", taskRunList.Items[0].Name) - // Optionally, sync status from child to parent. - return true, nil - } - - return false, nil -} - -// parseArguments parses the tool call arguments -func (r *TaskRunToolCallReconciler) parseArguments(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (map[string]interface{}, error) { - logger := log.FromContext(ctx) - - // Parse the arguments string as JSON (needed for both MCP and traditional tools) - var args map[string]interface{} - if err := json.Unmarshal([]byte(trtc.Spec.Arguments), &args); err != nil { - logger.Error(err, "Failed to parse arguments") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = DetailInvalidArgsJSON - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, err - } - return nil, err - } - - return args, nil -} - -// processMCPTool handles execution of an MCP tool -func (r *TaskRunToolCallReconciler) processMCPTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, serverName, mcpToolName string, args map[string]interface{}) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - logger.Info("Executing MCP tool", "serverName", serverName, "toolName", mcpToolName) - - // Execute the MCP tool - if err := r.executeMCPTool(ctx, trtc, serverName, mcpToolName, args); err != nil { - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("MCP tool execution failed: %v", err) - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - - if updateErr := r.Status().Update(ctx, trtc); updateErr != nil { - logger.Error(updateErr, "Failed to update status") - return ctrl.Result{}, updateErr - } - return ctrl.Result{}, err - } - - // Save the result - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status after execution") - return ctrl.Result{}, err - } - logger.Info("MCP tool execution completed", "result", trtc.Status.Result) - r.recorder.Event(trtc, corev1.EventTypeNormal, "ExecutionSucceeded", - fmt.Sprintf("MCP tool %q executed successfully", trtc.Spec.ToolRef.Name)) - return ctrl.Result{}, nil -} - -// getTraditionalTool retrieves and validates the Traditional Tool resource -func (r *TaskRunToolCallReconciler) getTraditionalTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (*kubechainv1alpha1.Tool, string, error) { - logger := log.FromContext(ctx) - - // Get the Tool resource - var tool kubechainv1alpha1.Tool - if err := r.Get(ctx, client.ObjectKey{Namespace: trtc.Namespace, Name: trtc.Spec.ToolRef.Name}, &tool); err != nil { - logger.Error(err, "Failed to get Tool", "tool", trtc.Spec.ToolRef.Name) - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get Tool: %v", err) - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, "", err - } - return nil, "", err - } - - // Determine tool type from the Tool resource - var toolType string - if tool.Spec.Execute.Builtin != nil { - toolType = "function" - } else if tool.Spec.AgentRef != nil { - toolType = "delegateToAgent" - } else if tool.Spec.Execute.ExternalAPI != nil { - toolType = "externalAPI" - } else if tool.Spec.ToolType != "" { - toolType = tool.Spec.ToolType - } else { - err := fmt.Errorf("unknown tool type: tool doesn't have valid execution configuration") - logger.Error(err, "Invalid tool configuration") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, "", err - } - return nil, "", err - } - - return &tool, toolType, nil -} - -// processDelegateToAgent handles agent delegation (not yet implemented) -func (r *TaskRunToolCallReconciler) processDelegateToAgent(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - err := fmt.Errorf("delegation is not implemented yet; only direct execution is supported") - logger.Error(err, "Delegation not implemented") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err -} - -// processBuiltinFunction handles built-in function execution -func (r *TaskRunToolCallReconciler) processBuiltinFunction(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool, args map[string]interface{}) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // Create a child span for function execution - ctx, span := r.initializeTaskRunToolCallChildSpan(ctx, trtc, "ExecuteBuiltinFunction") - if span != nil { - defer span.End() - } - - logger.Info("Tool call arguments", "toolName", tool.Name, "arguments", args) - - var res float64 - // Determine which function to execute based on the builtin name - switch tool.Spec.Execute.Builtin.Name { - case "add": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a + b - case "subtract": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a - b - case "multiply": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a * b - case "divide": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - if b == 0 { - err := fmt.Errorf("division by zero") - logger.Error(err, "Division by zero") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = "Division by zero" - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - res = a / b - default: - err := fmt.Errorf("unsupported builtin function %q", tool.Spec.Execute.Builtin.Name) - logger.Error(err, "Unsupported builtin") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - // Update TaskRunToolCall status with the function result - trtc.Status.Result = fmt.Sprintf("%v", res) - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - trtc.Status.CompletionTime = &metav1.Time{Time: time.Now()} - - // End the root span since execution is complete - r.endTaskRunToolCallRootSpan(ctx, trtc) - - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status after execution") - return ctrl.Result{}, err - } - logger.Info("Direct execution completed", "result", res) - r.recorder.Event(trtc, corev1.EventTypeNormal, "ExecutionSucceeded", fmt.Sprintf("Tool %q executed successfully", tool.Name)) - return ctrl.Result{}, nil -} - -// getExternalAPICredentials fetches and validates credentials for external API -func (r *TaskRunToolCallReconciler) getExternalAPICredentials(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool) (string, error) { - logger := log.FromContext(ctx) - - if tool.Spec.Execute.ExternalAPI == nil { - err := fmt.Errorf("externalAPI tool missing execution details") - logger.Error(err, "Missing execution details") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - - // Get API key from secret - var apiKey string - if tool.Spec.Execute.ExternalAPI.CredentialsFrom != nil { - var secret corev1.Secret - err := r.Get(ctx, client.ObjectKey{ - Namespace: trtc.Namespace, - Name: tool.Spec.Execute.ExternalAPI.CredentialsFrom.Name, - }, &secret) - if err != nil { - logger.Error(err, "Failed to get API credentials") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get API credentials: %v", err) - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - - apiKey = string(secret.Data[tool.Spec.Execute.ExternalAPI.CredentialsFrom.Key]) - logger.Info("Retrieved API key", "key", apiKey) - if apiKey == "" { - err := fmt.Errorf("empty API key in secret") - logger.Error(err, "Empty API key") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - } - - return apiKey, nil -} - -// processExternalAPI executes a call to an external API -func (r *TaskRunToolCallReconciler) processExternalAPI(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // Get API credentials - _, err := r.getExternalAPICredentials(ctx, trtc, tool) - if err != nil { - return ctrl.Result{}, err - } - - // Parse arguments - var argsMap map[string]interface{} - if err := json.Unmarshal([]byte(trtc.Spec.Arguments), &argsMap); err != nil { - logger.Error(err, "Failed to parse arguments") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = DetailInvalidArgsJSON - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - // Special handling for HumanLayer function calls - if len(argsMap) == 0 && tool.Name == "humanlayer-function-call" { - humanlayer.RegisterClient() - - // Create kwargs map first to ensure it's properly initialized - kwargs := map[string]interface{}{ - "tool_name": trtc.Spec.ToolRef.Name, - "task_run": trtc.Spec.TaskRunRef.Name, - "namespace": trtc.Namespace, - } - - // Default function call for HumanLayer with verified kwargs - argsMap = map[string]interface{}{ - "fn": "approve_tool_call", - "kwargs": kwargs, - } - - // Log to verify - logger.Info("Created humanlayer function call args", - "argsMap", argsMap, - "kwargs", kwargs) - } - - // Get the external client - externalClient, err := externalapi.DefaultRegistry.GetClient( - tool.Name, - r.Client, - trtc.Namespace, - tool.Spec.Execute.ExternalAPI.CredentialsFrom, - ) - if err != nil { - logger.Error(err, "Failed to get external client") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get external client: %v", err) - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - var fn string - var kwargs map[string]interface{} - - // Extract function name - if fnVal, fnExists := argsMap["fn"]; fnExists && fnVal != nil { - fn, _ = fnVal.(string) - } - - // Extract kwargs - if kwargsVal, kwargsExists := argsMap["kwargs"]; kwargsExists && kwargsVal != nil { - kwargs, _ = kwargsVal.(map[string]interface{}) - } - - // Generate call ID - callID := "call-" + uuid.New().String() - - // Prepare function call spec - functionSpec := map[string]interface{}{ - "fn": fn, - "kwargs": kwargs, - } - - // Make the API call - _, err = externalClient.Call(ctx, trtc.Name, callID, functionSpec) - if err != nil { - logger.Error(err, "External API call failed") - return ctrl.Result{}, err - } - - // Update TaskRunToolCall with the result - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - return ctrl.Result{}, err - } - logger.Info("TaskRunToolCall completed", "phase", trtc.Status.Phase) - return ctrl.Result{}, nil -} - -// handleUnsupportedToolType handles the fallback for unrecognized tool types -func (r *TaskRunToolCallReconciler) handleUnsupportedToolType(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - err := fmt.Errorf("unsupported tool configuration") - logger.Error(err, "Unsupported tool configuration") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err -} - -// getMCPServer gets the MCPServer for a tool and checks if it requires approval -func (r *TaskRunToolCallReconciler) getMCPServer(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (*kubechainv1alpha1.MCPServer, bool, error) { - logger := log.FromContext(ctx) - - // Check if this is an MCP tool - serverName, _, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) - if !isMCP { - return nil, false, nil - } - - // Get the MCPServer - var mcpServer kubechainv1alpha1.MCPServer - if err := r.Get(ctx, client.ObjectKey{ - Namespace: trtc.Namespace, - Name: serverName, - }, &mcpServer); err != nil { - logger.Error(err, "Failed to get MCPServer", "serverName", serverName) - return nil, false, err - } - - return &mcpServer, mcpServer.Spec.ApprovalContactChannel != nil, nil -} - -// Reconcile processes TaskRunToolCall objects. -func (r *TaskRunToolCallReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - var trtc kubechainv1alpha1.TaskRunToolCall - if err := r.Get(ctx, req.NamespacedName, &trtc); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - logger.Info("Reconciling TaskRunToolCall", "name", trtc.Name) - - // Step 1: Initialize status if not set - if initialized, err := r.initializeTRTC(ctx, &trtc); initialized || err != nil { - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - - // Step 2: Check if already completed or has child TaskRun - if done, err := r.checkCompletedOrExisting(ctx, &trtc); done || err != nil { - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - - serverName, mcpToolName, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) - - if isMCP { - // Step 3: Check if this is an MCP tool and needs approval - mcpServer, needsApproval, err := r.getMCPServer(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - if needsApproval { - trtc.Status.Status = "AwaitingHumanApproval" - trtc.Status.StatusDetail = fmt.Sprintf("Waiting for human approval via contact channel %s", mcpServer.Spec.ApprovalContactChannel.Name) - r.recorder.Event(&trtc, corev1.EventTypeNormal, "AwaitingHumanApproval", - fmt.Sprintf("Tool execution requires approval via contact channel %s", mcpServer.Spec.ApprovalContactChannel.Name)) - - if err := r.Status().Update(ctx, &trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - } - - // Step 4: Parse arguments - args, err := r.parseArguments(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - // Step 5: Handle MCP tool execution if applicable - if isMCP && r.MCPManager != nil { - return r.processMCPTool(ctx, &trtc, serverName, mcpToolName, args) - } - - // Step 6: Get traditional Tool resource - tool, toolType, err := r.getTraditionalTool(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - // Step 7: Process based on tool type - switch toolType { - case "delegateToAgent": - return r.processDelegateToAgent(ctx, &trtc) - case "function": - return r.processBuiltinFunction(ctx, &trtc, tool, args) - case "externalAPI": - return r.processExternalAPI(ctx, &trtc, tool) - default: - return r.handleUnsupportedToolType(ctx, &trtc) - } -} - -func (r *TaskRunToolCallReconciler) SetupWithManager(mgr ctrl.Manager) error { - r.recorder = mgr.GetEventRecorderFor("taskruntoolcall-controller") - r.server = &http.Server{Addr: ":8080"} // Choose a port - http.HandleFunc("/webhook/inbound", r.webhookHandler) - - // Initialize MCPManager if it hasn't been initialized yet - if r.MCPManager == nil { - r.MCPManager = mcpmanager.NewMCPServerManager() - } - - go func() { - if err := r.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Log.Error(err, "Failed to start HTTP server") - } - }() - - return ctrl.NewControllerManagedBy(mgr). - For(&kubechainv1alpha1.TaskRunToolCall{}). - Complete(r) -} - -func (r *TaskRunToolCallReconciler) Stop() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := r.server.Shutdown(ctx); err != nil { - log.Log.Error(err, "Failed to shut down HTTP server") - } -} diff --git a/backend/src/__tests__/test-data/dex-go/original.go b/backend/src/__tests__/test-data/dex-go/original.go deleted file mode 100644 index d59a1852fd..0000000000 --- a/backend/src/__tests__/test-data/dex-go/original.go +++ /dev/null @@ -1,753 +0,0 @@ -package taskruntoolcall - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/google/uuid" - kubechainv1alpha1 "github.com/humanlayer/smallchain/kubechain/api/v1alpha1" - externalapi "github.com/humanlayer/smallchain/kubechain/internal/externalAPI" - "github.com/humanlayer/smallchain/kubechain/internal/humanlayer" - "github.com/humanlayer/smallchain/kubechain/internal/mcpmanager" -) - -const ( - StatusReady = "Ready" - StatusError = "Error" - DetailToolExecutedSuccess = "Tool executed successfully" - DetailInvalidArgsJSON = "Invalid arguments JSON" -) - -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=taskruntoolcalls,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=taskruntoolcalls/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=tools,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch - -// TaskRunToolCallReconciler reconciles a TaskRunToolCall object. -type TaskRunToolCallReconciler struct { - client.Client - Scheme *runtime.Scheme - recorder record.EventRecorder - server *http.Server - MCPManager *mcpmanager.MCPServerManager -} - -func (r *TaskRunToolCallReconciler) webhookHandler(w http.ResponseWriter, req *http.Request) { - logger := log.FromContext(context.Background()) - var webhook humanlayer.FunctionCall - if err := json.NewDecoder(req.Body).Decode(&webhook); err != nil { - logger.Error(err, "Failed to decode webhook payload") - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - logger.Info("Received webhook", "webhook", webhook) - - if webhook.Status != nil && webhook.Status.Approved != nil { - if *webhook.Status.Approved { - logger.Info("Email approved", "comment", webhook.Status.Comment) - } else { - logger.Info("Email request denied") - } - - // Update TaskRunToolCall status - if err := r.updateTaskRunToolCall(context.Background(), webhook); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - http.Error(w, "Failed to update status", http.StatusInternalServerError) - return - } - } - - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte(`{"status": "ok"}`)); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - return - } -} - -func (r *TaskRunToolCallReconciler) updateTaskRunToolCall(ctx context.Context, webhook humanlayer.FunctionCall) error { - logger := log.FromContext(ctx) - var trtc kubechainv1alpha1.TaskRunToolCall - - if err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: webhook.RunID}, &trtc); err != nil { - return fmt.Errorf("failed to get TaskRunToolCall: %w", err) - } - - logger.Info("Webhook received", - "runID", webhook.RunID, - "status", webhook.Status, - "approved", *webhook.Status.Approved, - "comment", webhook.Status.Comment) - - if webhook.Status != nil && webhook.Status.Approved != nil { - // Update the TaskRunToolCall status with the webhook data - if *webhook.Status.Approved { - trtc.Status.Result = "Approved" - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - } else { - trtc.Status.Result = "Rejected" - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = "Tool execution rejected" - } - - // if webhook.Status.RespondedAt != nil { - // trtc.Status.RespondedAt = &metav1.Time{Time: *webhook.Status.RespondedAt} - // } - - // if webhook.Status.Approved != nil { - // trtc.Status.Approved = webhook.Status.Approved - // } - - if err := r.Status().Update(ctx, &trtc); err != nil { - return fmt.Errorf("failed to update TaskRunToolCall status: %w", err) - } - logger.Info("TaskRunToolCall status updated", "name", trtc.Name, "phase", trtc.Status.Phase) - } - - return nil -} - -// Helper function to convert various value types to float64 -func convertToFloat(val interface{}) (float64, error) { - switch v := val.(type) { - case float64: - return v, nil - case int: - return float64(v), nil - case string: - return strconv.ParseFloat(v, 64) - default: - return 0, fmt.Errorf("cannot convert %T to float64", val) - } -} - -// checkIfMCPTool checks if a tool name follows the MCPServer tool pattern (serverName__toolName) -// and returns the serverName, toolName, and whether it's an MCP tool -func isMCPTool(toolName string) (serverName string, actualToolName string, isMCP bool) { - parts := strings.Split(toolName, "__") - if len(parts) == 2 { - return parts[0], parts[1], true - } - return "", toolName, false -} - -// executeMCPTool executes a tool call on an MCP server -func (r *TaskRunToolCallReconciler) executeMCPTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, serverName, toolName string, args map[string]interface{}) error { - logger := log.FromContext(ctx) - - if r.MCPManager == nil { - return fmt.Errorf("MCPManager is not initialized") - } - - // Call the MCP tool - result, err := r.MCPManager.CallTool(ctx, serverName, toolName, args) - if err != nil { - logger.Error(err, "Failed to call MCP tool", - "serverName", serverName, - "toolName", toolName) - return err - } - - // Update TaskRunToolCall status with the MCP tool result - trtc.Status.Result = result - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = "MCP tool executed successfully" - - return nil -} - -// initializeTRTC initializes the TaskRunToolCall status if not already set -// Returns true if initialization was done, false otherwise -func (r *TaskRunToolCallReconciler) initializeTRTC(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (bool, error) { - logger := log.FromContext(ctx) - - if trtc.Status.Phase == "" { - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhasePending - trtc.Status.Status = "Pending" - trtc.Status.StatusDetail = "Initializing" - trtc.Status.StartTime = &metav1.Time{Time: time.Now()} - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update initial status on TaskRunToolCall") - return true, err - } - return true, nil - } - - return false, nil -} - -// checkCompletedOrExisting checks if the TRTC is already complete or has a child TaskRun -func (r *TaskRunToolCallReconciler) checkCompletedOrExisting(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (bool, error) { - logger := log.FromContext(ctx) - - // Check if already completed - if trtc.Status.Phase == kubechainv1alpha1.TaskRunToolCallPhaseSucceeded || trtc.Status.Phase == kubechainv1alpha1.TaskRunToolCallPhaseFailed { - logger.Info("TaskRunToolCall already completed, nothing to do", "phase", trtc.Status.Phase) - return true, nil - } - - // Check if a child TaskRun already exists for this tool call - var taskRunList kubechainv1alpha1.TaskRunList - if err := r.List(ctx, &taskRunList, client.InNamespace(trtc.Namespace), client.MatchingLabels{"kubechain.humanlayer.dev/taskruntoolcall": trtc.Name}); err != nil { - logger.Error(err, "Failed to list child TaskRuns") - return true, err - } - if len(taskRunList.Items) > 0 { - logger.Info("Child TaskRun already exists", "childTaskRun", taskRunList.Items[0].Name) - // Optionally, sync status from child to parent. - return true, nil - } - - return false, nil -} - -// parseArguments parses the tool call arguments -func (r *TaskRunToolCallReconciler) parseArguments(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (map[string]interface{}, error) { - logger := log.FromContext(ctx) - - // Parse the arguments string as JSON (needed for both MCP and traditional tools) - var args map[string]interface{} - if err := json.Unmarshal([]byte(trtc.Spec.Arguments), &args); err != nil { - logger.Error(err, "Failed to parse arguments") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = DetailInvalidArgsJSON - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, err - } - return nil, err - } - - return args, nil -} - -// processMCPTool handles execution of an MCP tool -func (r *TaskRunToolCallReconciler) processMCPTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, serverName, mcpToolName string, args map[string]interface{}) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - logger.Info("Executing MCP tool", "serverName", serverName, "toolName", mcpToolName) - - // Execute the MCP tool - if err := r.executeMCPTool(ctx, trtc, serverName, mcpToolName, args); err != nil { - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("MCP tool execution failed: %v", err) - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - - if updateErr := r.Status().Update(ctx, trtc); updateErr != nil { - logger.Error(updateErr, "Failed to update status") - return ctrl.Result{}, updateErr - } - return ctrl.Result{}, err - } - - // Save the result - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status after execution") - return ctrl.Result{}, err - } - logger.Info("MCP tool execution completed", "result", trtc.Status.Result) - r.recorder.Event(trtc, corev1.EventTypeNormal, "ExecutionSucceeded", - fmt.Sprintf("MCP tool %q executed successfully", trtc.Spec.ToolRef.Name)) - return ctrl.Result{}, nil -} - -// getTraditionalTool retrieves and validates the Traditional Tool resource -func (r *TaskRunToolCallReconciler) getTraditionalTool(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (*kubechainv1alpha1.Tool, string, error) { - logger := log.FromContext(ctx) - - // Get the Tool resource - var tool kubechainv1alpha1.Tool - if err := r.Get(ctx, client.ObjectKey{Namespace: trtc.Namespace, Name: trtc.Spec.ToolRef.Name}, &tool); err != nil { - logger.Error(err, "Failed to get Tool", "tool", trtc.Spec.ToolRef.Name) - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get Tool: %v", err) - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, "", err - } - return nil, "", err - } - - // Determine tool type from the Tool resource - var toolType string - if tool.Spec.Execute.Builtin != nil { - toolType = "function" - } else if tool.Spec.AgentRef != nil { - toolType = "delegateToAgent" - } else if tool.Spec.Execute.ExternalAPI != nil { - toolType = "externalAPI" - } else if tool.Spec.ToolType != "" { - toolType = tool.Spec.ToolType - } else { - err := fmt.Errorf("unknown tool type: tool doesn't have valid execution configuration") - logger.Error(err, "Invalid tool configuration") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return nil, "", err - } - return nil, "", err - } - - return &tool, toolType, nil -} - -// processDelegateToAgent handles agent delegation (not yet implemented) -func (r *TaskRunToolCallReconciler) processDelegateToAgent(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - err := fmt.Errorf("delegation is not implemented yet; only direct execution is supported") - logger.Error(err, "Delegation not implemented") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err -} - -// processBuiltinFunction handles built-in function execution -func (r *TaskRunToolCallReconciler) processBuiltinFunction(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool, args map[string]interface{}) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - logger.Info("Tool call arguments", "toolName", tool.Name, "arguments", args) - - var res float64 - // Determine which function to execute based on the builtin name - switch tool.Spec.Execute.Builtin.Name { - case "add": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a + b - case "subtract": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a - b - case "multiply": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - res = a * b - case "divide": - a, err1 := convertToFloat(args["a"]) - b, err2 := convertToFloat(args["b"]) - if err1 != nil { - logger.Error(err1, "Failed to parse first argument") - return ctrl.Result{}, err1 - } - if err2 != nil { - logger.Error(err2, "Failed to parse second argument") - return ctrl.Result{}, err2 - } - if b == 0 { - err := fmt.Errorf("division by zero") - logger.Error(err, "Division by zero") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = "Division by zero" - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - res = a / b - default: - err := fmt.Errorf("unsupported builtin function %q", tool.Spec.Execute.Builtin.Name) - logger.Error(err, "Unsupported builtin") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - // Update TaskRunToolCall status with the function result - trtc.Status.Result = fmt.Sprintf("%v", res) - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status after execution") - return ctrl.Result{}, err - } - logger.Info("Direct execution completed", "result", res) - r.recorder.Event(trtc, corev1.EventTypeNormal, "ExecutionSucceeded", fmt.Sprintf("Tool %q executed successfully", tool.Name)) - return ctrl.Result{}, nil -} - -// getExternalAPICredentials fetches and validates credentials for external API -func (r *TaskRunToolCallReconciler) getExternalAPICredentials(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool) (string, error) { - logger := log.FromContext(ctx) - - if tool.Spec.Execute.ExternalAPI == nil { - err := fmt.Errorf("externalAPI tool missing execution details") - logger.Error(err, "Missing execution details") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - - // Get API key from secret - var apiKey string - if tool.Spec.Execute.ExternalAPI.CredentialsFrom != nil { - var secret corev1.Secret - err := r.Get(ctx, client.ObjectKey{ - Namespace: trtc.Namespace, - Name: tool.Spec.Execute.ExternalAPI.CredentialsFrom.Name, - }, &secret) - if err != nil { - logger.Error(err, "Failed to get API credentials") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get API credentials: %v", err) - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - - apiKey = string(secret.Data[tool.Spec.Execute.ExternalAPI.CredentialsFrom.Key]) - logger.Info("Retrieved API key", "key", apiKey) - if apiKey == "" { - err := fmt.Errorf("empty API key in secret") - logger.Error(err, "Empty API key") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ValidationFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return "", err - } - return "", err - } - } - - return apiKey, nil -} - -// processExternalAPI executes a call to an external API -func (r *TaskRunToolCallReconciler) processExternalAPI(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall, tool *kubechainv1alpha1.Tool) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // Get API credentials - _, err := r.getExternalAPICredentials(ctx, trtc, tool) - if err != nil { - return ctrl.Result{}, err - } - - // Parse arguments - var argsMap map[string]interface{} - if err := json.Unmarshal([]byte(trtc.Spec.Arguments), &argsMap); err != nil { - logger.Error(err, "Failed to parse arguments") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = DetailInvalidArgsJSON - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - // Special handling for HumanLayer function calls - if len(argsMap) == 0 && tool.Name == "humanlayer-function-call" { - humanlayer.RegisterClient() - - // Create kwargs map first to ensure it's properly initialized - kwargs := map[string]interface{}{ - "tool_name": trtc.Spec.ToolRef.Name, - "task_run": trtc.Spec.TaskRunRef.Name, - "namespace": trtc.Namespace, - } - - // Default function call for HumanLayer with verified kwargs - argsMap = map[string]interface{}{ - "fn": "approve_tool_call", - "kwargs": kwargs, - } - - // Log to verify - logger.Info("Created humanlayer function call args", - "argsMap", argsMap, - "kwargs", kwargs) - } - - // Get the external client - externalClient, err := externalapi.DefaultRegistry.GetClient( - tool.Name, - r.Client, - trtc.Namespace, - tool.Spec.Execute.ExternalAPI.CredentialsFrom, - ) - if err != nil { - logger.Error(err, "Failed to get external client") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = fmt.Sprintf("Failed to get external client: %v", err) - trtc.Status.Error = err.Error() - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseFailed - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err - } - - var fn string - var kwargs map[string]interface{} - - // Extract function name - if fnVal, fnExists := argsMap["fn"]; fnExists && fnVal != nil { - fn, _ = fnVal.(string) - } - - // Extract kwargs - if kwargsVal, kwargsExists := argsMap["kwargs"]; kwargsExists && kwargsVal != nil { - kwargs, _ = kwargsVal.(map[string]interface{}) - } - - // Generate call ID - callID := "call-" + uuid.New().String() - - // Prepare function call spec - functionSpec := map[string]interface{}{ - "fn": fn, - "kwargs": kwargs, - } - - // Make the API call - _, err = externalClient.Call(ctx, trtc.Name, callID, functionSpec) - if err != nil { - logger.Error(err, "External API call failed") - return ctrl.Result{}, err - } - - // Update TaskRunToolCall with the result - trtc.Status.Phase = kubechainv1alpha1.TaskRunToolCallPhaseSucceeded - trtc.Status.Status = StatusReady - trtc.Status.StatusDetail = DetailToolExecutedSuccess - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - return ctrl.Result{}, err - } - logger.Info("TaskRunToolCall completed", "phase", trtc.Status.Phase) - return ctrl.Result{}, nil -} - -// handleUnsupportedToolType handles the fallback for unrecognized tool types -func (r *TaskRunToolCallReconciler) handleUnsupportedToolType(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - err := fmt.Errorf("unsupported tool configuration") - logger.Error(err, "Unsupported tool configuration") - trtc.Status.Status = StatusError - trtc.Status.StatusDetail = err.Error() - trtc.Status.Error = err.Error() - r.recorder.Event(trtc, corev1.EventTypeWarning, "ExecutionFailed", err.Error()) - if err := r.Status().Update(ctx, trtc); err != nil { - logger.Error(err, "Failed to update status") - return ctrl.Result{}, err - } - return ctrl.Result{}, err -} - -// getMCPServer gets the MCPServer for a tool and checks if it requires approval -func (r *TaskRunToolCallReconciler) getMCPServer(ctx context.Context, trtc *kubechainv1alpha1.TaskRunToolCall) (*kubechainv1alpha1.MCPServer, bool, error) { - logger := log.FromContext(ctx) - - // Check if this is an MCP tool - serverName, _, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) - if !isMCP { - return nil, false, nil - } - - // Get the MCPServer - var mcpServer kubechainv1alpha1.MCPServer - if err := r.Get(ctx, client.ObjectKey{ - Namespace: trtc.Namespace, - Name: serverName, - }, &mcpServer); err != nil { - logger.Error(err, "Failed to get MCPServer", "serverName", serverName) - return nil, false, err - } - - return &mcpServer, mcpServer.Spec.ApprovalContactChannel != nil, nil -} - -// Reconcile processes TaskRunToolCall objects. -func (r *TaskRunToolCallReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - var trtc kubechainv1alpha1.TaskRunToolCall - if err := r.Get(ctx, req.NamespacedName, &trtc); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - logger.Info("Reconciling TaskRunToolCall", "name", trtc.Name) - - // Step 1: Initialize status if not set - if initialized, err := r.initializeTRTC(ctx, &trtc); initialized || err != nil { - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - - // Step 2: Check if already completed or has child TaskRun - if done, err := r.checkCompletedOrExisting(ctx, &trtc); done || err != nil { - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - - serverName, mcpToolName, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) - - if isMCP { - // Step 3: Check if this is an MCP tool and needs approval - mcpServer, needsApproval, err := r.getMCPServer(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - if needsApproval { - trtc.Status.Status = "AwaitingHumanApproval" - trtc.Status.StatusDetail = fmt.Sprintf("Waiting for human approval via contact channel %s", mcpServer.Spec.ApprovalContactChannel.Name) - r.recorder.Event(&trtc, corev1.EventTypeNormal, "AwaitingHumanApproval", - fmt.Sprintf("Tool execution requires approval via contact channel %s", mcpServer.Spec.ApprovalContactChannel.Name)) - - if err := r.Status().Update(ctx, &trtc); err != nil { - logger.Error(err, "Failed to update TaskRunToolCall status") - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - } - - // Step 4: Parse arguments - args, err := r.parseArguments(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - // Step 5: Handle MCP tool execution if applicable - if isMCP && r.MCPManager != nil { - return r.processMCPTool(ctx, &trtc, serverName, mcpToolName, args) - } - - // Step 6: Get traditional Tool resource - tool, toolType, err := r.getTraditionalTool(ctx, &trtc) - if err != nil { - return ctrl.Result{}, err - } - - // Step 7: Process based on tool type - switch toolType { - case "delegateToAgent": - return r.processDelegateToAgent(ctx, &trtc) - case "function": - return r.processBuiltinFunction(ctx, &trtc, tool, args) - case "externalAPI": - return r.processExternalAPI(ctx, &trtc, tool) - default: - return r.handleUnsupportedToolType(ctx, &trtc) - } -} - -func (r *TaskRunToolCallReconciler) SetupWithManager(mgr ctrl.Manager) error { - r.recorder = mgr.GetEventRecorderFor("taskruntoolcall-controller") - r.server = &http.Server{Addr: ":8080"} // Choose a port - http.HandleFunc("/webhook/inbound", r.webhookHandler) - - // Initialize MCPManager if it hasn't been initialized yet - if r.MCPManager == nil { - r.MCPManager = mcpmanager.NewMCPServerManager() - } - - go func() { - if err := r.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Log.Error(err, "Failed to start HTTP server") - } - }() - - return ctrl.NewControllerManagedBy(mgr). - For(&kubechainv1alpha1.TaskRunToolCall{}). - Complete(r) -} - -func (r *TaskRunToolCallReconciler) Stop() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := r.server.Shutdown(ctx); err != nil { - log.Log.Error(err, "Failed to shut down HTTP server") - } -} diff --git a/backend/src/__tests__/tool-call-schema.test.ts b/backend/src/__tests__/tool-call-schema.test.ts deleted file mode 100644 index eb4cda299d..0000000000 --- a/backend/src/__tests__/tool-call-schema.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect } from 'bun:test' - -describe('Backend Tool Call Schema', () => { - it('should validate tool call request structure', () => { - const toolCallRequest = { - type: 'tool-call-request' as const, - requestId: 'test-id', - toolName: 'read_files', - args: { paths: 'test.ts' }, - timeout: 30000, - } - - expect(toolCallRequest.type).toBe('tool-call-request') - expect(toolCallRequest.requestId).toBe('test-id') - expect(toolCallRequest.toolName).toBe('read_files') - expect(toolCallRequest.args).toEqual({ paths: 'test.ts' }) - expect(toolCallRequest.timeout).toBe(30000) - }) - - it('should validate tool call response structure', () => { - const successResponse = { - type: 'tool-call-response' as const, - requestId: 'test-id', - success: true, - result: { content: 'file content' }, - } - - const errorResponse = { - type: 'tool-call-response' as const, - requestId: 'test-id', - success: false, - error: 'File not found', - } - - expect(successResponse.type).toBe('tool-call-response') - expect(successResponse.success).toBe(true) - expect(successResponse.result).toEqual({ content: 'file content' }) - - expect(errorResponse.type).toBe('tool-call-response') - expect(errorResponse.success).toBe(false) - expect(errorResponse.error).toBe('File not found') - }) - - it('should handle tool call timeout scenarios', () => { - const timeoutMs = 5000 - const startTime = Date.now() - - // Simulate timeout logic - const isTimedOut = (startTime: number, timeoutMs: number) => { - return Date.now() - startTime > timeoutMs - } - - // Should not be timed out immediately - expect(isTimedOut(startTime, timeoutMs)).toBe(false) - - // Should be timed out after the timeout period (simulated) - const futureTime = startTime + timeoutMs + 1000 - expect(futureTime - startTime > timeoutMs).toBe(true) - }) - - it('should validate different tool types', () => { - const toolTypes = [ - 'read_files', - 'run_terminal_command', - 'code_search', - 'write_file', - 'str_replace', - ] - - toolTypes.forEach((toolName) => { - const request = { - type: 'tool-call-request' as const, - requestId: `test-${toolName}`, - toolName, - args: {}, - timeout: 30000, - } - - expect(request.toolName).toBe(toolName) - expect(request.type).toBe('tool-call-request') - }) - }) - - it('should handle request ID generation', () => { - // Test that request IDs are unique-ish - const generateRequestId = () => Math.random().toString(36).substring(2, 15) - - const id1 = generateRequestId() - const id2 = generateRequestId() - - expect(id1).not.toBe(id2) - expect(typeof id1).toBe('string') - expect(typeof id2).toBe('string') - expect(id1.length).toBeGreaterThan(0) - expect(id2.length).toBeGreaterThan(0) - }) - - it('should generate mock project structure analysis', async () => { - const analysis = await generateMockProjectStructureAnalysis() - - expect(analysis).toContain('## Project Analysis') - expect(analysis).toContain('TypeScript/JavaScript/JSON files') - expect(typeof analysis).toBe('string') - }) - - it('should generate mock dependency analysis', async () => { - const analysis = await generateMockDependencyAnalysis() - - expect(analysis).toContain('## Dependency Analysis') - expect(analysis).toContain('Declared Dependencies') - expect(typeof analysis).toBe('string') - }) - - it('should handle error scenarios in mock generators', async () => { - const errorAnalysis = await generateMockProjectStructureAnalysis(true) - - expect(errorAnalysis).toContain('Project analysis failed') - expect(typeof errorAnalysis).toBe('string') - }) -}) - -/** - * Mock generator: Project structure analysis using backend-initiated tool calls - * This demonstrates how the backend can dynamically request information from the client - * based on the current context or user request - */ -export async function generateMockProjectStructureAnalysis( - simulateError: boolean = false, -): Promise { - try { - if (simulateError) { - throw new Error('Simulated error for testing') - } - - // Mock Step 1: Get the project structure - const mockListResult = { - success: true, - result: { - stdout: - './package.json\n./tsconfig.json\n./codebuff.json\n./src/index.ts\n./src/utils.ts', - stderr: '', - exitCode: 0, - }, - } - - const files = mockListResult.result.stdout - .trim() - .split('\n') - .filter((f: string) => f.length > 0) - - // Mock Step 2: Read key configuration files - const configFiles = files.filter( - (f: string) => - f.includes('package.json') || - f.includes('tsconfig.json') || - f.includes('codebuff.json'), - ) - - const mockFileContents = { - './package.json': JSON.stringify({ - name: 'test-project', - version: '1.0.0', - dependencies: { express: '^4.18.0', lodash: '^4.17.21' }, - devDependencies: { typescript: '^5.0.0', '@types/node': '^20.0.0' }, - }), - './tsconfig.json': JSON.stringify({ - compilerOptions: { target: 'ES2020', module: 'commonjs' }, - }), - './codebuff.json': JSON.stringify({ - maxAgentSteps: 20, - startupProcesses: [], - }), - } - - // Mock Step 3: Analyze the contents - let analysis = `## Project Analysis\n\n` - analysis += `Found ${files.length} TypeScript/JavaScript/JSON files\n\n` - - for (const [filePath, content] of Object.entries(mockFileContents)) { - if (content) { - analysis += `### ${filePath}\n` - if (filePath.includes('package.json')) { - try { - const pkg = JSON.parse(content) - analysis += `- Name: ${pkg.name || 'Unknown'}\n` - analysis += `- Version: ${pkg.version || 'Unknown'}\n` - analysis += `- Dependencies: ${Object.keys(pkg.dependencies || {}).length}\n` - analysis += `- Dev Dependencies: ${Object.keys(pkg.devDependencies || {}).length}\n` - } catch (e) { - analysis += `- Could not parse package.json\n` - } - } else if (filePath.includes('tsconfig.json')) { - analysis += `- TypeScript configuration found\n` - analysis += `- Size: ${content.length} characters\n` - } else if (filePath.includes('codebuff.json')) { - analysis += `- Codebuff configuration found\n` - analysis += `- Size: ${content.length} characters\n` - } - analysis += '\n' - } - } - - return analysis - } catch (error) { - return `Project analysis failed: ${error instanceof Error ? error.message : error}` - } -} - -/** - * Mock generator: Smart dependency analysis - * Dynamically searches for imports and analyzes dependencies - */ -export async function generateMockDependencyAnalysis( - searchPattern?: string, -): Promise { - try { - const pattern = searchPattern || 'import.*from' - - // Mock search result - const mockSearchResult = { - success: true, - result: `src/index.ts:1:import express from 'express' -src/index.ts:2:import { Router } from 'express' -src/utils.ts:1:import _ from 'lodash' -src/utils.ts:2:import { readFileSync } from 'fs'`, - } - - // Mock package.json content - const mockPackageFiles = { - 'package.json': JSON.stringify({ - dependencies: { express: '^4.18.0', lodash: '^4.17.21' }, - devDependencies: { typescript: '^5.0.0', '@types/node': '^20.0.0' }, - }), - } - - let analysis = `## Dependency Analysis\n\n` - - if (mockPackageFiles['package.json']) { - try { - const pkg = JSON.parse(mockPackageFiles['package.json']) - const deps = Object.keys(pkg.dependencies || {}) - const devDeps = Object.keys(pkg.devDependencies || {}) - - analysis += `### Declared Dependencies\n` - analysis += `- Production: ${deps.length} packages\n` - analysis += `- Development: ${devDeps.length} packages\n\n` - - analysis += `### Import Analysis\n` - analysis += `Search pattern: \`${pattern}\`\n` - analysis += `Found ${mockSearchResult.result?.split('\n').length || 0} import statements\n\n` - } catch (e) { - analysis += `Could not parse package.json for dependency comparison\n\n` - } - } - - return analysis - } catch (error) { - return `Dependency analysis failed: ${error instanceof Error ? error.message : error}` - } -} - -/** - * Mock generator: File content analysis - * Generates mock analysis of file contents for testing - */ -export async function generateMockFileContentAnalysis( - filePaths: string[], -): Promise { - try { - // Mock file contents - const mockFileContents: Record = { - 'src/index.ts': `import express from 'express';\nconst app = express();\napp.listen(3000);`, - 'src/utils.ts': `export function helper() { return 'test'; }`, - 'package.json': JSON.stringify({ name: 'test', version: '1.0.0' }), - } - - let analysis = `## File Content Analysis\n\n` - analysis += `Analyzing ${filePaths.length} files\n\n` - - for (const filePath of filePaths) { - const content = mockFileContents[filePath] || 'File not found' - analysis += `### ${filePath}\n` - analysis += `- Size: ${content.length} characters\n` - analysis += `- Lines: ${content.split('\n').length}\n` - - if (filePath.endsWith('.ts') || filePath.endsWith('.js')) { - const importCount = (content.match(/import\s+/g) || []).length - const exportCount = (content.match(/export\s+/g) || []).length - analysis += `- Imports: ${importCount}\n` - analysis += `- Exports: ${exportCount}\n` - } - - analysis += '\n' - } - - return analysis - } catch (error) { - return `File content analysis failed: ${error instanceof Error ? error.message : error}` - } -} diff --git a/backend/src/__tests__/usage-calculation.test.ts b/backend/src/__tests__/usage-calculation.test.ts deleted file mode 100644 index ab81b3afd9..0000000000 --- a/backend/src/__tests__/usage-calculation.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { calculateUsageAndBalance } from '@codebuff/billing' -import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' -import { - mockModule, - clearMockedModules, -} from '@codebuff/common/testing/mock-modules' -import { afterAll, beforeAll, describe, expect, it } from 'bun:test' - -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { GrantType } from '@codebuff/internal/db/schema' - -describe('Usage Calculation System', () => { - const logger: Logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, - } - - beforeAll(async () => { - // Mock the database module before importing the function - await mockModule('@codebuff/internal/db', () => ({ - default: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => Promise.resolve([]), - }), - }), - }), - }, - })) - }) - - afterAll(() => { - clearMockedModules() - }) - - it('should calculate usage this cycle correctly', async () => { - const mockGrants = [ - { - operation_id: 'test-1', - user_id: 'test-user', - type: 'free' as GrantType, - principal: 500, // Used 200 (500 - 300) - balance: 300, - created_at: new Date('2024-01-01'), - expires_at: new Date('2024-02-01'), - priority: GRANT_PRIORITIES.free, - }, - { - operation_id: 'test-2', - user_id: 'test-user', - type: 'purchase' as GrantType, - principal: 1000, // Used 200 (1000 - 800) - balance: 800, - created_at: new Date('2024-01-15'), - expires_at: null, - priority: GRANT_PRIORITIES.purchase, - }, - ] - - // Mock the database module with the test data - await mockModule('@codebuff/internal/db', () => ({ - default: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => Promise.resolve(mockGrants), - }), - }), - }), - }, - })) - - const { usageThisCycle } = await calculateUsageAndBalance({ - userId: 'test-user', - quotaResetDate: new Date('2024-01-01'), - now: new Date('2024-01-15'), // Pass current time when grants are active - logger, - }) - - expect(usageThisCycle).toBe(400) // 200 + 200 = 400 total usage - }) - - it('should handle expired grants', async () => { - const mockGrants = [ - { - operation_id: 'test-1', - user_id: 'test-user', - type: 'free' as GrantType, - principal: 500, - balance: 300, - created_at: new Date('2024-01-01'), - expires_at: new Date('2024-01-15'), // Already expired - priority: GRANT_PRIORITIES.free, - }, - ] - - // Mock the database module with the test data - await mockModule('@codebuff/internal/db', () => ({ - default: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => Promise.resolve(mockGrants), - }), - }), - }), - }, - })) - - const { balance, usageThisCycle } = await calculateUsageAndBalance({ - userId: 'test-user', - quotaResetDate: new Date('2024-01-01'), - now: new Date('2024-01-16'), // Current time after expiry - logger, - }) - - expect(balance.totalRemaining).toBe(0) // Expired grant doesn't count - expect(balance.totalDebt).toBe(0) - expect(balance.netBalance).toBe(0) - expect(balance.breakdown).toEqual({ - free: 0, - purchase: 0, - referral: 0, - admin: 0, - organization: 0, - }) - expect(usageThisCycle).toBe(200) // 500 - 300 = 200 used - }) - - it('should handle grants with debt', async () => { - const mockGrants = [ - { - operation_id: 'test-1', - user_id: 'test-user', - type: 'free' as GrantType, - principal: 500, - balance: -100, // In debt - created_at: new Date('2024-01-01'), - expires_at: new Date('2024-02-01'), - priority: GRANT_PRIORITIES.free, - }, - ] - - // Mock the database module with the test data - await mockModule('@codebuff/internal/db', () => ({ - default: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => Promise.resolve(mockGrants), - }), - }), - }), - }, - })) - - const { balance } = await calculateUsageAndBalance({ - userId: 'test-user', - quotaResetDate: new Date('2024-01-01'), - now: new Date('2024-01-15'), // Pass current time when grants are active - logger, - }) - - expect(balance.totalRemaining).toBe(0) - expect(balance.totalDebt).toBe(100) - expect(balance.netBalance).toBe(-100) - expect(balance.breakdown).toEqual({ - free: 0, - purchase: 0, - referral: 0, - admin: 0, - organization: 0, - }) // No positive balances - }) - - it('should handle in-memory settlement between positive balance and debt', async () => { - const mockGrants = [ - { - operation_id: 'test-1', - user_id: 'test-user', - type: 'free' as GrantType, - principal: 200, - balance: 100, // Positive balance - created_at: new Date('2024-01-01'), - expires_at: new Date('2024-02-01'), - priority: GRANT_PRIORITIES.free, - }, - { - operation_id: 'test-2', - user_id: 'test-user', - type: 'purchase' as GrantType, - principal: 100, - balance: -50, // Debt - created_at: new Date('2024-01-15'), - expires_at: null, - priority: GRANT_PRIORITIES.purchase, - }, - ] - - // Mock the database module with the test data - await mockModule('@codebuff/internal/db', () => ({ - default: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => Promise.resolve(mockGrants), - }), - }), - }), - }, - })) - - const { balance, usageThisCycle } = await calculateUsageAndBalance({ - userId: 'test-user', - quotaResetDate: new Date('2024-01-01'), - now: new Date('2024-01-15'), // Pass current time when grants are active - logger, - }) - - // Settlement: 100 positive balance - 50 debt = 50 remaining - expect(balance.totalRemaining).toBe(50) - expect(balance.totalDebt).toBe(0) - expect(balance.netBalance).toBe(50) - - // Breakdown shows positive balances before settlement - expect(balance.breakdown).toEqual({ - free: 100, - purchase: 0, // No positive balance for purchase grant - referral: 0, - admin: 0, - organization: 0, - }) - - // Principals show original grant amounts - expect(balance.principals).toEqual({ - free: 200, - purchase: 100, - referral: 0, - admin: 0, - organization: 0, - }) - - // Usage calculation: (200-100) + (100-(-50)) = 100 + 150 = 250 - expect(usageThisCycle).toBe(250) - }) -}) diff --git a/backend/src/admin/grade-runs.ts b/backend/src/admin/grade-runs.ts deleted file mode 100644 index a0e6ef501c..0000000000 --- a/backend/src/admin/grade-runs.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { models, TEST_USER_ID } from '@codebuff/common/old-constants' -import { systemMessage, userMessage } from '@codebuff/common/util/messages' -import { closeXml } from '@codebuff/common/util/xml' - -import type { Relabel, GetRelevantFilesTrace } from '@codebuff/bigquery' -import type { PromptAiSdkFn } from '@codebuff/common/types/contracts/llm' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' - -const PROMPT = ` -You are an evaluator system, measuring how well various models perform at selecting the most relevant files for a given user request. -You will be provided the context given to the other models, in the ${closeXml('request_context')} tags. -You will then be provided with multiple outputs, in the ${closeXml('model_outputs')} tags. -It will be provided in the following format: - - - ... -${closeXml('request_context')} - - - - 1${closeXml('model_id')} - ... - ${closeXml('output')} - - 2${closeXml('model_id')} - ... - ${closeXml('output')} -${closeXml('model_outputs')} - -Your goal is to rank and grade the outputs from best to worst, and provide 1-5 scores based on how well they followed the instructions in the tags. -Provide the best output first, and the worst output last. Multiple models may receive the same score, but you should break ties by quality. -Multiple models may receive the same score. - -You will provide your response in the following format: - - - - 2${closeXml('model_id')} - 4${closeXml('score')} - ${closeXml('score')} - - 1${closeXml('model_id')} - 4${closeXml('score')} - ${closeXml('score')} - - 3${closeXml('model_id')} - 2${closeXml('score')} - ${closeXml('score')} - ... -${closeXml('scores')} -` - -function modelsToXML(models: { model: string; output: string }[]) { - // 1-indexed ID, and then the output - return models - .map( - (model, index) => - ` -${index + 1}${closeXml('model_id')} -${model.output} -${closeXml('output')}`, - ) - .join('\n') -} - -function extractResponse(response: string): { - scores: { id: string; score: number }[] -} { - const scoresMatch = response.match(/([\s\S]*?)<\/scores>/) - if (!scoresMatch) { - throw new Error('No scores found in response') - } - - const scoresXml = scoresMatch[1] - const scoreMatches = scoresXml.match( - /[\s\S]*?(\d+)<\/model_id>[\s\S]*?(\d+)<\/score>[\s\S]*?<\/score>/g, - ) - - if (!scoreMatches) { - throw new Error('No valid score entries found') - } - - return { - scores: scoreMatches.map((scoreXml) => { - const modelMatch = scoreXml.match(/[\s]*(\d+)[\s]*<\/model_id>/) - const scoreMatch = scoreXml.match(/[\s]*(\d+)[\s]*<\/score>/) - - if (!modelMatch || !scoreMatch) { - throw new Error('Invalid score entry format') - } - - return { - id: modelMatch[1], - score: parseInt(scoreMatch[1], 10), - } - }), - } -} - -export async function gradeRun( - params: { - trace: GetRelevantFilesTrace - relabels: Relabel[] - promptAiSdk: PromptAiSdkFn - logger: Logger - } & ParamsExcluding< - PromptAiSdkFn, - | 'messages' - | 'model' - | 'clientSessionId' - | 'fingerprintId' - | 'userInputId' - | 'userId' - >, -) { - const { trace, relabels, promptAiSdk, logger } = params - const messages = trace.payload.messages - - const originalOutput = trace.payload.output - const originalModel = trace.payload.model - - const modelsWithOutputs: { - model: string - output: string - }[] = [ - { - model: originalModel ?? 'original', - output: originalOutput, - }, - ] - - for (const relabel of relabels) { - const model = relabel.model - const output = relabel.payload.output - modelsWithOutputs.push({ model, output }) - } - - // randomize the order of the models, but remember the original order - modelsWithOutputs.sort(() => Math.random() - 0.5) - - const modelOutputs = modelsToXML(modelsWithOutputs) - - console.log(relabels) - - const stringified = JSON.stringify(messages) - const response = await promptAiSdk({ - ...params, - messages: [ - systemMessage(PROMPT), - userMessage( - `${stringified}${closeXml('request_context')}`, - ), - userMessage(`${modelOutputs}${closeXml('model_outputs')}`), - userMessage(PROMPT), - ], - model: models.openrouter_claude_sonnet_4, - clientSessionId: 'relabel-trace-api', - fingerprintId: 'relabel-trace-api', - userInputId: 'relabel-trace-api', - userId: TEST_USER_ID, - // thinking: { - // type: 'enabled', - // budget_tokens: 10000, - // }, - }) - - const { scores } = extractResponse(response) - - // Combine the scores with the model name from modelsWithOutputs - const scoresWithModelName = scores.map((score, index) => { - const model = modelsWithOutputs[index] - return { model: model.model, score: score.score, rank: index + 1 } - }) - - console.log(response) - console.log(scoresWithModelName) -} diff --git a/backend/src/admin/relabelRuns.ts b/backend/src/admin/relabelRuns.ts deleted file mode 100644 index d507ae7cf4..0000000000 --- a/backend/src/admin/relabelRuns.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { messagesWithSystem } from '@codebuff/agent-runtime/util/messages' -import { - getTracesAndAllDataForUser, - getTracesWithoutRelabels, - insertRelabel, - setupBigQuery, -} from '@codebuff/bigquery' -import { - finetunedVertexModels, - models, - TEST_USER_ID, -} from '@codebuff/common/old-constants' -import { generateCompactId } from '@codebuff/common/util/string' -import { closeXml } from '@codebuff/common/util/xml' - -import { rerank } from '../llm-apis/relace-api' -import { promptAiSdk } from '../llm-apis/vercel-ai-sdk/ai-sdk' - -import type { System } from '@codebuff/agent-runtime/llm-api/claude' -import type { - GetExpandedFileContextForTrainingBlobTrace, - GetExpandedFileContextForTrainingTrace, - GetRelevantFilesPayload, - GetRelevantFilesTrace, - Relabel, -} from '@codebuff/bigquery' -import type { PromptAiSdkFn } from '@codebuff/common/types/contracts/llm' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' -import type { Message } from '@codebuff/common/types/messages/codebuff-message' -import type { Request, Response } from 'express' - -// --- GET Handler Logic --- - -export async function getTracesForUserHandler(params: { - req: Request - res: Response - logger: Logger -}) { - const { req, res, logger } = params - try { - // Extract userId from the query parameters - const userId = req.query.userId as string - - if (!userId) { - return res - .status(400) - .json({ error: 'Missing required parameter: userId' }) - } - - // Call the function to get traces and relabels - const tracesAndRelabels = await getTracesAndAllDataForUser(userId) - - // Transform data for the frontend - const formattedResults = tracesAndRelabels.map( - ({ trace, relatedTraces, relabels }) => { - // Extract timestamp - const timestamp = (trace.created_at as unknown as { value: string }) - .value - - // Extract query from the last message in the messages array - const messages = trace.payload.messages || [] - - const queryBody = - Array.isArray(messages) && messages.length > 0 - ? messages[messages.length - 1].content[0].text ?? - messages[messages.length - 1].content ?? - 'Unknown query' - : 'Unknown query' - - // User prompt: User prompt: \"still not seeing it, can you see it on the page?\" - // Extract using regex the above specific substring, matching the bit inside quotes - const query = queryBody.match(/"(.*?)"/)?.[1] || 'Unknown query' - - // Get base model output - const baseOutput = trace.payload.output || '' - - // Initialize outputs with base model - const outputs: Record = { - base: baseOutput, - } - - // Add outputs from relabels - relabels.forEach((relabel) => { - if (relabel.model && relabel.payload?.output) { - outputs[relabel.model] = relabel.payload.output - } - }) - - relatedTraces.forEach((trace) => { - if (trace.type === 'get-expanded-file-context-for-training') { - outputs['files-uploaded'] = ( - trace.payload as GetRelevantFilesPayload - ).output - } - }) - - return { - timestamp, - query, - outputs, - } - }, - ) - - // Return the formatted data - return res.json({ data: formattedResults }) - } catch (error) { - logger.error( - { - error: error, - stack: error instanceof Error ? error.stack : undefined, - message: error instanceof Error ? error.message : 'Unknown error', - }, - 'Error fetching traces and relabels', - ) - return res - .status(500) - .json({ error: 'Failed to fetch traces and relabels' }) - } -} - -// --- POST Handler Logic --- - -const modelsToRelabel = [ - // geminiModels.gemini2_5_pro_preview, - // models.openrouter_claude_sonnet_4, - // models.openrouter_claude_opus_4, - // finetunedVertexModels.ft_filepicker_005, - finetunedVertexModels.ft_filepicker_008, - finetunedVertexModels.ft_filepicker_topk_002, -] as const - -export async function relabelForUserHandler( - params: { - req: Request - res: Response - logger: Logger - } & ParamsExcluding< - PromptAiSdkFn, - | 'messages' - | 'model' - | 'clientSessionId' - | 'fingerprintId' - | 'userInputId' - | 'userId' - > & - ParamsExcluding, -) { - const { req, res, logger } = params - try { - // Extract userId from the URL query params - const userId = req.query.userId as string - - if (!userId) { - return res - .status(400) - .json({ error: 'Missing required parameter: userId' }) - } - - // Parse request body to get the relabeling data - const limit = req.body.limit || 10 // Default to 10 traces per model if not specified - - const allResults = [] - - const relaceResults = relabelUsingFullFilesForUser({ - ...params, - userId, - limit, - promptAiSdk, - }) - - // Process each model - for (const model of modelsToRelabel) { - logger.info(`Processing traces for model ${model} and user ${userId}...`) - - // Get traces without relabels for this model and user - const traces = await getTracesWithoutRelabels(model, limit, userId) - - logger.info( - `Found ${traces.length} traces without relabels for model ${model}`, - ) - - // Process traces for this model - const modelResults = await Promise.all( - traces.map(async (trace) => { - logger.info(`Processing trace ${trace.id}`) - const payload = ( - typeof trace.payload === 'string' - ? JSON.parse(trace.payload) - : trace.payload - ) as GetRelevantFilesPayload - - try { - let output: string - const messages = payload.messages - const system = payload.system - - output = await promptAiSdk({ - ...params, - messages: messagesWithSystem({ - messages: messages as Message[], - system: system as System, - }), - model: model, - clientSessionId: 'relabel-trace-api', - fingerprintId: 'relabel-trace-api', - userInputId: 'relabel-trace-api', - userId: TEST_USER_ID, - }) - - // Create relabel record - const relabel = { - id: generateCompactId(), - agent_step_id: trace.agent_step_id, - user_id: trace.user_id, - created_at: new Date(), - model: model, - payload: { - user_input_id: payload.user_input_id, - client_session_id: payload.client_session_id, - fingerprint_id: payload.fingerprint_id, - output: output, - }, - } - - // Store the relabel - await insertRelabel({ relabel, logger }) - logger.info(`Successfully stored relabel for trace ${trace.id}`) - - return { - traceId: trace.id, - status: 'success', - model: model, - } - } catch (error) { - logger.error( - { - error: error, - stack: error instanceof Error ? error.stack : undefined, - message: - error instanceof Error ? error.message : 'Unknown error', - }, - `Error processing trace ${trace.id}:`, - ) - return { - traceId: trace.id, - status: 'error', - model: model, - error: error instanceof Error ? error.message : 'Unknown error', - } - } - }), - ) - - allResults.push(...modelResults) - } - - await relaceResults - - // TODO: Add the judging step right here? - - // Return success response - return res.json({ - success: true, - message: 'Traces relabeled successfully', - data: allResults, - }) - } catch (error) { - logger.error( - { - error: error, - stack: error instanceof Error ? error.stack : undefined, - message: error instanceof Error ? error.message : 'Unknown error', - }, - 'Error relabeling traces', - ) - return res.status(500).json({ error: 'Failed to relabel traces' }) - } -} - -async function relabelUsingFullFilesForUser( - params: { - userId: string - limit: number - logger: Logger - } & ParamsExcluding< - typeof relabelWithClaudeWithFullFileContext, - 'trace' | 'fileBlobs' | 'model' - > & - ParamsExcluding, -) { - const { userId, limit, logger } = params - // TODO: We need to figure out changing _everything_ to use `getTracesAndAllDataForUser` - const tracesBundles = await getTracesAndAllDataForUser(userId) - - let relabeled = 0 - let didRelabel = false - const relabelPromises = [] - for (const traceBundle of tracesBundles) { - const trace = traceBundle.trace as GetRelevantFilesTrace - const files = traceBundle.relatedTraces.find( - (t) => - t.type === 'get-expanded-file-context-for-training' && - (t.payload as GetRelevantFilesPayload), - ) as GetExpandedFileContextForTrainingTrace - // TODO: We might be messing this up by not storing if 'Key' or 'Non-obvious', now that we collect both (?) - const fileBlobs = traceBundle.relatedTraces.find( - (t) => t.type === 'get-expanded-file-context-for-training-blobs', - ) as GetExpandedFileContextForTrainingBlobTrace - - if (!files || !fileBlobs) { - continue - } - - if (!traceBundle.relabels.some((r) => r.model === 'relace-ranker')) { - relabelPromises.push(relabelWithRelace({ ...params, trace, fileBlobs })) - didRelabel = true - } - for (const model of [ - models.openrouter_claude_sonnet_4, - models.openrouter_claude_opus_4, - ]) { - if ( - !traceBundle.relabels.some( - (r) => r.model === `${model}-with-full-file-context`, - ) - ) { - relabelPromises.push( - relabelWithClaudeWithFullFileContext({ - ...params, - trace, - fileBlobs, - model, - }), - ) - didRelabel = true - } - } - - if (didRelabel) { - relabeled++ - didRelabel = false - } - - if (relabeled >= limit) { - break - } - } - - await Promise.allSettled(relabelPromises) - - return relabeled -} - -async function relabelWithRelace( - params: { - trace: GetRelevantFilesTrace - fileBlobs: GetExpandedFileContextForTrainingBlobTrace - logger: Logger - } & ParamsExcluding< - typeof rerank, - | 'files' - | 'prompt' - | 'clientSessionId' - | 'fingerprintId' - | 'userInputId' - | 'userId' - | 'messageId' - >, -) { - const { trace, fileBlobs, logger } = params - logger.info(`Relabeling ${trace.id} with Relace`) - const messages = trace.payload.messages || [] - const queryBody = - Array.isArray(messages) && messages.length > 0 - ? messages[messages.length - 1].content[0].text || 'Unknown query' - : 'Unknown query' - - // User prompt: User prompt: \"still not seeing it, can you see it on the page?\" - // Extract using regex the above specific substring, matching the bit inside quotes - const query = queryBody.match(/"(.*?)"/)?.[1] || 'Unknown query' - - const filesWithPath = Object.entries(fileBlobs.payload.files).map( - ([path, file]) => ({ - path, - content: file.content, - }), - ) - - const relaced = await rerank({ - ...params, - files: filesWithPath, - prompt: query, - clientSessionId: trace.payload.client_session_id, - fingerprintId: trace.payload.fingerprint_id, - userInputId: trace.payload.user_input_id, - userId: 'test-user-id', // Make sure we don't bill em for it!! - messageId: trace.id, - }) - - const relabel = { - id: generateCompactId(), - agent_step_id: trace.agent_step_id, - user_id: trace.user_id, - created_at: new Date(), - model: 'relace-ranker', - payload: { - user_input_id: trace.payload.user_input_id, - client_session_id: trace.payload.client_session_id, - fingerprint_id: trace.payload.fingerprint_id, - output: relaced.join('\n'), - }, - } - - await insertRelabel({ relabel, logger }) - - return relaced -} - -export async function relabelWithClaudeWithFullFileContext( - params: { - trace: GetRelevantFilesTrace - fileBlobs: GetExpandedFileContextForTrainingBlobTrace - model: string - dataset?: string - promptAiSdk: PromptAiSdkFn - logger: Logger - } & ParamsExcluding< - PromptAiSdkFn, - | 'messages' - | 'model' - | 'clientSessionId' - | 'fingerprintId' - | 'userInputId' - | 'userId' - | 'maxOutputTokens' - >, -) { - const { trace, fileBlobs, model, dataset, promptAiSdk, logger } = params - if (dataset) { - await setupBigQuery({ dataset, logger }) - } - logger.info(`Relabeling ${trace.id} with Claude with full file context`) - const filesWithPath = Object.entries(fileBlobs.payload.files).map( - ([path, file]): { path: string; content: string } => ({ - path, - content: file.content, - }), - ) - - const filesString = filesWithPath - .map( - (f) => ` - ${f.path}${closeXml('name')} - ${f.content}${closeXml('contents')} - ${closeXml('file-contents')}`, - ) - .join('\n') - - const partialFileContext = `## Partial file context\n In addition to the file-tree, you've also been provided with some full files to make a better decision. Use these to help you decide which files are most relevant to the query. \n\n${filesString}\n${closeXml('partial-file-context')}` - - let system = trace.payload.system as System - if (typeof system === 'string') { - system = system + partialFileContext - } else { - // append partialFileContext to the last element of the system array - system[system.length - 1].text = - system[system.length - 1].text + partialFileContext - } - - const output = await promptAiSdk({ - ...params, - messages: messagesWithSystem({ - messages: trace.payload.messages as Message[], - system, - }), - model: model as any, // Model type is string here for flexibility - clientSessionId: 'relabel-trace-api', - fingerprintId: 'relabel-trace-api', - userInputId: 'relabel-trace-api', - userId: TEST_USER_ID, - maxOutputTokens: 1000, - }) - - const relabel = { - id: generateCompactId(), - agent_step_id: trace.agent_step_id, - user_id: trace.user_id, - created_at: new Date(), - model: `${model}-with-full-file-context-new`, - payload: { - user_input_id: trace.payload.user_input_id, - client_session_id: trace.payload.client_session_id, - fingerprint_id: trace.payload.fingerprint_id, - output: output, - }, - } as Relabel - - await insertRelabel({ relabel, dataset, logger }) - - return relabel -} diff --git a/backend/src/agent-run.ts b/backend/src/agent-run.ts deleted file mode 100644 index 20408980c3..0000000000 --- a/backend/src/agent-run.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' - -import type { - AddAgentStepFn, - FinishAgentRunFn, - StartAgentRunFn, -} from '@codebuff/common/types/contracts/database' -import type { ParamsOf } from '@codebuff/common/types/function-params' - -/** - * Starts a new agent run and creates an entry in the agent_run table - */ -export async function startAgentRun( - params: ParamsOf, -): ReturnType { - const { userId, agentId, ancestorRunIds, logger } = params - if (userId === TEST_USER_ID) { - return 'test-run-id' - } - - const runId = crypto.randomUUID() - - try { - await db.insert(schema.agentRun).values({ - id: runId, - user_id: userId, - agent_id: agentId, - ancestor_run_ids: ancestorRunIds.length > 0 ? ancestorRunIds : null, - status: 'running', - created_at: new Date(), - }) - - return runId - } catch (error) { - logger.error( - { error, runId, userId, agentId, ancestorRunIds }, - 'Failed to start agent run', - ) - throw error - } -} - -/** - * Completes an agent run by updating its status and metrics - */ -export async function finishAgentRun( - params: ParamsOf, -): ReturnType { - const { - userId, - runId, - status, - totalSteps, - directCredits, - totalCredits, - errorMessage, - logger, - } = params - if (userId === TEST_USER_ID) { - return - } - - try { - await db - .update(schema.agentRun) - .set({ - status, - completed_at: new Date(), - total_steps: totalSteps, - direct_credits: directCredits.toString(), - total_credits: totalCredits.toString(), - error_message: errorMessage, - }) - .where(eq(schema.agentRun.id, runId)) - } catch (error) { - logger.error({ error, runId, status }, 'Failed to finish agent run') - throw error - } -} - -/** - * Adds a completed step to the agent_step table - */ -export async function addAgentStep( - params: ParamsOf, -): ReturnType { - const { - userId, - agentRunId, - stepNumber, - credits, - childRunIds, - messageId, - status = 'completed', - errorMessage, - startTime, - logger, - } = params - - if (userId === TEST_USER_ID) { - return 'test-step-id' - } - const stepId = crypto.randomUUID() - - try { - await db.insert(schema.agentStep).values({ - id: stepId, - agent_run_id: agentRunId, - step_number: stepNumber, - status, - credits: credits?.toString(), - child_run_ids: childRunIds, - message_id: messageId, - error_message: errorMessage, - created_at: startTime, - completed_at: new Date(), - }) - - return stepId - } catch (error) { - logger.error({ error, agentRunId, stepNumber }, 'Failed to add agent step') - throw error - } -} diff --git a/backend/src/api/__tests__/validate-agent-name.test.ts b/backend/src/api/__tests__/validate-agent-name.test.ts deleted file mode 100644 index 6b19512588..0000000000 --- a/backend/src/api/__tests__/validate-agent-name.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { AGENT_PERSONAS } from '@codebuff/common/constants/agents' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' - -import { validateAgentNameHandlerHelper } from '../validate-agent-name' - -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '@codebuff/common/types/contracts/agent-runtime' -import type { FetchAgentFromDatabaseFn } from '@codebuff/common/types/contracts/database' -import type { - Request as ExpressRequest, - Response as ExpressResponse, - NextFunction, -} from 'express' - -let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps - -function createMockReq(query: Record): Partial { - return { - query, - headers: { 'x-codebuff-api-key': 'test-api-key' }, - } as any -} - -function createMockRes() { - const res: Partial & { - statusCode?: number - jsonPayload?: any - } = {} - res.status = mock((code: number) => { - res.statusCode = code - return res as ExpressResponse - }) as any - res.json = mock((payload: any) => { - res.jsonPayload = payload - return res as ExpressResponse - }) as any - return res as ExpressResponse & { statusCode?: number; jsonPayload?: any } -} - -const noopNext: NextFunction = () => {} - -function mockFetchAgentFromDatabase( - returnValue: ReturnType, -) { - const spy = mock((input) => { - return returnValue - }) - agentRuntimeImpl = { - ...agentRuntimeImpl, - fetchAgentFromDatabase: spy, - } - return spy -} - -describe('validateAgentNameHandler', () => { - const builtinAgentId = Object.keys(AGENT_PERSONAS)[0] || 'file-picker' - - beforeEach(() => { - agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL } - }) - - afterEach(() => { - mock.restore() - }) - - it('returns valid=true for builtin agent ids', async () => { - const req = createMockReq({ agentId: builtinAgentId }) - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalled() - expect(res.jsonPayload.valid).toBe(true) - expect(res.jsonPayload.source).toBe('builtin') - expect(res.jsonPayload.normalizedId).toBe(builtinAgentId) - }) - - it('returns valid=true for published agent ids (publisher/name)', async () => { - const agentId = 'codebuff/file-explorer' - - const spy = mockFetchAgentFromDatabase( - Promise.resolve({ - id: 'codebuff/file-explorer@0.0.1', - } as any), - ) - - const req = createMockReq({ agentId }) - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - parsedAgentId: { - publisherId: 'codebuff', - agentId: 'file-explorer', - version: undefined, - }, - }), - ) - expect(res.status).toHaveBeenCalledWith(200) - expect(res.jsonPayload.valid).toBe(true) - expect(res.jsonPayload.source).toBe('published') - expect(res.jsonPayload.normalizedId).toBe('codebuff/file-explorer@0.0.1') - }) - - it('returns valid=true for versioned published agent ids (publisher/name@version)', async () => { - const agentId = 'codebuff/file-explorer@0.0.1' - - const spy = mockFetchAgentFromDatabase( - Promise.resolve({ - id: agentId, - } as any), - ) - - const req = createMockReq({ agentId }) - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - parsedAgentId: { - publisherId: 'codebuff', - agentId: 'file-explorer', - version: '0.0.1', - }, - }), - ) - expect(res.status).toHaveBeenCalledWith(200) - expect(res.jsonPayload.valid).toBe(true) - expect(res.jsonPayload.source).toBe('published') - expect(res.jsonPayload.normalizedId).toBe(agentId) - }) - - it('returns valid=false for unknown agents', async () => { - const agentId = 'someorg/not-a-real-agent' - - const spy = mockFetchAgentFromDatabase(Promise.resolve(null)) - - const req = createMockReq({ agentId }) - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - parsedAgentId: { - publisherId: 'someorg', - agentId: 'not-a-real-agent', - version: undefined, - }, - }), - ) - expect(res.status).toHaveBeenCalledWith(200) - expect(res.jsonPayload.valid).toBe(false) - }) - - it('returns 400 for invalid requests (missing agentId)', async () => { - const req = createMockReq({}) - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - // Handler normalizes zod errors to 400 - expect(res.status).toHaveBeenCalledWith(400) - expect(res.jsonPayload.valid).toBe(false) - expect(res.jsonPayload.message).toBe('Invalid request') - }) - - it('returns 403 for requests without API key', async () => { - const req = { query: { agentId: 'test' }, headers: {} } as any - const res = createMockRes() - - await validateAgentNameHandlerHelper({ - ...agentRuntimeImpl, - req: req as any, - res: res as any, - next: noopNext, - }) - - expect(res.status).toHaveBeenCalledWith(403) - expect(res.jsonPayload.valid).toBe(false) - expect(res.jsonPayload.message).toBe('API key required') - }) -}) diff --git a/backend/src/api/agents.ts b/backend/src/api/agents.ts deleted file mode 100644 index 1fc315ca99..0000000000 --- a/backend/src/api/agents.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { validateAgentNameHandlerHelper } from './validate-agent-name' -import { BACKEND_AGENT_RUNTIME_IMPL } from '../impl/agent-runtime' - -import type { - Request as ExpressRequest, - Response as ExpressResponse, - NextFunction, -} from 'express' - -// GET /api/agents/validate-name -export async function validateAgentNameHandler( - req: ExpressRequest, - res: ExpressResponse, - next: NextFunction, -): Promise { - return validateAgentNameHandlerHelper({ - ...BACKEND_AGENT_RUNTIME_IMPL, - req, - res, - next, - }) -} diff --git a/backend/src/api/org.ts b/backend/src/api/org.ts deleted file mode 100644 index 9c14142787..0000000000 --- a/backend/src/api/org.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { findOrganizationForRepository } from '@codebuff/billing' -import { INVALID_AUTH_TOKEN_MESSAGE } from '@codebuff/common/old-constants' -import { z } from 'zod/v4' - -import { extractAuthTokenFromHeader } from '../util/auth-helpers' -import { logger } from '../util/logger' -import { getUserInfoFromApiKey } from '../websockets/auth' - -import type { - Request as ExpressRequest, - Response as ExpressResponse, - NextFunction, -} from 'express' - -const isRepoCoveredRequestSchema = z.object({ - owner: z.string(), - repo: z.string(), - remoteUrl: z.string(), -}) - -async function isRepoCoveredHandler( - req: ExpressRequest, - res: ExpressResponse, - next: NextFunction, -): Promise { - try { - const { owner, repo, remoteUrl } = isRepoCoveredRequestSchema.parse( - req.body, - ) - - // Get user ID from x-codebuff-api-key header - const authToken = extractAuthTokenFromHeader(req) - if (!authToken) { - return res - .status(401) - .json({ error: 'Missing x-codebuff-api-key header' }) - } - const userId = ( - await getUserInfoFromApiKey({ apiKey: authToken, fields: ['id'], logger }) - )?.id - - if (!userId) { - return res.status(401).json({ error: INVALID_AUTH_TOKEN_MESSAGE }) - } - - // Check if repository is covered by an organization - const orgLookup = await findOrganizationForRepository({ - userId, - repositoryUrl: remoteUrl, - logger, - }) - - return res.status(200).json({ - isCovered: orgLookup.found, - organizationName: orgLookup.organizationName, - organizationId: orgLookup.organizationId, // Keep organizationId for now, might be used elsewhere - organizationSlug: orgLookup.organizationSlug, // Add organizationSlug - }) - } catch (error) { - logger.error({ error }, 'Error handling /api/orgs/is-repo-covered request') - if (error instanceof z.ZodError) { - return res - .status(400) - .json({ error: 'Invalid request body', issues: error.issues }) - } - next(error) - return - } -} - -export { isRepoCoveredHandler } diff --git a/backend/src/api/usage.ts b/backend/src/api/usage.ts deleted file mode 100644 index 35d965a394..0000000000 --- a/backend/src/api/usage.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { getOrganizationUsageResponse } from '@codebuff/billing' -import { INVALID_AUTH_TOKEN_MESSAGE } from '@codebuff/common/old-constants' -import { z } from 'zod/v4' - -import { BACKEND_AGENT_RUNTIME_IMPL } from '../impl/agent-runtime' -import { checkAuth } from '../util/check-auth' -import { logger } from '../util/logger' -import { getUserInfoFromApiKey } from '../websockets/auth' -import { genUsageResponse } from '../websockets/websocket-action' - -import type { - Request as ExpressRequest, - Response as ExpressResponse, - NextFunction, -} from 'express' - -const usageRequestSchema = z.object({ - fingerprintId: z.string(), - authToken: z.string().optional(), - orgId: z.string().optional(), -}) - -async function usageHandler( - req: ExpressRequest, - res: ExpressResponse, - next: NextFunction, -): Promise { - try { - const { fingerprintId, authToken, orgId } = usageRequestSchema.parse( - req.body, - ) - const clientSessionId = `api-${fingerprintId}-${Date.now()}` - - const authResult = await checkAuth({ - ...BACKEND_AGENT_RUNTIME_IMPL, - authToken, - clientSessionId, - }) - if (authResult) { - const errorMessage = - authResult.type === 'action-error' - ? authResult.message - : 'Authentication failed' - return res.status(401).json({ message: errorMessage }) - } - - const userId = authToken - ? ( - await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id'], - logger, - }) - )?.id - : undefined - - if (!userId) { - const message = authToken - ? INVALID_AUTH_TOKEN_MESSAGE - : 'Authentication failed' - return res.status(401).json({ message }) - } - - // If orgId is provided, return organization usage data - if (orgId) { - try { - const orgUsageResponse = await getOrganizationUsageResponse({ - organizationId: orgId, - userId, - logger, - }) - return res.status(200).json(orgUsageResponse) - } catch (error) { - logger.error( - { error, orgId, userId }, - 'Error fetching organization usage', - ) - // If organization usage fails, fall back to personal usage - logger.info( - { orgId, userId }, - 'Falling back to personal usage due to organization error', - ) - } - } - - // Return personal usage data (default behavior) - const usageResponse = await genUsageResponse({ - fingerprintId, - userId, - clientSessionId, - logger, - }) - - return res.status(200).json(usageResponse) - } catch (error) { - logger.error({ error }, 'Error handling /api/usage request') - if (error instanceof z.ZodError) { - return res - .status(400) - .json({ message: 'Invalid request body', issues: error.issues }) - } - next(error) - return - } -} - -export default usageHandler diff --git a/backend/src/api/validate-agent-name.ts b/backend/src/api/validate-agent-name.ts deleted file mode 100644 index 6ed541a55e..0000000000 --- a/backend/src/api/validate-agent-name.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { getAgentTemplate } from '@codebuff/agent-runtime/templates/agent-registry' -import { AGENT_PERSONAS } from '@codebuff/common/constants/agents' -import { z } from 'zod/v4' - -import { extractAuthTokenFromHeader } from '../util/auth-helpers' -import { logger } from '../util/logger' - -import type { ParamsExcluding } from '@codebuff/common/types/function-params' -import type { - Request as ExpressRequest, - Response as ExpressResponse, - NextFunction, -} from 'express' - -// Add short-lived cache for positive validations -const AGENT_VALIDATION_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes - -type CacheEntry = { - result: { - valid: true - source?: string - normalizedId?: string - displayName?: string - } - expiresAt: number -} - -const agentValidationCache = new Map() - -// Simple request schema -const validateAgentRequestSchema = z.object({ - agentId: z.string().min(1), -}) - -export async function validateAgentNameHandlerHelper( - params: { - req: ExpressRequest - res: ExpressResponse - next: NextFunction - } & ParamsExcluding< - typeof getAgentTemplate, - 'agentId' | 'localAgentTemplates' | 'apiKey' - >, -): Promise { - const { req, res, next } = params - - try { - // Check for x-codebuff-api-key header for authentication - const apiKey = extractAuthTokenFromHeader(req) - if (!apiKey) { - return res.status(403).json({ - valid: false, - message: 'API key required', - }) - } - - // Parse from query instead (GET) - const { agentId } = validateAgentRequestSchema.parse({ - agentId: String((req.query as any)?.agentId ?? ''), - }) - - // Check cache (positive results only) - const cached = agentValidationCache.get(agentId) - if (cached && cached.expiresAt > Date.now()) { - return res.status(200).json({ ...cached.result, cached: true }) - } else if (cached) { - agentValidationCache.delete(agentId) - } - - // Check built-in agents first - const persona = AGENT_PERSONAS[agentId as keyof typeof AGENT_PERSONAS] - if (persona) { - const result = { - valid: true as const, - source: 'builtin', - normalizedId: agentId, - displayName: persona.displayName, - } - agentValidationCache.set(agentId, { - result, - expiresAt: Date.now() + AGENT_VALIDATION_CACHE_TTL_MS, - }) - return res.status(200).json(result) - } - - // Check published agents (database) - const found = await getAgentTemplate({ - ...params, - agentId, - localAgentTemplates: {}, - apiKey, - }) - if (found) { - const result = { - valid: true as const, - source: 'published', - normalizedId: found.id, - displayName: found.displayName, - } - agentValidationCache.set(agentId, { - result, - expiresAt: Date.now() + AGENT_VALIDATION_CACHE_TTL_MS, - }) - return res.status(200).json(result) - } - - return res.status(200).json({ valid: false }) - } catch (error) { - logger.error( - { error: error instanceof Error ? error.message : String(error) }, - 'Error validating agent name', - ) - if (error instanceof z.ZodError) { - return res.status(400).json({ - valid: false, - message: 'Invalid request', - issues: error.issues, - }) - } - next(error) - return - } -} diff --git a/backend/src/client-wrapper.ts b/backend/src/client-wrapper.ts deleted file mode 100644 index f30bc99b26..0000000000 --- a/backend/src/client-wrapper.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { toOptionalFile } from '@codebuff/common/old-constants' -import { ensureEndsWithNewline } from '@codebuff/common/util/file' -import { generateCompactId } from '@codebuff/common/util/string' - -import { subscribeToAction } from './websockets/websocket-action' - -import type { ServerAction } from '@codebuff/common/actions' -import type { - HandleStepsLogChunkFn, - RequestFilesFn, - RequestMcpToolDataFn, - RequestOptionalFileFn, - SendSubagentChunkFn, -} from '@codebuff/common/types/contracts/client' -import type { ParamsOf } from '@codebuff/common/types/function-params' -import type { MCPConfig } from '@codebuff/common/types/mcp' -import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' -import type { ServerMessage } from '@codebuff/common/websockets/websocket-schema' -import type { WebSocket } from 'ws' - -function sendMessage(ws: WebSocket, server: ServerMessage) { - ws.send(JSON.stringify(server)) -} - -/** - * Sends an action to the client via WebSocket - * @param ws - The WebSocket connection to send the action to - * @param action - The server action to send - */ -export function sendActionWs(params: { ws: WebSocket; action: ServerAction }) { - const { ws, action } = params - - sendMessage(ws, { - type: 'action', - data: action, - }) -} - -/** - * Requests a tool call execution from the client with timeout support - * @param ws - The WebSocket connection - * @param toolName - Name of the tool to execute - * @param input - Arguments for the tool (can include timeout) - * @returns Promise resolving to the tool execution result - */ -export async function requestToolCallWs(params: { - ws: WebSocket - userInputId: string - toolName: string - input: Record & { timeout_seconds?: number } - mcpConfig?: MCPConfig -}): Promise<{ - output: ToolResultOutput[] -}> { - const { ws, userInputId, toolName, input, mcpConfig } = params - - return new Promise((resolve) => { - const requestId = generateCompactId() - const timeoutInSeconds = - (input.timeout_seconds || 30) < 0 - ? undefined - : input.timeout_seconds || 30 - - // Set up timeout - const timeoutHandle = - timeoutInSeconds === undefined - ? undefined - : setTimeout( - () => { - unsubscribe() - resolve({ - output: [ - { - type: 'json', - value: { - errorMessage: `Tool call '${toolName}' timed out after ${timeoutInSeconds}s`, - }, - }, - ], - }) - }, - timeoutInSeconds * 1000 + 5000, // Convert to ms and add a small buffer - ) - - // Subscribe to response - const unsubscribe = subscribeToAction('tool-call-response', (action) => { - if (action.requestId === requestId) { - clearTimeout(timeoutHandle) - unsubscribe() - resolve({ - output: action.output, - }) - } - }) - - // Send the request - sendActionWs({ - ws, - action: { - type: 'tool-call-request', - requestId, - userInputId, - toolName, - input, - timeout: - timeoutInSeconds === undefined ? undefined : timeoutInSeconds * 1000, // Send timeout in milliseconds - mcpConfig, - }, - }) - }) -} - -/** - * Requests a tool call execution from the client with timeout support - * @param ws - The WebSocket connection - * @param mcpConfig - The configuration for the MCP server - * @param input - Arguments for the tool (can include timeout) - * @returns Promise resolving to the tool execution result - */ -export async function requestMcpToolDataWs( - params: ParamsOf & { - ws: WebSocket - }, -): ReturnType { - const { ws, mcpConfig, toolNames } = params - - return new Promise((resolve) => { - const requestId = generateCompactId() - - // Set up timeout - const timeoutHandle = setTimeout( - () => { - unsubscribe() - resolve([]) - }, - 45_000 + 5000, // Convert to ms and add a small buffer - ) - - // Subscribe to response - const unsubscribe = subscribeToAction('mcp-tool-data', (action) => { - if (action.requestId === requestId) { - clearTimeout(timeoutHandle) - unsubscribe() - resolve(action.tools) - } - }) - - // Send the request - sendActionWs({ - ws, - action: { - type: 'request-mcp-tool-data', - mcpConfig, - requestId, - ...(toolNames && { toolNames }), - }, - }) - }) -} - -/** - * Requests multiple files from the client - * @param ws - The WebSocket connection - * @param filePaths - Array of file paths to request - * @returns Promise resolving to an object mapping file paths to their contents - */ -export async function requestFilesWs( - params: { - ws: WebSocket - } & ParamsOf, -): ReturnType { - const { ws, filePaths } = params - return new Promise>((resolve) => { - const requestId = generateCompactId() - const unsubscribe = subscribeToAction('read-files-response', (action) => { - for (const [filename, contents] of Object.entries(action.files)) { - action.files[filename] = ensureEndsWithNewline(contents) - } - if (action.requestId === requestId) { - unsubscribe() - resolve(action.files) - } - }) - sendActionWs({ - ws, - action: { - type: 'read-files', - filePaths, - requestId, - }, - }) - }) -} - -export async function requestOptionalFileWs( - params: { - ws: WebSocket - } & ParamsOf, -): ReturnType { - const { ws, filePath } = params - const files = await requestFilesWs({ ws, filePaths: [filePath] }) - return toOptionalFile(files[filePath] ?? null) -} - -export function sendSubagentChunkWs( - params: { - ws: WebSocket - } & ParamsOf, -): ReturnType { - const { - ws, - userInputId, - agentId, - agentType, - chunk, - prompt, - forwardToPrompt = true, - } = params - return sendActionWs({ - ws, - action: { - type: 'subagent-response-chunk', - userInputId, - agentId, - agentType, - chunk, - prompt, - forwardToPrompt, - }, - }) -} - -export function handleStepsLogChunkWs( - params: { - ws: WebSocket - } & ParamsOf, -): ReturnType { - const { ws, userInputId, runId, level, data, message } = params - return sendActionWs({ - ws, - action: { - type: 'handlesteps-log-chunk', - userInputId, - agentId: runId, - level, - data, - message, - }, - }) -} diff --git a/backend/src/context/app-context.ts b/backend/src/context/app-context.ts deleted file mode 100644 index 75af3a78b2..0000000000 --- a/backend/src/context/app-context.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { AsyncLocalStorage } from 'async_hooks' - -export interface LoggerContext { - userId?: string - userEmail?: string - clientSessionId?: string - fingerprintId?: string - clientRequestId?: string - messageId?: string - discordId?: string - costMode?: string - [key: string]: any // Allow for future extensions -} - -export interface RequestContextData { - // The user ID for whom this context is established - currentUserId?: string - - // The specific organization ID under which the repoUrl was approved for the currentUserId - approvedOrgIdForRepo?: string - - // The repository URL that was processed for approval - processedRepoUrl?: string - - // The owner of the repository, parsed from processedRepoUrl - processedRepoOwner?: string - - // The base name of the repository, parsed from processedRepoUrl - processedRepoName?: string - - // The full repository identifier in "owner/repo" format - processedRepoId?: string - - // Flag indicating if the processedRepoUrl is approved for the currentUserId within the approvedOrgIdForRepo - isRepoApprovedForUserInOrg?: boolean -} - -export interface AppContext { - logger: LoggerContext - request: RequestContextData -} - -export const appContextStore = new AsyncLocalStorage() - -/** - * Helper function to run a callback with a new app context. - * This establishes both logger and request contexts in a single call. - */ -export function withAppContext( - loggerData: Partial, - requestData: RequestContextData, - callback: () => T, -): T { - const existingContext = appContextStore.getStore() - return appContextStore.run( - { - logger: { ...existingContext?.logger, ...loggerData }, - request: { ...existingContext?.request, ...requestData }, - }, - callback, - ) -} - -/** - * Helper function to update the current app context. - */ -export function updateAppContext(updates: { - logger?: Partial - request?: Partial -}): void { - const store = appContextStore.getStore() - if (store) { - if (updates.logger) { - Object.assign(store.logger, updates.logger) - } - if (updates.request) { - Object.assign(store.request, updates.request) - } - } -} - -/** - * Helper function to get the current app context. - */ -export function getAppContext(): AppContext | undefined { - return appContextStore.getStore() -} - -/** - * Helper function to get just the logger context. - */ -export function getLoggerContext(): LoggerContext | undefined { - return appContextStore.getStore()?.logger -} - -/** - * Helper function to get just the request context. - */ -export function getRequestContext(): RequestContextData | undefined { - return appContextStore.getStore()?.request -} - -/** - * Helper function to update just the request context. - */ -export function updateRequestContext( - updates: Partial, -): void { - updateAppContext({ request: updates }) -} diff --git a/backend/src/get-documentation-for-query.ts b/backend/src/get-documentation-for-query.ts deleted file mode 100644 index 498bc3a06b..0000000000 --- a/backend/src/get-documentation-for-query.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { fetchContext7LibraryDocumentation } from '@codebuff/agent-runtime/llm-api/context7-api' -import { models } from '@codebuff/common/old-constants' -import { userMessage } from '@codebuff/common/util/messages' -import { closeXml } from '@codebuff/common/util/xml' -import { uniq } from 'lodash' -import { z } from 'zod/v4' - -import type { PromptAiSdkStructuredFn } from '@codebuff/common/types/contracts/llm' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { - ParamsExcluding, - ParamsOf, -} from '@codebuff/common/types/function-params' - -const DELIMITER = `\n\n----------------------------------------\n\n` - -/** - * Gets relevant documentation chunks for a query by using Flash to analyze the best project and topic - * @param query The user's query to find documentation for - * @param options Optional parameters for the request - * @param options.tokens Number of tokens to retrieve (default: 5000) - * @param options.clientSessionId Unique ID for the client session - * @param options.fingerprintId Unique ID for the user's device/fingerprint - * @param options.userId The ID of the user making the request - * @returns The documentation text chunks or null if no relevant docs found - */ -export async function getDocumentationForQuery( - params: { - query: string - clientSessionId: string - userInputId: string - fingerprintId: string - userId?: string - logger: Logger - } & ParamsOf & - ParamsExcluding & - ParamsExcluding< - typeof fetchContext7LibraryDocumentation, - 'query' | 'topic' - >, -): Promise { - const { query, clientSessionId, userInputId, fingerprintId, userId, logger } = - params - const startTime = Date.now() - - // 1. Search for relevant libraries - const libraryResults = await suggestLibraries(params) - - if (!libraryResults || libraryResults.libraries.length === 0) { - logger.info( - { - query, - timings: { - total: Date.now() - startTime, - }, - }, - 'Documentation chunks: No relevant libraries suggested.', - ) - return null - } - - const { libraries, geminiDuration: geminiDuration1 } = libraryResults - - // 2. Fetch documentation for these libraries - const allRawChunks = ( - await Promise.all( - libraries.map(({ libraryName, topic }) => - fetchContext7LibraryDocumentation({ - ...params, - query: libraryName, - topic, - }), - ), - ) - ).flat() - - const maxChunks = 25 - const allUniqueChunks = uniq( - allRawChunks - .filter((chunk) => chunk !== null) - .join(DELIMITER) - .split(DELIMITER), - ).slice(0, maxChunks) - - if (allUniqueChunks.length === 0) { - logger.info( - { - query, - libraries, - timings: { - total: Date.now() - startTime, - gemini1: geminiDuration1, - }, - }, - 'Documentation chunks: No chunks found after fetching from Context7.', - ) - return null - } - - // 3. Filter relevant chunks using another LLM call - const filterResults = await filterRelevantChunks({ - ...params, - query, - allChunks: allUniqueChunks, - clientSessionId, - userInputId, - fingerprintId, - userId, - logger, - }) - - const totalDuration = Date.now() - startTime - - if (!filterResults || filterResults.relevantChunks.length === 0) { - logger.info( - { - query, - libraries, - chunks: allUniqueChunks, - chunksCount: allUniqueChunks.length, - geminiDuration1, - geminiDuration2: filterResults?.geminiDuration, - timings: { - total: totalDuration, - gemini1: geminiDuration1, - gemini2: filterResults?.geminiDuration, - }, - }, - 'Documentation chunks: No relevant chunks selected by the filter, or filter failed.', - ) - return null - } - - const { relevantChunks, geminiDuration: geminiDuration2 } = filterResults - - logger.info( - { - query, - libraries, - chunks: allUniqueChunks, - chunksCount: allUniqueChunks.length, - relevantChunks, - relevantChunksCount: relevantChunks.length, - timings: { - total: totalDuration, - gemini1: geminiDuration1, - gemini2: geminiDuration2, - }, - }, - 'Documentation chunks: results', - ) - - return relevantChunks.join(DELIMITER) -} - -const suggestLibraries = async ( - params: { - query: string - promptAiSdkStructured: PromptAiSdkStructuredFn - logger: Logger - } & ParamsExcluding< - PromptAiSdkStructuredFn, - 'messages' | 'model' | 'temperature' | 'schema' | 'timeout' - >, -) => { - const { query, promptAiSdkStructured, logger } = params - const prompt = - `You are an expert at documentation for libraries. Given a user's query return a list of (library name, topic) where each library name is the name of a library and topic is a keyword or phrase that specifies a topic within the library that is most relevant to the user's query. - -For example, the library name could be "Node.js" and the topic could be "async/await". - -You can include the same library name multiple times with different topics, or the same topic multiple times with different library names (but keep to a maximum of 3 libraries/topics). - -If there are no obvious libraries that would be helpful, return an empty list. It is common that you would return an empty list. - -Please just return an empty list of libraries/topics unless you are really, really sure that they are relevant. - - -${query} -${closeXml('user_query')} - `.trim() - - const geminiStartTime = Date.now() - try { - const response = await promptAiSdkStructured({ - ...params, - messages: [userMessage(prompt)], - model: models.openrouter_gemini2_5_flash, - temperature: 0, - schema: z.object({ - libraries: z.array( - z.object({ - libraryName: z.string(), - topic: z.string(), - }), - ), - }), - timeout: 5_000, - }) - return { - libraries: response.libraries, - geminiDuration: Date.now() - geminiStartTime, - } - } catch (error) { - logger.error( - { error }, - 'Failed to get Gemini response getDocumentationForQuery', - ) - return null - } -} - -/** - * Filters a list of documentation chunks to find those relevant to a query, using an LLM. - * @param query The user's query. - * @param allChunks An array of all documentation chunks to filter. - * @param options Common request options including session and user identifiers. - * @returns A promise that resolves to an object containing the relevant chunks and Gemini call duration, or null if an error occurs. - */ -async function filterRelevantChunks( - params: { - query: string - allChunks: string[] - promptAiSdkStructured: PromptAiSdkStructuredFn - logger: Logger - } & ParamsExcluding< - PromptAiSdkStructuredFn, - 'messages' | 'model' | 'temperature' | 'schema' | 'timeout' - >, -): Promise<{ relevantChunks: string[]; geminiDuration: number } | null> { - const { query, allChunks, promptAiSdkStructured, logger } = params - const prompt = `You are an expert at analyzing documentation queries. Given a user's query and a list of documentation chunks, determine which chunks are relevant to the query. Choose as few chunks as possible, likely none. Only include chunks if they are relevant to the user query. - - -${query} -${closeXml('user_query')} - - -${allChunks.map((chunk, i) => `${chunk}${closeXml(`chunk_${i}`)}`).join(DELIMITER)} -${closeXml('documentation_chunks')} -` - - const geminiStartTime = Date.now() - try { - const response = await promptAiSdkStructured({ - ...params, - messages: [userMessage(prompt)], - model: models.openrouter_gemini2_5_flash, - temperature: 0, - schema: z.object({ - relevant_chunks: z.array(z.number()), - }), - timeout: 20_000, - }) - const geminiDuration = Date.now() - geminiStartTime - - const selectedChunks = response.relevant_chunks - .filter((index) => index >= 0 && index < allChunks.length) // Sanity check indices - .map((i) => allChunks[i]) - - return { relevantChunks: selectedChunks, geminiDuration } - } catch (error) { - const e = error as Error - logger.error( - { - error: { message: e.message, stack: e.stack }, - query, - allChunksCount: allChunks.length, - }, - 'Failed to get Gemini response in filterRelevantChunks', - ) - return null - } -} diff --git a/backend/src/impl/agent-runtime.ts b/backend/src/impl/agent-runtime.ts deleted file mode 100644 index d1ff079b2d..0000000000 --- a/backend/src/impl/agent-runtime.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { consumeCreditsWithFallback } from '@codebuff/billing' -import { trackEvent } from '@codebuff/common/analytics' - -import { addAgentStep, finishAgentRun, startAgentRun } from '../agent-run' -import { - promptAiSdk, - promptAiSdkStream, - promptAiSdkStructured, -} from '../llm-apis/vercel-ai-sdk/ai-sdk' -import { fetchAgentFromDatabase } from '../templates/agent-db' -import { logger } from '../util/logger' -import { getUserInfoFromApiKey } from '../websockets/auth' - -import type { AgentTemplate } from '@codebuff/agent-runtime/templates/types' -import type { AgentRuntimeDeps } from '@codebuff/common/types/contracts/agent-runtime' - -export const BACKEND_AGENT_RUNTIME_IMPL: AgentRuntimeDeps = Object.freeze({ - // Database - getUserInfoFromApiKey, - fetchAgentFromDatabase, - startAgentRun, - finishAgentRun, - addAgentStep, - - // Billing - consumeCreditsWithFallback, - - // LLM - promptAiSdkStream, - promptAiSdk, - promptAiSdkStructured, - - // Mutable State - databaseAgentCache: new Map(), - liveUserInputRecord: {}, - sessionConnections: {}, - - // Analytics - trackEvent, - - // Other - logger, - fetch: globalThis.fetch, -}) diff --git a/backend/src/index.ts b/backend/src/index.ts deleted file mode 100644 index 7669cd357d..0000000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,178 +0,0 @@ -import http from 'http' - -import { setupBigQuery } from '@codebuff/bigquery' -import { flushAnalytics, initAnalytics } from '@codebuff/common/analytics' -import { env } from '@codebuff/internal/env' -import cors from 'cors' -import express from 'express' - -import { - getTracesForUserHandler, - relabelForUserHandler, -} from './admin/relabelRuns' -import { validateAgentNameHandler } from './api/agents' -import { isRepoCoveredHandler } from './api/org' -import usageHandler from './api/usage' -import { BACKEND_AGENT_RUNTIME_IMPL } from './impl/agent-runtime' -import { checkAdmin } from './util/check-auth' -import { logger } from './util/logger' -import { - sendRequestReconnect, - waitForAllClientsDisconnected, - listen as webSocketListen, -} from './websockets/server' - -// Grace period for graceful shutdown -const SHUTDOWN_GRACE_PERIOD_MS = 30 * 60 * 1000 - -const app = express() -const port = env.PORT - -app.use(express.json()) - -app.get('/', (req, res) => { - res.send('Codebuff Backend Server') -}) - -app.get('/healthz', (req, res) => { - res.send('ok') -}) - -app.post('/api/usage', usageHandler) -app.post('/api/orgs/is-repo-covered', isRepoCoveredHandler) -app.get('/api/agents/validate-name', validateAgentNameHandler) - -// Enable CORS for preflight requests to the admin relabel endpoint -app.options('/api/admin/relabel-for-user', cors()) - -// Add the admin routes with CORS and auth -app.get( - '/api/admin/relabel-for-user', - cors(), - checkAdmin, - getTracesForUserHandler, -) - -app.post( - '/api/admin/relabel-for-user', - cors(), - checkAdmin, - relabelForUserHandler, -) - -app.use( - ( - err: Error, - req: express.Request, - res: express.Response, - next: express.NextFunction, - ) => { - logger.error({ err }, 'Something broke!') - res.status(500).send('Something broke!') - }, -) - -// Initialize BigQuery before starting the server -setupBigQuery({ logger }).catch((err) => { - logger.error( - { - error: err, - stack: err.stack, - message: err.message, - name: err.name, - code: err.code, - details: err.details, - }, - 'Failed to initialize BigQuery client', - ) -}) - -initAnalytics({ logger }) - -const server = http.createServer(app) - -server.listen(port, () => { - logger.debug(`🚀 Server is running on port ${port}`) - console.log(`🚀 Server is running on port ${port}`) -}) -webSocketListen({ - ...BACKEND_AGENT_RUNTIME_IMPL, - server, - path: '/ws', -}) - -let shutdownInProgress = false -// Graceful shutdown handler for both SIGTERM and SIGINT -async function handleShutdown(signal: string) { - flushAnalytics() - if (env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev') { - server.close((error) => { - console.log('Received error closing server', { error }) - }) - process.exit(0) - } - if (shutdownInProgress) { - console.log(`\nReceived ${signal}. Already shutting down...`) - return - } - shutdownInProgress = true - console.log( - `\nReceived ${signal}. Starting ${SHUTDOWN_GRACE_PERIOD_MS / 60000} minute graceful shutdown period...`, - ) - - // Don't shutdown, instead ask clients to disconnect from us - sendRequestReconnect() - - waitForAllClientsDisconnected().then(() => { - console.log('All clients disconnected. Shutting down...') - process.exit(0) - }) - - // Wait for the grace period to allow clients to switch to new instances - await new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_PERIOD_MS)) - - console.log('Grace period over. Proceeding with final shutdown...') - - process.exit(1) -} - -process.on('SIGTERM', () => handleShutdown('SIGTERM')) -process.on('SIGINT', () => handleShutdown('SIGINT')) - -process.on('unhandledRejection', (reason, promise) => { - // Don't rethrow the error, just log it. Keep the server running. - const stack = reason instanceof Error ? reason.stack : undefined - const message = reason instanceof Error ? reason.message : undefined - const name = reason instanceof Error ? reason.name : undefined - console.error('unhandledRejection', message, reason, stack) - logger.error( - { - reason, - stack, - message, - name, - promise, - }, - `Unhandled promise rejection: ${reason instanceof Error ? reason.message : 'Unknown reason'}`, - ) -}) - -process.on('uncaughtException', (err, origin) => { - console.error('uncaughtException', { - error: err, - message: err.message, - stack: err.stack, - name: err.name, - origin, - }) - logger.fatal( - { - err, - stack: err.stack, - message: err.message, - name: err.name, - origin, - }, - 'uncaught exception detected', - ) -}) diff --git a/backend/src/llm-apis/knowledge.md b/backend/src/llm-apis/knowledge.md deleted file mode 100644 index 9b1472f994..0000000000 --- a/backend/src/llm-apis/knowledge.md +++ /dev/null @@ -1,39 +0,0 @@ -# LLM API Integration - -## Overview - -The LLM API integration provides unified access to multiple AI providers through the Vercel AI SDK. All models are now handled directly through the AI SDK without complex fallback mechanisms. - -## Supported Providers - -1. **Anthropic**: Claude models via direct API -2. **OpenAI**: GPT models and O-series models -3. **Google**: Gemini models with thinking support -4. **OpenRouter**: Claude and Gemini models via unified API -5. **Vertex AI**: Finetuned models -6. **DeepSeek**: Chat and reasoning models - -## Provider Configuration - -Each provider is configured in `backend/src/llm-apis/vercel-ai-sdk/`: - -- `ai-sdk.ts`: Main integration logic -- `openrouter.ts`: OpenRouter provider using OpenAI-compatible API -- `vertex-finetuned.ts`: Custom Vertex AI finetuned models - -## Model Selection - -Models are defined in `common/src/constants.ts` and automatically routed to the appropriate provider based on the model identifier. - -## OpenRouter Integration - -OpenRouter provides access to Claude models through a unified API: - -- Uses OpenAI-compatible API format -- Configured with custom headers for Codebuff -- Supports all major Claude model variants -- Pricing tracked separately in cost calculator - -## Cost Tracking - -All API calls are tracked for billing purposes with provider-specific pricing in `message-cost-tracker.ts`. diff --git a/backend/src/llm-apis/message-cost-tracker.ts b/backend/src/llm-apis/message-cost-tracker.ts deleted file mode 100644 index d4638af5cc..0000000000 --- a/backend/src/llm-apis/message-cost-tracker.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { consumeCredits, consumeOrganizationCredits } from '@codebuff/billing' -import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { - models, - PROFIT_MARGIN, - TEST_USER_ID, -} from '@codebuff/common/old-constants' -import { withRetry } from '@codebuff/common/util/promise' -import db from '@codebuff/internal/db/index' -import * as schema from '@codebuff/internal/db/schema' -import { stripeServer } from '@codebuff/internal/util/stripe' -import { logSyncFailure } from '@codebuff/internal/util/sync-failure' -import { eq } from 'drizzle-orm' -import Stripe from 'stripe' -import { WebSocket } from 'ws' - -import { getRequestContext } from '../context/app-context' -import { withLoggerContext } from '../util/logger' -import { stripNullCharsFromObject } from '../util/object' -import { SWITCHBOARD } from '../websockets/server' - -import type { ClientState } from '../websockets/switchboard' -import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { SendActionFn } from '@codebuff/common/types/contracts/client' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { - ParamsExcluding, - ParamsOf, -} from '@codebuff/common/types/function-params' -import type { Message } from '@codebuff/common/types/messages/codebuff-message' - -// Pricing details: -// - https://www.anthropic.com/pricing#anthropic-api -// - https://openai.com/pricing -// - https://ai.google.dev/pricing -type CostModelKey = keyof (typeof TOKENS_COST_PER_M)['input'] -const TOKENS_COST_PER_M = { - input: { - // [models.opus4]: 15, - // [models.sonnet]: 3, - // [models.sonnet3_7]: 3, - // [models.haiku]: 0.8, - [models.gpt4o]: 2.5, - [models.gpt4_1]: 2, - [models.gpt4omini]: 0.15, - [models.o3pro]: 20.0, - [models.o3]: 2.0, - [models.o3mini]: 1.1, - [models.o4mini]: 1.1, - [models.deepseekChat]: 0.14, - [models.deepseekReasoner]: 0.55, - [models.ft_filepicker_003]: 0.1, - [models.ft_filepicker_005]: 0.1, - [models.openrouter_claude_sonnet_4]: 3, - [models.openrouter_claude_opus_4]: 15, - [models.openrouter_claude_3_5_haiku]: 0.8, - [models.openrouter_claude_3_5_sonnet]: 3, - [models.openrouter_gpt4o]: 2.5, - [models.openrouter_gpt4o_mini]: 0.15, - [models.openrouter_gpt4_1_nano]: 0.1, - [models.openrouter_o3_mini]: 1.1, - [models.openrouter_gemini2_5_pro_preview]: 1.25, - [models.openrouter_grok_4]: 3.0, - }, - output: { - // [models.opus4]: 75, - // [models.sonnet]: 15, - // [models.sonnet3_7]: 15, - // [models.haiku]: 4, - [models.gpt4o]: 10.0, - [models.gpt4_1]: 8, - [models.gpt4omini]: 0.6, - [models.o3pro]: 80.0, - [models.o3]: 8.0, - [models.o3mini]: 4.4, - [models.o4mini]: 1.1, - [models.deepseekChat]: 0.28, - [models.deepseekReasoner]: 2.19, - [models.ft_filepicker_003]: 0.4, - [models.ft_filepicker_005]: 0.4, - [models.openrouter_claude_sonnet_4]: 15, - [models.openrouter_claude_opus_4]: 75, - [models.openrouter_claude_3_5_haiku]: 4, - [models.openrouter_claude_3_5_sonnet]: 15, - [models.openrouter_gpt4o]: 10, - [models.openrouter_gpt4o_mini]: 0.6, - [models.openrouter_gpt4_1_nano]: 0.4, - [models.openrouter_o3_mini]: 4.4, - [models.openrouter_gemini2_5_pro_preview]: 10, - [models.openrouter_grok_4]: 15.0, - }, - cache_creation: { - // [models.opus4]: 18.75, - // [models.sonnet]: 3.75, - // [models.sonnet3_7]: 3.75, - // [models.haiku]: 1, - }, - cache_read: { - // [models.opus4]: 1.5, - // [models.sonnet]: 0.3, - // [models.sonnet3_7]: 0.3, - // [models.haiku]: 0.08, - [models.deepseekChat]: 0.014, - [models.deepseekReasoner]: 0.14, - [models.gpt4o]: 1.25, - [models.gpt4_1]: 0.5, - [models.gpt4omini]: 0.075, - [models.o3]: 0.5, - [models.o3mini]: 0.55, - [models.o4mini]: 0.275, - [models.ft_filepicker_003]: 0.025, - [models.ft_filepicker_005]: 0.025, - }, -} - -const RELACE_FAST_APPLY_COST = 0.01 - -/** - * Calculates the cost for the gemini-2.5-pro-preview model based on its specific tiered pricing. - * - * Pricing rules: - * - Input tokens: - * - $1.25 per 1 million tokens for the first 200,000 tokens. - * - $2.50 per 1 million tokens for tokens beyond 200,000. - * - Output tokens: - * - $10.00 per 1 million tokens if input tokens <= 200,000. - * - $15.00 per 1 million tokens if input tokens > 200,000. - * - * @param input_tokens The number of input tokens used. - * @param output_tokens The number of output tokens generated. - * @returns The calculated cost for the API call. - */ -const getGemini25ProPreviewCost = ( - input_tokens: number, - output_tokens: number, -): number => { - let inputCost = 0 - const tier1Tokens = Math.min(input_tokens, 200_000) - const tier2Tokens = Math.max(0, input_tokens - 200_000) - - inputCost += (tier1Tokens * 1.25) / 1_000_000 - inputCost += (tier2Tokens * 2.5) / 1_000_000 - - let outputCost = 0 - if (input_tokens <= 200_000) { - outputCost = (output_tokens * 10) / 1_000_000 - } else { - outputCost = (output_tokens * 15) / 1_000_000 - } - - return inputCost + outputCost -} - -/** - * Calculates the cost for the Grok 4 model based on its tiered pricing. - * - * Pricing rules: - * - Input tokens: - * - $3.0 per 1 million tokens for the first 128,000 tokens. - * - $6.0 per 1 million tokens for tokens beyond 128,000. - * - Output tokens: - * - $15.0 per 1 million tokens if input tokens <= 128,000. - * - $30.0 per 1 million tokens if input tokens > 128,000. - * - * @param input_tokens The number of input tokens used. - * @param output_tokens The number of output tokens generated. - * @returns The calculated cost for the API call. - */ -const getGrok4Cost = (input_tokens: number, output_tokens: number): number => { - let inputCost = 0 - const tier1Tokens = Math.min(input_tokens, 128_000) - const tier2Tokens = Math.max(0, input_tokens - 128_000) - - inputCost += (tier1Tokens * 3.0) / 1_000_000 - inputCost += (tier2Tokens * 6.0) / 1_000_000 - - let outputCost = 0 - if (input_tokens <= 128_000) { - outputCost = (output_tokens * 15.0) / 1_000_000 - } else { - outputCost = (output_tokens * 30.0) / 1_000_000 - } - - return inputCost + outputCost -} - -const getPerTokenCost = ( - model: string, - type: keyof typeof TOKENS_COST_PER_M, -): number => { - const costMap = TOKENS_COST_PER_M[type] as Record - return (costMap[model as CostModelKey] ?? 0) / 1_000_000 -} - -const calcCost = ( - model: string, - input_tokens: number, - output_tokens: number, - cache_creation_input_tokens: number, - cache_read_input_tokens: number, -) => { - if (model === 'relace-fast-apply') { - return RELACE_FAST_APPLY_COST - } - if (model === models.openrouter_grok_4) { - return ( - getGrok4Cost(input_tokens, output_tokens) + - cache_creation_input_tokens * getPerTokenCost(model, 'cache_creation') + - cache_read_input_tokens * getPerTokenCost(model, 'cache_read') - ) - } - return ( - input_tokens * getPerTokenCost(model, 'input') + - output_tokens * getPerTokenCost(model, 'output') + - cache_creation_input_tokens * getPerTokenCost(model, 'cache_creation') + - cache_read_input_tokens * getPerTokenCost(model, 'cache_read') - ) -} - -const VERBOSE = false - -async function syncMessageToStripe(params: { - messageId: string - userId: string - costInCents: number - finishedAt: Date - logger: Logger -}) { - const { messageId, userId, costInCents, finishedAt, logger } = params - - if (!userId || userId === TEST_USER_ID) { - if (VERBOSE) { - logger.debug( - { messageId, userId }, - 'Skipping Stripe sync (no user or test user).', - ) - } - return - } - - const logContext = { messageId, userId, costInCents } - - try { - const user = await db.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { stripe_customer_id: true }, - }) - - if (!user?.stripe_customer_id) { - logger.warn( - logContext, - 'Cannot sync usage to Stripe: User has no stripe_customer_id.', - ) - return - } - - const stripeCustomerId = user.stripe_customer_id - const timestamp = Math.floor(finishedAt.getTime() / 1000) - - const syncAction = async () => { - await stripeServer.billing.meterEvents.create({ - event_name: 'credits', - timestamp: timestamp, - payload: { - stripe_customer_id: stripeCustomerId, - value: costInCents.toString(), - message_id: messageId, - }, - }) - - await db - .delete(schema.syncFailure) - .where(eq(schema.syncFailure.id, messageId)) - .catch((err) => - logger.error( - { ...logContext, error: err }, - 'Error deleting sync failure record after successful sync.', - ), - ) - } - - await withRetry(syncAction, { - maxRetries: 5, - retryIf: (error: Stripe.errors.StripeError) => { - if ( - error instanceof Stripe.errors.StripeConnectionError || - error instanceof Stripe.errors.StripeAPIError || - error instanceof Stripe.errors.StripeRateLimitError - ) { - logger.warn( - { ...logContext, error: error.message, type: error.type }, - 'Retrying Stripe sync due to error.', - ) - return true - } - logger.error( - { ...logContext, error: error.message, type: error.type }, - 'Non-retriable error during Stripe sync.', - ) - return false - }, - }) - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error during Stripe sync' - logger.error( - { ...logContext, error: errorMessage }, - 'Failed to sync usage to Stripe after retries.', - ) - await logSyncFailure({ - id: messageId, - errorMessage, - provider: 'stripe', - logger, - }) - } -} - -type InsertMessageParams = { - messageId: string - userId: string | undefined - clientSessionId: string - fingerprintId: string - userInputId: string - model: string - request: Message[] - response: string - inputTokens: number - outputTokens: number - cacheCreationInputTokens?: number - cacheReadInputTokens?: number - cost: number - creditsUsed: number - finishedAt: Date - latencyMs: number -} - -export async function insertMessageRecordWithRetries(params: { - messageParams: InsertMessageParams - logger: Logger - maxRetries?: number -}): Promise { - const { messageParams, logger, maxRetries = 3 } = params - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await insertMessageRecord(messageParams) - } catch (error) { - if (attempt === maxRetries) { - logger.error( - { messageId: messageParams.messageId, error, attempt }, - `Failed to save message after ${maxRetries} attempts`, - ) - return null - // TODO: Consider rethrowing the error, if we are losing too much money. - // throw error - } else { - logger.warn( - { messageId: messageParams.messageId, error: error }, - `Retrying save message to DB (attempt ${attempt}/${maxRetries})`, - ) - await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) - } - } - } - throw new Error('Failed to save message after all attempts.') -} - -async function insertMessageRecord( - params: InsertMessageParams, -): Promise { - const { - messageId, - userId, - clientSessionId, - fingerprintId, - userInputId, - model, - request, - response, - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - cost, - creditsUsed, - finishedAt, - latencyMs, - } = params - - // Get organization context from request - const requestContext = getRequestContext() - const orgId = requestContext?.approvedOrgIdForRepo - const repoUrl = requestContext?.processedRepoUrl - - const insertResult = await db - .insert(schema.message) - .values({ - ...stripNullCharsFromObject({ - id: messageId, - user_id: userId, - client_id: clientSessionId, - client_request_id: userInputId, - model: model, - request: request, - response: response, - }), - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: cacheCreationInputTokens, - cache_read_input_tokens: cacheReadInputTokens, - cost: cost.toString(), - credits: creditsUsed, - finished_at: finishedAt, - latency_ms: latencyMs, - org_id: orgId || null, - repo_url: repoUrl || null, - }) - .returning() - - if (insertResult.length === 0) { - throw new Error('Failed to insert message into DB (no rows returned).') - } - - return insertResult[0] -} - -async function sendCostResponseToClient(params: { - clientSessionId: string - userInputId: string - creditsUsed: number - agentId?: string - sendAction: SendActionFn - logger: Logger -}): Promise { - const { - clientSessionId, - userInputId, - creditsUsed, - agentId, - sendAction, - logger, - } = params - try { - const clientEntry = Array.from(SWITCHBOARD.clients.entries()).find( - ([_, state]: [WebSocket, ClientState]) => - state.sessionId === clientSessionId, - ) - - if (clientEntry) { - const [ws] = clientEntry - if (ws.readyState === WebSocket.OPEN) { - sendAction({ - action: { - type: 'message-cost-response', - promptId: userInputId, - credits: creditsUsed, - agentId, - }, - }) - } else { - logger.warn( - { clientSessionId: clientSessionId }, - 'WebSocket connection not in OPEN state when trying to send cost response.', - ) - } - } else { - logger.warn( - { clientSessionId: clientSessionId }, - 'No WebSocket connection found for cost response.', - ) - } - } catch (wsError) { - logger.error( - { clientSessionId: clientSessionId, error: wsError }, - 'Error sending message cost response via WebSocket.', - ) - } -} - -type CreditConsumptionResult = { - consumed: number - fromPurchased: number -} - -async function updateUserCycleUsageWithRetries( - params: { - userId: string - creditsUsed: number - logger: Logger - maxRetries?: number - trackEvent: TrackEventFn - } & ParamsOf, -): Promise { - const { userId, creditsUsed, logger, maxRetries = 3, trackEvent } = params - const requestContext = getRequestContext() - const orgId = requestContext?.approvedOrgIdForRepo - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await updateUserCycleUsage(params) - } catch (error) { - if (attempt === maxRetries) { - logger.error( - { userId, orgId, creditsUsed, error, attempt }, - `Failed to update user cycle usage after ${maxRetries} attempts`, - ) - - return { consumed: 0, fromPurchased: 0 } - - // TODO: Consider rethrowing the error, if we are losing too much money. - // throw error - } else { - logger.warn( - { userId, orgId, creditsUsed, error: error }, - `Retrying update user cycle usage (attempt ${attempt}/${maxRetries})`, - ) - await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) - } - } - } - throw new Error('Failed to update user cycle usage after all attempts.') -} - -async function updateUserCycleUsage(params: { - userId: string - creditsUsed: number - logger: Logger - trackEvent: TrackEventFn -}): Promise { - const { userId, creditsUsed, logger, trackEvent } = params - if (creditsUsed <= 0) { - if (VERBOSE) { - logger.debug( - { userId, creditsUsed }, - 'Skipping user usage update (zero credits).', - ) - } - return { consumed: 0, fromPurchased: 0 } - } - - // Check if this should be billed to an organization - const requestContext = getRequestContext() - const orgId = requestContext?.approvedOrgIdForRepo - - if (orgId) { - // TODO: use `consumeCreditsWithFallback` to handle organization delegation - // Consume from organization credits - const result = await consumeOrganizationCredits({ - organizationId: orgId, - creditsToConsume: creditsUsed, - logger, - }) - - if (VERBOSE) { - logger.debug( - { userId, orgId, creditsUsed, ...result }, - `Consumed organization credits (${creditsUsed})`, - ) - } - - trackEvent({ - event: AnalyticsEvent.CREDIT_CONSUMED, - userId, - properties: { - creditsUsed, - fromPurchased: result.fromPurchased, - organizationId: orgId, - }, - logger, - }) - - return result - } else { - // Consume from personal credits - const result = await consumeCredits({ - userId, - creditsToConsume: creditsUsed, - logger, - }) - - if (VERBOSE) { - logger.debug( - { userId, creditsUsed, ...result }, - `Consumed personal credits (${creditsUsed})`, - ) - } - - trackEvent({ - event: AnalyticsEvent.CREDIT_CONSUMED, - userId, - properties: { - creditsUsed, - fromPurchased: result.fromPurchased, - }, - logger, - }) - - return result - } -} - -export async function saveMessage( - params: { - messageId: string - userId: string | undefined - clientSessionId: string - fingerprintId: string - userInputId: string - model: string - request: Message[] - response: string - inputTokens: number - outputTokens: number - cacheCreationInputTokens?: number - cacheReadInputTokens?: number - finishedAt: Date - latencyMs: number - usesUserApiKey?: boolean - chargeUser?: boolean - costOverrideDollars?: number - agentId?: string - logger: Logger - trackEvent: TrackEventFn - } & ParamsExcluding, -): Promise { - const { - messageId, - userId, - fingerprintId, - costOverrideDollars, - model, - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - logger, - trackEvent, - } = params - - return withLoggerContext( - { - messageId, - userId, - fingerprintId, - }, - async () => { - const cost = - costOverrideDollars ?? - calcCost( - model, - inputTokens, - outputTokens, - cacheCreationInputTokens ?? 0, - cacheReadInputTokens ?? 0, - ) - - // Default to 1 cent per credit - const centsPerCredit = 1 - - const costInCents = - params.chargeUser ?? true // default to true - ? Math.max( - 0, - Math.round( - cost * - 100 * - (params.usesUserApiKey ? PROFIT_MARGIN : 1 + PROFIT_MARGIN), - ), - ) - : 0 - - const creditsUsed = Math.max(0, costInCents) - - if (userId === TEST_USER_ID) { - logger.info( - { - costUSD: cost, - costInCents, - creditsUsed, - centsPerCredit, - value: { ...params, request: 'Omitted', response: 'Omitted' }, - }, - `Credits used by test user (${creditsUsed})`, - ) - return creditsUsed - } - - if (VERBOSE) { - logger.debug( - { - messageId, - costUSD: cost, - costInCents, - creditsUsed, - centsPerCredit, - }, - `Calculated credits (${creditsUsed})`, - ) - } - - sendCostResponseToClient({ - ...params, - creditsUsed, - }) - - await insertMessageRecordWithRetries({ - messageParams: { - ...params, - cost, - creditsUsed, - }, - logger, - }) - - if (!params.userId) { - logger.debug( - { messageId: params.messageId, userId: params.userId }, - 'Skipping further processing (no user ID or failed to save message).', - ) - return 0 - } - - const consumptionResult = await updateUserCycleUsageWithRetries({ - userId: params.userId, - creditsUsed, - logger, - trackEvent, - }) - - // Only sync the portion from purchased credits to Stripe - if (consumptionResult.fromPurchased > 0) { - const purchasedCostInCents = Math.round( - (costInCents * consumptionResult.fromPurchased) / creditsUsed, - ) - syncMessageToStripe({ - messageId: params.messageId, - userId: params.userId, - costInCents: purchasedCostInCents, - finishedAt: params.finishedAt, - logger, - }).catch((syncError) => { - logger.error( - { messageId: params.messageId, error: syncError }, - 'Background Stripe sync failed.', - ) - }) - } else if (VERBOSE) { - logger.debug( - { messageId: params.messageId }, - 'Skipping Stripe sync (no purchased credits used)', - ) - } - - return creditsUsed - }, - ) -} diff --git a/backend/src/llm-apis/openrouter.ts b/backend/src/llm-apis/openrouter.ts deleted file mode 100644 index 4e0d296aa0..0000000000 --- a/backend/src/llm-apis/openrouter.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { models } from '@codebuff/common/old-constants' -import { isExplicitlyDefinedModel } from '@codebuff/common/util/model-utils' -import { env } from '@codebuff/internal/env' -import { createOpenRouter } from '@codebuff/internal/openrouter-ai-sdk' - -import type { Model } from '@codebuff/common/old-constants' - -// Provider routing documentation: https://openrouter.ai/docs/features/provider-routing -const providerOrder = { - [models.openrouter_claude_sonnet_4]: [ - 'Google', - 'Anthropic', - 'Amazon Bedrock', - ], - [models.openrouter_claude_sonnet_4_5]: [ - 'Google', - 'Anthropic', - 'Amazon Bedrock', - ], - [models.openrouter_claude_opus_4]: ['Google', 'Anthropic'], -} as const - -export function openRouterLanguageModel(model: Model) { - const extraBody: Record = { - transforms: ['middle-out'], - } - - // Set allow_fallbacks based on whether model is explicitly defined - const isExplicitlyDefined = isExplicitlyDefinedModel(model) - - extraBody.provider = { - order: providerOrder[model as keyof typeof providerOrder], - allow_fallbacks: !isExplicitlyDefined, - } - - return createOpenRouter({ - apiKey: env.OPEN_ROUTER_API_KEY, - headers: { - 'HTTP-Referer': 'https://codebuff.com', - 'X-Title': 'Codebuff', - }, - extraBody, - }).languageModel(model, { - usage: { include: true }, - logprobs: true, - }) -} diff --git a/backend/src/llm-apis/relace-api.ts b/backend/src/llm-apis/relace-api.ts deleted file mode 100644 index 6437939838..0000000000 --- a/backend/src/llm-apis/relace-api.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { countTokens } from '@codebuff/agent-runtime/util/token-counter' -import { userMessage } from '@codebuff/common/util/messages' -import { env } from '@codebuff/internal/env' - -import { saveMessage } from '../llm-apis/message-cost-tracker' - -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' - -const timeoutPromise = (ms: number) => - new Promise((_, reject) => - setTimeout(() => reject(new Error('Relace API request timed out')), ms), - ) - -export interface RankedFile { - file: T - score: number -} - -export type FileWithPath = { - path: string - content: string -} - -export async function rerank( - params: { - files: FileWithPath[] - prompt: string - messageId: string - logger: Logger - } & ParamsExcluding< - typeof saveMessage, - | 'model' - | 'request' - | 'response' - | 'inputTokens' - | 'outputTokens' - | 'finishedAt' - | 'latencyMs' - >, -) { - const { files, prompt, messageId, logger } = params - const startTime = Date.now() - - if (!prompt || !files.length) { - logger.warn('Empty prompt or files array passed to rerank') - return files.map((f) => f.path) - } - - // Convert files to Relace format - const relaceFiles = files.map((f) => ({ - filename: f.path, - code: f.content, - })) - - try { - const response = (await Promise.race([ - fetch('https://ranker.endpoint.relace.run/v1/code/rank', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${env.RELACE_API_KEY}`, - }, - body: JSON.stringify({ - query: prompt, - codebase: relaceFiles, - token_limit: 128000, - 'relace-metadata': { - 'codebuff-id': messageId, - 'codebuff-user-prompt': prompt, - }, - }), - }), - timeoutPromise(100_000), - ])) as Response - - if (!response.ok) { - throw new Error( - `Relace API error: ${response.status} ${response.statusText}`, - ) - } - - const rankings = (await response.json()) as string[] - if (!rankings || !Array.isArray(rankings)) { - throw new Error('Invalid response format from Relace API') - } - - const fakeRequestContent = `Query: ${prompt}\n\nFiles:\n${files.map((f) => `${f.path}:\n${f.content}`).join('\n\n')}` - saveMessage({ - ...params, - model: 'relace-ranker', - request: [userMessage(fakeRequestContent)], - response: JSON.stringify(rankings), - inputTokens: countTokens(fakeRequestContent), - outputTokens: countTokens(JSON.stringify(rankings)), - finishedAt: new Date(), - latencyMs: Date.now() - startTime, - }) - - return rankings - } catch (error) { - logger.error( - { - error: - error && typeof error === 'object' && 'message' in error - ? error.message - : 'Unknown error', - }, - 'Error calling Relace ranker API', - ) - // Return original files order on error instead of throwing - return files.map((f) => f.path) - } -} diff --git a/backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts b/backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts deleted file mode 100644 index 9a197a8658..0000000000 --- a/backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import { - checkLiveUserInput, - getLiveUserInputIds, -} from '@codebuff/agent-runtime/live-user-inputs' -import { - finetunedVertexModels, - openaiModels, -} from '@codebuff/common/old-constants' -import { buildArray } from '@codebuff/common/util/array' -import { getErrorObject } from '@codebuff/common/util/error' -import { convertCbToModelMessages } from '@codebuff/common/util/messages' -import { withTimeout } from '@codebuff/common/util/promise' -import { StopSequenceHandler } from '@codebuff/common/util/stop-sequence' -import { APICallError, generateObject, generateText, streamText } from 'ai' - -import { saveMessage } from '../message-cost-tracker' -import { openRouterLanguageModel } from '../openrouter' -import { vertexFinetuned } from './vertex-finetuned' - -import type { Model, OpenAIModel } from '@codebuff/common/old-constants' -import type { - PromptAiSdkFn, - PromptAiSdkStreamFn, - PromptAiSdkStructuredInput, - PromptAiSdkStructuredOutput, -} from '@codebuff/common/types/contracts/llm' -import type { ParamsOf } from '@codebuff/common/types/function-params' -import type { - OpenRouterProviderOptions, - OpenRouterUsageAccounting, -} from '@openrouter/ai-sdk-provider' -import type { LanguageModel } from 'ai' -import type { z } from 'zod/v4' - -export type StreamChunk = - | { - type: 'text' - text: string - agentId?: string - } - | { - type: 'reasoning' - text: string - } - | { type: 'error'; message: string } -// TODO: We'll want to add all our models here! -const modelToAiSDKModel = (model: Model): LanguageModel => { - if ( - Object.values(finetunedVertexModels as Record).includes( - model, - ) - ) { - return vertexFinetuned(model) - } - if (model === openaiModels.o3pro || model === openaiModels.o3) { - return openai.responses(model) - } - if (Object.values(openaiModels).includes(model as OpenAIModel)) { - return openai.languageModel(model) - } - // All other models go through OpenRouter - return openRouterLanguageModel(model) -} - -// TODO: Add retries & fallbacks: likely by allowing this to instead of "model" -// also take an array of form [{model: Model, retries: number}, {model: Model, retries: number}...] -// eg: [{model: "gemini-2.0-flash-001"}, {model: "vertex/gemini-2.0-flash-001"}, {model: "claude-3-5-haiku", retries: 3}] -export async function* promptAiSdkStream( - params: ParamsOf, -): ReturnType { - const { logger } = params - const agentChunkMetadata = - params.agentId != null ? { agentId: params.agentId } : undefined - - if ( - !checkLiveUserInput({ ...params, clientSessionId: params.clientSessionId }) - ) { - logger.info( - { - userId: params.userId, - userInputId: params.userInputId, - liveUserInputId: getLiveUserInputIds(params), - }, - 'Skipping stream due to canceled user input', - ) - return null - } - const startTime = Date.now() - - let aiSDKModel = modelToAiSDKModel(params.model) - - const response = streamText({ - ...params, - prompt: undefined, - model: aiSDKModel, - messages: convertCbToModelMessages(params), - }) - - let content = '' - const stopSequenceHandler = new StopSequenceHandler(params.stopSequences) - - for await (const chunk of response.fullStream) { - if (chunk.type !== 'text-delta') { - const flushed = stopSequenceHandler.flush() - if (flushed) { - content += flushed - yield { - type: 'text', - text: flushed, - ...(agentChunkMetadata ?? {}), - } - } - } - if (chunk.type === 'error') { - logger.error( - { - chunk: { ...chunk, error: undefined }, - error: getErrorObject(chunk.error), - model: params.model, - }, - 'Error from AI SDK', - ) - - const errorBody = APICallError.isInstance(chunk.error) - ? chunk.error.responseBody - : undefined - const mainErrorMessage = - chunk.error instanceof Error - ? chunk.error.message - : typeof chunk.error === 'string' - ? chunk.error - : JSON.stringify(chunk.error) - const errorMessage = `Error from AI SDK (model ${params.model}): ${buildArray([mainErrorMessage, errorBody]).join('\n')}` - yield { - type: 'error', - message: errorMessage, - } - - return null - } - if (chunk.type === 'reasoning-delta') { - for (const provider of ['openrouter', 'codebuff'] as const) { - if ( - ( - params.providerOptions?.[provider] as - | OpenRouterProviderOptions - | undefined - )?.reasoning?.exclude - ) { - continue - } - } - yield { - type: 'reasoning', - text: chunk.text, - } - } - if (chunk.type === 'text-delta') { - if (!params.stopSequences) { - content += chunk.text - if (chunk.text) { - yield { - type: 'text', - text: chunk.text, - ...(agentChunkMetadata ?? {}), - } - } - continue - } - - const stopSequenceResult = stopSequenceHandler.process(chunk.text) - if (stopSequenceResult.text) { - content += stopSequenceResult.text - yield { - type: 'text', - text: stopSequenceResult.text, - ...(agentChunkMetadata ?? {}), - } - } - } - } - const flushed = stopSequenceHandler.flush() - if (flushed) { - content += flushed - yield { - type: 'text', - text: flushed, - ...(agentChunkMetadata ?? {}), - } - } - - const providerMetadata = (await response.providerMetadata) ?? {} - const usage = await response.usage - let inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - let cacheReadInputTokens: number = 0 - let cacheCreationInputTokens: number = 0 - let costOverrideDollars: number | undefined - if (providerMetadata.anthropic) { - cacheReadInputTokens = - typeof providerMetadata.anthropic.cacheReadInputTokens === 'number' - ? providerMetadata.anthropic.cacheReadInputTokens - : 0 - cacheCreationInputTokens = - typeof providerMetadata.anthropic.cacheCreationInputTokens === 'number' - ? providerMetadata.anthropic.cacheCreationInputTokens - : 0 - } - if (providerMetadata.openrouter) { - if (providerMetadata.openrouter.usage) { - const openrouterUsage = providerMetadata.openrouter - .usage as OpenRouterUsageAccounting - cacheReadInputTokens = - openrouterUsage.promptTokensDetails?.cachedTokens ?? 0 - inputTokens = openrouterUsage.promptTokens - cacheReadInputTokens - - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } - } - - const messageId = (await response.response).id - const creditsUsedPromise = saveMessage({ - ...params, - messageId, - request: params.messages, - response: content, - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - finishedAt: new Date(), - latencyMs: Date.now() - startTime, - chargeUser: params.chargeUser ?? true, - costOverrideDollars, - }) - - // Call the cost callback if provided - if (params.onCostCalculated) { - const creditsUsed = await creditsUsedPromise - await params.onCostCalculated(creditsUsed) - } - - return messageId -} - -// TODO: figure out a nice way to unify stream & non-stream versions maybe? -export async function promptAiSdk( - params: ParamsOf, -): ReturnType { - const { logger } = params - - if (!checkLiveUserInput(params)) { - logger.info( - { - userId: params.userId, - userInputId: params.userInputId, - liveUserInputId: getLiveUserInputIds(params), - }, - 'Skipping prompt due to canceled user input', - ) - return '' - } - - const startTime = Date.now() - let aiSDKModel = modelToAiSDKModel(params.model) - - const response = await generateText({ - ...params, - prompt: undefined, - model: aiSDKModel, - messages: convertCbToModelMessages(params), - }) - const content = response.text - - const messageId = response.response.id - const providerMetadata = response.providerMetadata ?? {} - const usage = response.usage - let inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - let cacheReadInputTokens: number = 0 - let cacheCreationInputTokens: number = 0 - let costOverrideDollars: number | undefined - if (providerMetadata.anthropic) { - cacheReadInputTokens = - typeof providerMetadata.anthropic.cacheReadInputTokens === 'number' - ? providerMetadata.anthropic.cacheReadInputTokens - : 0 - cacheCreationInputTokens = - typeof providerMetadata.anthropic.cacheCreationInputTokens === 'number' - ? providerMetadata.anthropic.cacheCreationInputTokens - : 0 - } - if (providerMetadata.openrouter) { - if (providerMetadata.openrouter.usage) { - const openrouterUsage = providerMetadata.openrouter - .usage as OpenRouterUsageAccounting - cacheReadInputTokens = - openrouterUsage.promptTokensDetails?.cachedTokens ?? 0 - inputTokens = openrouterUsage.promptTokens - cacheReadInputTokens - - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } - } - - const creditsUsedPromise = saveMessage({ - ...params, - messageId, - request: params.messages, - response: content, - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - finishedAt: new Date(), - latencyMs: Date.now() - startTime, - chargeUser: params.chargeUser ?? true, - costOverrideDollars, - }) - - // Call the cost callback if provided - if (params.onCostCalculated) { - const creditsUsed = await creditsUsedPromise - await params.onCostCalculated(creditsUsed) - } - - return content -} - -// Copied over exactly from promptAiSdk but with a schema -export async function promptAiSdkStructured( - params: PromptAiSdkStructuredInput, -): PromptAiSdkStructuredOutput { - const { logger } = params - - if (!checkLiveUserInput(params)) { - logger.info( - { - userId: params.userId, - userInputId: params.userInputId, - liveUserInputId: getLiveUserInputIds(params), - }, - 'Skipping structured prompt due to canceled user input', - ) - return {} as T - } - const startTime = Date.now() - let aiSDKModel = modelToAiSDKModel(params.model) - - const responsePromise = generateObject, 'object'>({ - ...params, - prompt: undefined, - model: aiSDKModel, - output: 'object', - messages: convertCbToModelMessages(params), - }) - - const response = await (params.timeout === undefined - ? responsePromise - : withTimeout(responsePromise, params.timeout)) - const content = response.object - - const messageId = response.response.id - const providerMetadata = response.providerMetadata ?? {} - const usage = response.usage - let inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - let cacheReadInputTokens: number = 0 - let cacheCreationInputTokens: number = 0 - let costOverrideDollars: number | undefined - if (providerMetadata.anthropic) { - cacheReadInputTokens = - typeof providerMetadata.anthropic.cacheReadInputTokens === 'number' - ? providerMetadata.anthropic.cacheReadInputTokens - : 0 - cacheCreationInputTokens = - typeof providerMetadata.anthropic.cacheCreationInputTokens === 'number' - ? providerMetadata.anthropic.cacheCreationInputTokens - : 0 - } - if (providerMetadata.openrouter) { - if (providerMetadata.openrouter.usage) { - const openrouterUsage = providerMetadata.openrouter - .usage as OpenRouterUsageAccounting - cacheReadInputTokens = - openrouterUsage.promptTokensDetails?.cachedTokens ?? 0 - inputTokens = openrouterUsage.promptTokens - cacheReadInputTokens - - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } - } - - const creditsUsedPromise = saveMessage({ - ...params, - messageId, - request: params.messages, - response: JSON.stringify(content), - inputTokens, - outputTokens, - cacheCreationInputTokens, - cacheReadInputTokens, - finishedAt: new Date(), - latencyMs: Date.now() - startTime, - chargeUser: params.chargeUser ?? true, - costOverrideDollars, - }) - - // Call the cost callback if provided - if (params.onCostCalculated) { - const creditsUsed = await creditsUsedPromise - await params.onCostCalculated(creditsUsed) - } - - return content -} diff --git a/backend/src/llm-apis/vercel-ai-sdk/openrouter.ts b/backend/src/llm-apis/vercel-ai-sdk/openrouter.ts deleted file mode 100644 index c4ecad26eb..0000000000 --- a/backend/src/llm-apis/vercel-ai-sdk/openrouter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createOpenAI } from '@ai-sdk/openai' -import { env } from '@codebuff/internal/env' - -/** - * Create OpenRouter provider using OpenAI-compatible API - */ -export const openrouter = createOpenAI({ - name: 'openrouter', - apiKey: env.OPEN_ROUTER_API_KEY, - baseURL: 'https://openrouter.ai/api/v1', - headers: { - 'HTTP-Referer': 'https://codebuff.com', - 'X-Title': 'Codebuff', - }, -}) diff --git a/backend/src/llm-apis/vercel-ai-sdk/vertex-finetuned.ts b/backend/src/llm-apis/vercel-ai-sdk/vertex-finetuned.ts deleted file mode 100644 index eb453c5341..0000000000 --- a/backend/src/llm-apis/vercel-ai-sdk/vertex-finetuned.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createVertex } from '@ai-sdk/google-vertex' - -const VERTEX_PROJECT = 'codebuff' -const VERTEX_LOCATION = 'us-west1' - -// This takes a fetch request, and patches it to replace a non-finetuning endpoint: -// eg: https://${location}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${location}/publishers/google/models/${MODEL_ID}:generateContent -d \ -// with the identical finetuning endpoint: -// https://TUNING_JOB_REGION-aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/TUNING_JOB_REGION/endpoints/${ENDPOINT_ID}:generateContent -// Notably: this keeps the same {location}, {project}, and {model} - -function patchedFetchForFinetune( - requestInfo: string | URL | Request, - requestInit?: RequestInit, -): Promise { - function patchString(str: string) { - return str.replace(`/publishers/google/models`, `/endpoints`) - } - - if (requestInfo instanceof URL) { - let patchedUrl = new URL(requestInfo) - patchedUrl.pathname = patchString(patchedUrl.pathname) - return fetch(patchedUrl, requestInit) - } - if (requestInfo instanceof Request) { - let patchedUrl = patchString(requestInfo.url) - let patchedRequest = new Request(patchedUrl, requestInfo) - return fetch(patchedRequest, requestInit) - } - if (typeof requestInfo === 'string') { - let patchedUrl = patchString(requestInfo) - return fetch(patchedUrl, requestInit) - } - // Should never happen - throw new Error('Unexpected requestInfo type: ' + typeof requestInfo) -} - -export const vertexFinetuned = createVertex({ - project: VERTEX_PROJECT, - location: VERTEX_LOCATION, - fetch: patchedFetchForFinetune as unknown as typeof globalThis.fetch, -}) diff --git a/backend/src/templates/agent-db.ts b/backend/src/templates/agent-db.ts deleted file mode 100644 index c5a1e36fa0..0000000000 --- a/backend/src/templates/agent-db.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { validateSingleAgent } from '@codebuff/common/templates/agent-validation' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { and, desc, eq } from 'drizzle-orm' - -import type { FetchAgentFromDatabaseFn } from '@codebuff/common/types/contracts/database' -import type { DynamicAgentTemplate } from '@codebuff/common/types/dynamic-agent-template' -import type { ParamsOf } from '@codebuff/common/types/function-params' - -/** - * Fetch and validate an agent from the database by publisher/agent-id[@version] format - */ -export async function fetchAgentFromDatabase( - params: ParamsOf, -): ReturnType { - const { parsedAgentId, logger } = params - const { publisherId, agentId, version } = parsedAgentId - - try { - let agentConfig - - if (version && version !== 'latest') { - // Query for specific version - agentConfig = await db - .select() - .from(schema.agentConfig) - .where( - and( - eq(schema.agentConfig.id, agentId), - eq(schema.agentConfig.publisher_id, publisherId), - eq(schema.agentConfig.version, version), - ), - ) - .then((rows) => rows[0]) - } else { - // Query for latest version - agentConfig = await db - .select() - .from(schema.agentConfig) - .where( - and( - eq(schema.agentConfig.id, agentId), - eq(schema.agentConfig.publisher_id, publisherId), - ), - ) - .orderBy( - desc(schema.agentConfig.major), - desc(schema.agentConfig.minor), - desc(schema.agentConfig.patch), - ) - .limit(1) - .then((rows) => rows[0]) - } - - if (!agentConfig) { - logger.debug( - { publisherId, agentId, version }, - 'fetchAgentFromDatabase: Agent not found in database', - ) - return null - } - - const rawAgentData = agentConfig.data as DynamicAgentTemplate - - // Validate the raw agent data with the original agentId (not full identifier) - const validationResult = validateSingleAgent({ - template: { ...rawAgentData, id: agentId, version: agentConfig.version }, - filePath: `${publisherId}/${agentId}@${agentConfig.version}`, - }) - - if (!validationResult.success) { - logger.error( - { - publisherId, - agentId, - version: agentConfig.version, - error: validationResult.error, - }, - 'fetchAgentFromDatabase: Agent validation failed', - ) - return null - } - - // Set the correct full agent ID for the final template - const agentTemplate = { - ...validationResult.agentTemplate!, - id: `${publisherId}/${agentId}@${agentConfig.version}`, - } - - logger.debug( - { - publisherId, - agentId, - version: agentConfig.version, - fullAgentId: agentTemplate.id, - parsedAgentId, - }, - 'fetchAgentFromDatabase: Successfully loaded and validated agent from database', - ) - - return agentTemplate - } catch (error) { - logger.error( - { publisherId, agentId, version, error }, - 'fetchAgentFromDatabase: Error fetching agent from database', - ) - return null - } -} diff --git a/backend/src/util/__tests__/object.test.ts b/backend/src/util/__tests__/object.test.ts deleted file mode 100644 index b3a9cb1e52..0000000000 --- a/backend/src/util/__tests__/object.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect } from 'bun:test' - -import { stripNullCharsFromObject } from '../object' - -describe('stripNullCharsFromObject', () => { - it('should remove null characters from strings in a simple object', () => { - const input = { - a: 'hello\u0000world', - b: 'test\u0000ing', - c: 123, - } - const expected = { - a: 'helloworld', - b: 'testing', - c: 123, - } - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should remove null characters from strings in a nested object', () => { - const input = { - level1: { - a: 'nested\u0000string', - b: true, - level2: { - c: 'deep\u0000er', - d: null, - }, - }, - e: 'top\u0000level', - } - const expected = { - level1: { - a: 'nestedstring', - b: true, - level2: { - c: 'deeper', - d: null, - }, - }, - e: 'toplevel', - } - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should remove null characters from strings within an array', () => { - const input = ['one\u0000', 'two\u0000three', 4, 'five'] - const expected = ['one', 'twothree', 4, 'five'] - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should remove null characters from strings within objects in an array', () => { - const input = [ - { id: 1, text: 'item\u0000one' }, - { id: 2, text: 'item\u0000two', value: 10 }, - { id: 3, text: 'item three' }, - ] - const expected = [ - { id: 1, text: 'itemone' }, - { id: 2, text: 'itemtwo', value: 10 }, - { id: 3, text: 'item three' }, - ] - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should handle mixed data types correctly', () => { - const input = { - str: 'a\u0000b', - num: 123, - bool: false, - nil: null, - undef: undefined, - arr: [1, 'c\u0000d', true], - obj: { nested: 'e\u0000f' }, - } - const expected = { - str: 'ab', - num: 123, - bool: false, - nil: null, - undef: undefined, - arr: [1, 'cd', true], - obj: { nested: 'ef' }, - } - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should handle empty objects and arrays', () => { - expect(stripNullCharsFromObject({})).toEqual({}) - expect(stripNullCharsFromObject([])).toEqual([]) - }) - - it('should handle a string input directly', () => { - const input = 'direct\u0000string\u0000test' - const expected = 'directstringtest' - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) - - it('should handle non-string, non-object, non-array inputs', () => { - expect(stripNullCharsFromObject(123)).toBe(123) - expect(stripNullCharsFromObject(true)).toBe(true) - expect(stripNullCharsFromObject(null)).toBe(null) - expect(stripNullCharsFromObject(undefined)).toBe(undefined) - }) - - it('should handle complex nested structures', () => { - const input = { - users: [ - { name: 'Alice\u0000', id: 1, roles: ['admin\u0000', 'editor'] }, - { name: 'Bob', id: 2, roles: ['viewer\u0000'] }, - ], - settings: { - theme: 'dark\u0000mode', - notifications: { email: true, sms: 'no\u0000t enabled' }, - }, - status: 'active\u0000', - } - const expected = { - users: [ - { name: 'Alice', id: 1, roles: ['admin', 'editor'] }, - { name: 'Bob', id: 2, roles: ['viewer'] }, - ], - settings: { - theme: 'darkmode', - notifications: { email: true, sms: 'not enabled' }, - }, - status: 'active', - } - expect(stripNullCharsFromObject(input)).toEqual(expected) - }) -}) diff --git a/backend/src/util/auth-helpers.ts b/backend/src/util/auth-helpers.ts deleted file mode 100644 index f01355194f..0000000000 --- a/backend/src/util/auth-helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Request } from 'express' - -/** - * Extract auth token from x-codebuff-api-key header - */ -export function extractAuthTokenFromHeader(req: Request): string | undefined { - const token = req.headers['x-codebuff-api-key'] as string | undefined - // Trim any whitespace that might be present - return token?.trim() -} diff --git a/backend/src/util/check-auth.ts b/backend/src/util/check-auth.ts deleted file mode 100644 index 2859cd8d3b..0000000000 --- a/backend/src/util/check-auth.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { utils } from '@codebuff/internal' - -import { extractAuthTokenFromHeader } from './auth-helpers' -import { getUserInfoFromApiKey } from '../websockets/auth' - -import type { ServerAction } from '@codebuff/common/actions' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' -import type { Request, Response, NextFunction } from 'express' - -export const checkAuth = async ( - params: { - authToken?: string - clientSessionId: string - getUserInfoFromApiKey: GetUserInfoFromApiKeyFn - logger: Logger - } & ParamsExcluding, -): Promise => { - const { authToken, clientSessionId, getUserInfoFromApiKey, logger } = params - - // Use shared auth check functionality - const authResult = authToken - ? await getUserInfoFromApiKey({ - ...params, - apiKey: authToken, - fields: ['id'], - }) - : null - - if (!authResult) { - const errorMessage = 'Authentication failed' - logger.error({ clientSessionId, error: errorMessage }, errorMessage) - return { - type: 'action-error', - message: errorMessage, - } - } - - // if (authResult.user) { - // // Log successful authentication if we have a user - // logger.debug( - // { clientSessionId, userId: authResult.user.id }, - // 'Authentication successful' - // ) - // } - - return -} - -// Express middleware for checking admin access -export const checkAdmin = - (logger: Logger) => - async (req: Request, res: Response, next: NextFunction) => { - // Extract auth token from x-codebuff-api-key header - const authToken = extractAuthTokenFromHeader(req) - if (!authToken) { - return res - .status(401) - .json({ error: 'Missing x-codebuff-api-key header' }) - } - - // Generate a client session ID for this request - const clientSessionId = `admin-relabel-${Date.now()}` - - // Check authentication - const user = await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id', 'email'], - logger, - }) - - if (!user) { - return res.status(401).json({ error: 'Invalid session' }) - } - - // Check if user has admin access using shared utility - const adminUser = await utils.checkUserIsCodebuffAdmin(user.id) - if (!adminUser) { - logger.warn( - { userId: user.id, email: user.email, clientSessionId }, - 'Unauthorized access attempt to admin endpoint', - ) - return res.status(403).json({ error: 'Forbidden' }) - } - - // Store user info in request for handlers to use if needed - // req.user = adminUser // TODO: ensure type check passes - - // Auth passed and user is admin, proceed to next middleware - next() - return - } diff --git a/backend/src/util/logger.ts b/backend/src/util/logger.ts deleted file mode 100644 index d5929a92e2..0000000000 --- a/backend/src/util/logger.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { mkdirSync } from 'fs' -import path from 'path' -import { format } from 'util' - -import { splitData } from '@codebuff/common/util/split-data' -import { env } from '@codebuff/internal/env' -import pino from 'pino' - -import { - getLoggerContext, - withAppContext, - type LoggerContext, -} from '../context/app-context' - -// --- Constants --- -const MAX_LENGTH = 65535 // Max total log size is sometimes 100k (sometimes 65535?) -const BUFFER = 1000 // Buffer for context, etc. - -export const withLoggerContext = ( - additionalContext: Partial, - fn: () => Promise, -) => { - // Use the new combined context, preserving any existing request context - return withAppContext(additionalContext, {}, fn) -} - -// Ensure debug directory exists for local environment -const debugDir = path.join(__dirname, '../../../debug') -if ( - env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' && - process.env['CODEBUFF_GITHUB_ACTIONS'] !== 'true' -) { - try { - mkdirSync(debugDir, { recursive: true }) - } catch (err) { - console.error('Failed to create debug directory:', err) - } -} - -const pinoLogger = pino( - { - level: 'debug', - mixin() { - // Use the new combined context - return { logTrace: getLoggerContext() } - }, - formatters: { - level: (label) => { - return { level: label.toUpperCase() } - }, - }, - timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`, - }, - env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' && - process.env['CODEBUFF_GITHUB_ACTIONS'] !== 'true' - ? pino.transport({ - target: 'pino/file', - options: { - destination: path.join(debugDir, 'backend.log'), - }, - level: 'debug', - }) - : undefined, -) - -const loggingLevels = ['info', 'debug', 'warn', 'error', 'fatal'] as const -type LogLevel = (typeof loggingLevels)[number] - -function splitAndLog( - level: LogLevel, - data: any, - msg?: string, - ...args: any[] -): void { - const formattedMsg = format(msg ?? '', ...args) - const availableDataLimit = MAX_LENGTH - BUFFER - formattedMsg.length - - // split data recursively into chunks small enough to log - const processedData: any[] = splitData({ - data, - maxChunkSize: availableDataLimit, - }) - - if (processedData.length === 1) { - pinoLogger[level](processedData[0], msg, ...args) - return - } - - processedData.forEach((chunk, index) => { - pinoLogger[level]( - chunk, - `${formattedMsg} (chunk ${index + 1}/${processedData.length})`, - ) - }) -} - -export const logger: Record = - env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' - ? pinoLogger - : (Object.fromEntries( - loggingLevels.map((level) => { - return [ - level, - (data: any, msg?: string, ...args: any[]) => - splitAndLog(level, data, msg, ...args), - ] - }), - ) as Record) diff --git a/backend/src/util/object.ts b/backend/src/util/object.ts deleted file mode 100644 index 8cb5486717..0000000000 --- a/backend/src/util/object.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { stripNullChars } from '@codebuff/common/util/string' - -/** - * Recursively traverses an object or array and removes null characters (\u0000) - * from all string values. - * - * @param input The object or array to sanitize. - * @returns A new object or array with null characters removed from strings. - */ -export function stripNullCharsFromObject(input: T): T { - if (typeof input === 'string') { - // Explicitly cast back to T, assuming T could be string - return stripNullChars(input) as T - } - - if (Array.isArray(input)) { - // Explicitly cast back to T, assuming T could be an array type - return input.map(stripNullCharsFromObject) as T - } - - if (input !== null && typeof input === 'object') { - const sanitizedObject: { [key: string]: any } = {} - for (const key in input) { - // Ensure we only process own properties - if (Object.prototype.hasOwnProperty.call(input, key)) { - sanitizedObject[key] = stripNullCharsFromObject(input[key]) - } - } - // Explicitly cast back to T - return sanitizedObject as T - } - - // Return non-object/array/string types as is - return input -} diff --git a/backend/src/websockets/auth.ts b/backend/src/websockets/auth.ts deleted file mode 100644 index e68b8a87ae..0000000000 --- a/backend/src/websockets/auth.ts +++ /dev/null @@ -1,30 +0,0 @@ -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' - -import type { - GetUserInfoFromApiKeyInput, - GetUserInfoFromApiKeyOutput, - UserColumn, -} from '@codebuff/common/types/contracts/database' - -export async function getUserInfoFromApiKey( - params: GetUserInfoFromApiKeyInput, -): GetUserInfoFromApiKeyOutput { - const { apiKey, fields } = params - - // Build a typed selection object for user columns - const userSelection = Object.fromEntries( - fields.map((field) => [field, schema.user[field]]), - ) as { [K in T]: (typeof schema.user)[K] } - - const rows = await db - .select({ user: userSelection }) // <-- important: nest under 'user' - .from(schema.user) - .leftJoin(schema.session, eq(schema.user.id, schema.session.userId)) - .where(eq(schema.session.sessionToken, apiKey)) - .limit(1) - - // Drizzle returns { user: ..., session: ... }, we return only the user part - return rows[0]?.user ?? null -} diff --git a/backend/src/websockets/middleware.ts b/backend/src/websockets/middleware.ts deleted file mode 100644 index a56eef317b..0000000000 --- a/backend/src/websockets/middleware.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { - calculateUsageAndBalance, - triggerMonthlyResetAndGrant, - checkAndTriggerAutoTopup, - checkAndTriggerOrgAutoTopup, - calculateOrganizationUsageAndBalance, - extractOwnerAndRepo, - findOrganizationForRepository, -} from '@codebuff/billing' -import { pluralize } from '@codebuff/common/util/string' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' - -import { getUserInfoFromApiKey } from './auth' -import { updateRequestContext } from './request-context' -import { - handleStepsLogChunkWs, - requestFilesWs, - requestMcpToolDataWs, - requestOptionalFileWs, - requestToolCallWs, - sendActionWs, - sendSubagentChunkWs, -} from '../client-wrapper' -import { withAppContext } from '../context/app-context' -import { BACKEND_AGENT_RUNTIME_IMPL } from '../impl/agent-runtime' -import { checkAuth } from '../util/check-auth' -import { logger } from '../util/logger' - -import type { ClientAction, ServerAction } from '@codebuff/common/actions' -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '@codebuff/common/types/contracts/agent-runtime' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { Source } from '@codebuff/common/types/source' -import type { WebSocket } from 'ws' - -type MiddlewareCallback = (params: { - action: ClientAction - clientSessionId: string - ws: WebSocket - userInfo: { id: string } | null - logger: Logger -}) => Promise - -function getServerErrorAction( - action: T, - error: T extends { type: 'prompt' } - ? Omit, 'type' | 'userInputId'> - : Omit, 'type'>, -): ServerAction<'prompt-error'> | ServerAction<'action-error'> { - return action.type === 'prompt' - ? { - type: 'prompt-error', - userInputId: action.promptId, - ...error, - } - : { - type: 'action-error', - ...error, - } -} - -export class WebSocketMiddleware { - private middlewares: Array = [] - private implSource: Source - private impl: AgentRuntimeDeps | undefined - - constructor(params: Source) { - this.implSource = params - } - - async getImpl() { - if (this.impl) { - return this.impl - } - - if (typeof this.implSource === 'function') { - this.impl = await this.implSource() - } else { - this.impl = await this.implSource - } - return this.impl - } - - use( - callback: ( - params: { - action: ClientAction - clientSessionId: string - ws: WebSocket - userInfo: { id: string } | null - } & AgentRuntimeDeps, - ) => Promise, - ) { - this.middlewares.push(callback as MiddlewareCallback) - } - - async execute( - params: { - action: ClientAction - clientSessionId: string - ws: WebSocket - silent?: boolean - } & AgentRuntimeDeps, - ): Promise { - const { - action, - clientSessionId, - ws, - silent, - getUserInfoFromApiKey, - logger, - } = params - - const userInfo = - 'authToken' in action && action.authToken - ? await getUserInfoFromApiKey({ - apiKey: action.authToken, - fields: ['id'], - logger, - }) - : null - - for (const middleware of this.middlewares) { - const actionOrContinue = await middleware({ - ...params, - action, - clientSessionId, - ws, - userInfo, - }) - if (actionOrContinue) { - logger.warn( - { - actionType: action.type, - middlewareResp: actionOrContinue.type, - clientSessionId, - }, - 'Middleware execution halted.', - ) - if (!silent) { - sendActionWs({ ws, action: actionOrContinue }) - } - return false - } - } - return true - } - - run(params: { - baseAction: ( - params: { - action: ClientAction - clientSessionId: string - ws: WebSocket - } & AgentRuntimeDeps & - AgentRuntimeScopedDeps, - ) => void | Promise - silent?: boolean - }) { - const { baseAction, silent } = params - - return async ( - action: ClientAction, - clientSessionId: string, - ws: WebSocket, - ) => { - const authToken = 'authToken' in action ? action.authToken : undefined - const userInfo = authToken - ? await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id', 'email', 'discord_id'], - logger, - }) - : undefined - - const scopedDeps: AgentRuntimeScopedDeps = { - handleStepsLogChunk: (params) => - handleStepsLogChunkWs({ ...params, ws }), - requestToolCall: (params) => requestToolCallWs({ ...params, ws }), - requestMcpToolData: (params) => requestMcpToolDataWs({ ...params, ws }), - requestFiles: (params) => requestFilesWs({ ...params, ws }), - requestOptionalFile: (params) => - requestOptionalFileWs({ ...params, ws }), - sendSubagentChunk: (params) => sendSubagentChunkWs({ ...params, ws }), - sendAction: (params) => sendActionWs({ ...params, ws }), - apiKey: authToken ?? '', - } - - // Use the new combined context - much cleaner! - return withAppContext( - { - clientSessionId, - userId: userInfo?.id, - userEmail: userInfo?.email, - discordId: userInfo?.discord_id ?? undefined, - }, - {}, // request context starts empty - async () => { - const shouldContinue = await this.execute({ - action, - clientSessionId, - ws, - silent, - ...(await this.getImpl()), - }) - if (shouldContinue) { - baseAction({ - action, - clientSessionId, - ws, - ...(await this.getImpl()), - ...scopedDeps, - }) - } - }, - ) - } - } -} - -export const protec = new WebSocketMiddleware(() => BACKEND_AGENT_RUNTIME_IMPL) - -protec.use(async (params) => { - const { action } = params - return checkAuth({ - ...params, - authToken: 'authToken' in action ? action.authToken : undefined, - }) -}) - -// Organization repository coverage detection middleware -protec.use(async ({ action, userInfo, logger }) => { - const userId = userInfo?.id - - // Only process actions that have repoUrl as a valid string - if ( - !('repoUrl' in action) || - typeof action.repoUrl !== 'string' || - !action.repoUrl || - !userId - ) { - return undefined - } - - const repoUrl = action.repoUrl - - try { - // Extract owner and repo from URL - const ownerRepo = extractOwnerAndRepo(repoUrl) - if (!ownerRepo) { - logger.debug( - { userId, repoUrl }, - 'Could not extract owner/repo from repository URL', - ) - return undefined - } - - const { owner, repo } = ownerRepo - - // Perform lookup (cache removed) - const orgLookup = await findOrganizationForRepository({ - userId, - repositoryUrl: repoUrl, - logger, - }) - - // If an organization covers this repository, check its balance - if (orgLookup.found && orgLookup.organizationId) { - // Check and trigger organization auto top-up if needed - try { - await checkAndTriggerOrgAutoTopup({ - organizationId: orgLookup.organizationId, - userId, - logger, - }) - } catch (error) { - logger.error( - { - error: - error instanceof Error - ? { - name: error.name, - message: error.message, - stack: error.stack, - } - : error, - organizationId: orgLookup.organizationId, - organizationName: orgLookup.organizationName, - userId, - repoUrl, - action: 'failed_org_auto_topup_check', - errorType: - error instanceof Error ? error.constructor.name : typeof error, - }, - 'Error during organization auto top-up check in middleware', - ) - // Continue execution to check remaining balance - } - - const now = new Date() - // For balance checking, precise quotaResetDate isn't as critical as for usageThisCycle. - // Using a far past date ensures all grants are considered for current balance. - const orgQuotaResetDate = new Date(0) - const { balance: orgBalance } = - await calculateOrganizationUsageAndBalance({ - organizationId: orgLookup.organizationId, - quotaResetDate: orgQuotaResetDate, - now, - logger, - }) - - if (orgBalance.totalRemaining <= 0) { - const orgName = orgLookup.organizationName || 'Your organization' - const message = - orgBalance.totalDebt > 0 - ? `The organization '${orgName}' has a balance of negative ${pluralize(Math.abs(orgBalance.totalDebt), 'credit')}. Please contact your organization administrator.` - : `The organization '${orgName}' does not have enough credits for this action. Please contact your organization administrator.` - - logger.warn( - { - userId, - repoUrl, - organizationId: orgLookup.organizationId, - organizationName: orgName, - orgBalance: orgBalance.netBalance, - }, - 'Organization has insufficient credits, gating request.', - ) - return getServerErrorAction(action, { - error: 'Insufficient organization credits', - message, - remainingBalance: orgBalance.netBalance, // Send org balance here - }) - } - } - - // Update request context with the results - updateRequestContext({ - currentUserId: userId, - approvedOrgIdForRepo: orgLookup.found - ? orgLookup.organizationId - : undefined, - processedRepoUrl: repoUrl, - processedRepoOwner: owner, - processedRepoName: repo, - processedRepoId: `${owner}/${repo}`, - isRepoApprovedForUserInOrg: orgLookup.found, - }) - - // logger.debug( - // { - // userId, - // repoUrl, - // owner, - // repo, - // isApproved: orgLookup.found, - // organizationId: orgLookup.organizationId, - // organizationName: orgLookup.organizationName, - // }, - // 'Organization repository coverage processed' - // ) - } catch (error) { - logger.error( - { userId, repoUrl, error }, - 'Error processing organization repository coverage', - ) - } - - return undefined -}) - -protec.use(async ({ action, clientSessionId, ws, userInfo, logger }) => { - const userId = userInfo?.id - const fingerprintId = - 'fingerprintId' in action ? action.fingerprintId : 'unknown-fingerprint' - - if (!userId || !fingerprintId) { - logger.warn( - { - userId, - fingerprintId, - actionType: action.type, - }, - 'Missing user or fingerprint ID', - ) - return getServerErrorAction(action, { - error: 'Missing user or fingerprint ID', - message: 'Please log in to continue.', - }) - } - - // Get user info for balance calculation - const user = await db.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { - next_quota_reset: true, - stripe_customer_id: true, - }, - }) - - // Check and trigger monthly reset if needed (ignore the returned quotaResetDate since we use user.next_quota_reset) - await triggerMonthlyResetAndGrant({ userId, logger }) - - // Check if we need to trigger auto top-up and get the amount added (if any) - let autoTopupAdded: number | undefined = undefined - try { - autoTopupAdded = await checkAndTriggerAutoTopup({ userId, logger }) - } catch (error) { - logger.error( - { - error: - error instanceof Error - ? { - name: error.name, - message: error.message, - stack: error.stack, - } - : error, - userId, - clientSessionId, - action: 'failed_user_auto_topup_check', - errorType: - error instanceof Error ? error.constructor.name : typeof error, - }, - 'Error during auto top-up check in middleware', - ) - // Continue execution to check remaining balance - } - - const { usageThisCycle, balance } = await calculateUsageAndBalance({ - userId, - quotaResetDate: user?.next_quota_reset ?? new Date(0), - logger, - }) - - // Check if we have enough remaining credits - if (balance.totalRemaining <= 0) { - // If they have debt, show that in the message - const message = - balance.totalDebt > 0 - ? `You have a balance of negative ${pluralize(Math.abs(balance.totalDebt), 'credit')}. Please add credits to continue using Codebuff.` - : `You do not have enough credits for this action. Please add credits or wait for your next cycle to begin.` - - return getServerErrorAction(action, { - error: 'Insufficient credits', - message, - remainingBalance: balance.netBalance, - }) - } - - // Send initial usage info if we have sufficient credits - sendActionWs({ - ws, - action: { - type: 'usage-response', - usage: usageThisCycle, - remainingBalance: balance.totalRemaining, - balanceBreakdown: balance.breakdown, - next_quota_reset: user?.next_quota_reset ?? null, - autoTopupAdded, // Include the amount added by auto top-up (if any) - }, - }) - - return undefined -}) diff --git a/backend/src/websockets/request-context.ts b/backend/src/websockets/request-context.ts deleted file mode 100644 index 9b25ae532a..0000000000 --- a/backend/src/websockets/request-context.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { RequestContextData } from '../context/app-context' -export { getRequestContext, updateRequestContext } from '../context/app-context' diff --git a/backend/src/websockets/server.ts b/backend/src/websockets/server.ts deleted file mode 100644 index 094206c374..0000000000 --- a/backend/src/websockets/server.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { setSessionConnected } from '@codebuff/agent-runtime/live-user-inputs' -import { isError } from 'lodash' -import { WebSocketServer } from 'ws' - -import { Switchboard } from './switchboard' -import { onWebsocketAction } from './websocket-action' - -import type { SessionRecord } from '@codebuff/common/types/contracts/live-user-input' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ServerMessage } from '@codebuff/common/websockets/websocket-schema' -import type { Server as HttpServer } from 'node:http' -import type { RawData, WebSocket } from 'ws' - -export const SWITCHBOARD = new Switchboard() - -// if a connection doesn't ping for this long, we assume the other side is toast -const CONNECTION_TIMEOUT_MS = 60 * 1000 - -export class MessageParseError extends Error { - details?: unknown - constructor(message: string, details?: unknown) { - super(message) - this.name = 'MessageParseError' - this.details = details - } -} - -function serializeError(err: unknown) { - return isError(err) ? err.message : 'Unexpected error.' -} - -async function processMessage(params: { - ws: WebSocket - clientSessionId: string - data: RawData - logger: Logger -}): Promise> { - const { ws, clientSessionId, data, logger } = params - - let messageObj: any - try { - messageObj = JSON.parse(data.toString()) - } catch (err) { - logger.error( - { err, data }, - 'Error parsing message: not valid UTF-8 encoded JSON.', - ) - return { type: 'ack', success: false, error: serializeError(err) } - } - - try { - const msg = messageObj - const { type, txid } = msg - switch (type) { - case 'subscribe': { - SWITCHBOARD.subscribe(ws, ...msg.topics) - break - } - case 'unsubscribe': { - SWITCHBOARD.unsubscribe(ws, ...msg.topics) - break - } - case 'ping': { - SWITCHBOARD.markSeen(ws) - break - } - case 'action': { - onWebsocketAction({ ws, clientSessionId, msg, logger }) - break - } - default: - throw new Error("Unknown message type; shouldn't be possible here.") - } - return { type: 'ack', txid, success: true } - } catch (err) { - logger.error({ err }, 'Error processing message') - return { - type: 'ack', - txid: messageObj.txid, - success: false, - error: serializeError(err), - } - } -} - -export function listen(params: { - server: HttpServer - path: string - logger: Logger - sessionConnections: SessionRecord -}) { - const { server, path, logger } = params - const wss = new WebSocketServer({ server, path }) - let deadConnectionCleaner: NodeJS.Timeout | undefined - wss.on('listening', () => { - logger.info(`Web socket server listening on ${path}.`) - deadConnectionCleaner = setInterval(function ping() { - const now = Date.now() - try { - for (const ws of wss.clients) { - try { - const client = SWITCHBOARD.getClient(ws) - if (!client) { - logger.warn( - 'Client not found in switchboard, terminating connection', - ) - ws.terminate() - continue - } - - const lastSeen = client.lastSeen - if (lastSeen < now - CONNECTION_TIMEOUT_MS) { - ws.terminate() - } - } catch (err) { - // logger.error( - // { error: err }, - // 'Error checking individual connection in deadConnectionCleaner' - // ) - } - } - } catch (error) { - logger.error({ error }, 'Error in deadConnectionCleaner outer loop') - } - }, CONNECTION_TIMEOUT_MS) - }) - wss.on('error', (err: Error) => { - logger.error({ error: err }, 'Error on websocket server.') - }) - wss.on('connection', (ws: WebSocket) => { - // todo: should likely kill connections that haven't sent any ping for a long time - // logger.info('WS client connected.') - SWITCHBOARD.connect(ws) - const clientSessionId = - SWITCHBOARD.clients.get(ws)?.sessionId ?? 'mc-client-unknown' - - // Mark session as connected - setSessionConnected({ - ...params, - sessionId: clientSessionId, - connected: true, - }) - ws.on('message', async (data: RawData) => { - const result = await processMessage({ ws, clientSessionId, data, logger }) - // mqp: check ws.readyState before sending? - ws.send(JSON.stringify(result)) - }) - ws.on('close', (code: number, reason: Buffer) => { - // logger.debug( - // { code, reason: reason.toString() }, - // 'WS client disconnected.' - // ) - - // Mark session as disconnected to stop all agents - setSessionConnected({ - ...params, - sessionId: clientSessionId, - connected: false, - }) - - SWITCHBOARD.disconnect(ws) - }) - ws.on('error', (err: Error) => { - logger.error({ error: err }, 'Error on websocket connection.') - }) - }) - wss.on('close', function close() { - if (deadConnectionCleaner) { - clearInterval(deadConnectionCleaner) - } - }) - return wss -} - -export const sendMessage = (ws: WebSocket, server: ServerMessage) => { - ws.send(JSON.stringify(server)) -} - -export function sendRequestReconnect() { - for (const ws of SWITCHBOARD.clients.keys()) { - sendMessage(ws, { type: 'action', data: { type: 'request-reconnect' } }) - } -} - -export function waitForAllClientsDisconnected() { - return SWITCHBOARD.waitForAllClientsDisconnected() -} diff --git a/backend/src/websockets/switchboard.ts b/backend/src/websockets/switchboard.ts deleted file mode 100644 index b11a0f7e94..0000000000 --- a/backend/src/websockets/switchboard.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { WebSocket } from 'ws' - -export type ClientState = { - sessionId?: string - lastSeen: number - subscriptions: Set -} - -/** Tracks the relationship of clients to websockets and subscription lists. */ -export class Switchboard { - clients: Map - private allClientsDisconnectedPromise: Promise | null = null - private allClientsDisconnectedResolver: ((value: true) => void) | null = null - - constructor() { - this.clients = new Map() - } - getClient(ws: WebSocket) { - const existing = this.clients.get(ws) - if (existing == null) { - throw new Error("Looking for a nonexistent client. Shouldn't happen.") - } - return existing - } - getAll() { - return this.clients.entries() - } - getSubscribers(topic: string) { - const entries = Array.from(this.clients.entries()) - return entries.filter(([_k, v]) => v.subscriptions.has(topic)) - } - connect(ws: WebSocket) { - const existing = this.clients.get(ws) - if (existing != null) { - throw new Error("Client already connected! Shouldn't happen.") - } - this.clients.set(ws, { - lastSeen: Date.now(), - sessionId: `mc-client-` + Math.random().toString(36).slice(2, 15), - subscriptions: new Set(), - }) - } - disconnect(ws: WebSocket) { - this.getClient(ws).sessionId = undefined - this.clients.delete(ws) - - // If this was the last client, resolve the promise - if (this.clients.size === 0 && this.allClientsDisconnectedResolver) { - console.log('Last client disconnected. Resolving promise.') - this.allClientsDisconnectedResolver(true) - this.allClientsDisconnectedResolver = null - this.allClientsDisconnectedPromise = Promise.resolve(true) - } - } - markSeen(ws: WebSocket) { - this.getClient(ws).lastSeen = Date.now() - } - subscribe(ws: WebSocket, ...topics: string[]) { - const client = this.getClient(ws) - for (const topic of topics) { - client.subscriptions.add(topic) - } - this.markSeen(ws) - } - unsubscribe(ws: WebSocket, ...topics: string[]) { - const client = this.getClient(ws) - for (const topic of topics) { - client.subscriptions.delete(topic) - } - this.markSeen(ws) - } - - // Note: This function assumes that new clients are - // no longer being added to the switchboard after this is called - waitForAllClientsDisconnected(): Promise { - // If there are no clients, resolve immediately - if (this.clients.size === 0) { - console.log('No clients connected. Resolving immediately.') - return Promise.resolve(true) - } - - // If we don't have a promise yet, create one - if ( - !this.allClientsDisconnectedPromise || - this.allClientsDisconnectedPromise === Promise.resolve(true) - ) { - this.allClientsDisconnectedPromise = new Promise((resolve) => { - this.allClientsDisconnectedResolver = resolve - }) - } - - return this.allClientsDisconnectedPromise - } -} diff --git a/backend/src/websockets/websocket-action.ts b/backend/src/websockets/websocket-action.ts deleted file mode 100644 index 581a410890..0000000000 --- a/backend/src/websockets/websocket-action.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { - cancelUserInput, - startUserInput, -} from '@codebuff/agent-runtime/live-user-inputs' -import { callMainPrompt } from '@codebuff/agent-runtime/main-prompt' -import { calculateUsageAndBalance } from '@codebuff/billing' -import { trackEvent } from '@codebuff/common/analytics' -import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { getErrorObject } from '@codebuff/common/util/error' -import db from '@codebuff/internal/db/index' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' - -import { protec } from './middleware' -import { sendActionWs } from '../client-wrapper' -import { getRequestContext } from './request-context' -import { withLoggerContext } from '../util/logger' - -import type { ClientAction, UsageResponse } from '@codebuff/common/actions' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { UserInputRecord } from '@codebuff/common/types/contracts/live-user-input' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { ParamsExcluding } from '@codebuff/common/types/function-params' -import type { ClientMessage } from '@codebuff/common/websockets/websocket-schema' -import type { WebSocket } from 'ws' - -/** - * Generates a usage response object for the client - * @param fingerprintId - The fingerprint ID for the user/device - * @param userId - user ID for authenticated users - * @param clientSessionId - Optional session ID - * @returns A UsageResponse object containing usage metrics and referral information - */ -export async function genUsageResponse(params: { - fingerprintId: string - userId: string - clientSessionId?: string - logger: Logger -}): Promise { - const { fingerprintId, userId, clientSessionId, logger } = params - const logContext = { fingerprintId, userId, sessionId: clientSessionId } - const defaultResp = { - type: 'usage-response' as const, - usage: 0, - remainingBalance: 0, - next_quota_reset: null, - } satisfies UsageResponse - - return withLoggerContext(logContext, async () => { - const user = await db.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { - next_quota_reset: true, - auto_topup_enabled: true, - }, - }) - - if (!user) { - return defaultResp - } - - try { - // Get the usage data - const { balance: balanceDetails, usageThisCycle } = - await calculateUsageAndBalance({ - userId, - quotaResetDate: new Date(), - logger, - }) - - return { - type: 'usage-response' as const, - usage: usageThisCycle, - remainingBalance: balanceDetails.totalRemaining, - balanceBreakdown: balanceDetails.breakdown, - next_quota_reset: user.next_quota_reset, - autoTopupEnabled: user.auto_topup_enabled ?? false, - } satisfies UsageResponse - } catch (error) { - logger.error( - { error, usage: defaultResp }, - 'Error generating usage response, returning default', - ) - } - - return defaultResp - }) -} - -/** - * Handles prompt actions from the client - * @param action - The prompt action from the client - * @param clientSessionId - The client's session ID - * @param ws - The WebSocket connection - */ -const onPrompt = async ( - params: { - action: ClientAction<'prompt'> - ws: WebSocket - getUserInfoFromApiKey: GetUserInfoFromApiKeyFn - liveUserInputRecord: UserInputRecord - logger: Logger - } & ParamsExcluding< - typeof callMainPrompt, - 'userId' | 'promptId' | 'repoId' | 'repoUrl' | 'signal' | 'tools' - >, -) => { - const { action, ws, getUserInfoFromApiKey, logger } = params - const { fingerprintId, authToken, promptId, prompt, costMode } = action - - await withLoggerContext( - { fingerprintId, clientRequestId: promptId, costMode }, - async () => { - const userId = authToken - ? ( - await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id'], - logger, - }) - )?.id - : null - if (!userId) { - throw new Error('User not found') - } - - if (prompt) { - logger.info({ prompt }, `USER INPUT: ${prompt.slice(0, 100)}`) - trackEvent({ - event: AnalyticsEvent.USER_INPUT, - userId, - properties: { - prompt, - promptId, - }, - logger, - }) - } - - const requestContext = getRequestContext() - const repoId = requestContext?.processedRepoId - const repoUrl = requestContext?.processedRepoUrl - - startUserInput({ ...params, userId, userInputId: promptId }) - - try { - const result = await callMainPrompt({ - ...params, - userId, - promptId, - repoUrl, - repoId, - signal: new AbortController().signal, - tools: {}, - }) - if (result.output.type === 'error') { - throw new Error(result.output.message) - } - } catch (e) { - logger.error({ error: getErrorObject(e) }, 'Error in mainPrompt') - let response = - e && typeof e === 'object' && 'message' in e ? `${e.message}` : `${e}` - - sendActionWs({ - ws, - action: { - type: 'prompt-error', - userInputId: promptId, - message: response, - }, - }) - } finally { - cancelUserInput({ ...params, userId, userInputId: promptId }) - const usageResponse = await genUsageResponse({ - fingerprintId, - userId, - logger, - }) - sendActionWs({ ws, action: usageResponse }) - } - }, - ) -} - -/** - * Handles initialization actions from the client - * @param fileContext - The file context information - * @param fingerprintId - The fingerprint ID for the user/device - * @param authToken - The authentication token - * @param clientSessionId - The client's session ID - * @param ws - The WebSocket connection - */ -const onInit = async (params: { - action: ClientAction<'init'> - clientSessionId: string - ws: WebSocket - getUserInfoFromApiKey: GetUserInfoFromApiKeyFn - logger: Logger -}) => { - const { action, clientSessionId, ws, getUserInfoFromApiKey, logger } = params - const { fileContext, fingerprintId, authToken } = action - - await withLoggerContext({ fingerprintId }, async () => { - const userId = authToken - ? ( - await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id'], - logger, - }) - )?.id - : undefined - - if (!userId) { - sendActionWs({ - ws, - action: { - usage: 0, - remainingBalance: 0, - next_quota_reset: null, - type: 'init-response', - }, - }) - return - } - - // Send combined init and usage response - const usageResponse = await genUsageResponse({ - fingerprintId, - userId, - clientSessionId, - logger, - }) - sendActionWs({ - ws, - action: { - ...usageResponse, - type: 'init-response', - }, - }) - }) -} - -const onCancelUserInput = async (params: { - action: ClientAction<'cancel-user-input'> - getUserInfoFromApiKey: GetUserInfoFromApiKeyFn - liveUserInputRecord: UserInputRecord - logger: Logger -}) => { - const { action, getUserInfoFromApiKey, logger } = params - const { authToken, promptId } = action - - const userId = ( - await getUserInfoFromApiKey({ - apiKey: authToken, - fields: ['id'], - logger, - }) - )?.id - if (!userId) { - logger.error({ authToken }, 'User id not found for authToken') - return - } - cancelUserInput({ ...params, userId, userInputId: promptId }) -} - -/** - * Storage for action callbacks organized by action type - */ -const callbacksByAction = {} as Record< - ClientAction['type'], - ((action: ClientAction, clientSessionId: string, ws: WebSocket) => void)[] -> - -/** - * Subscribes a callback function to a specific action type - * @param type - The action type to subscribe to - * @param callback - The callback function to execute when the action is received - * @returns A function to unsubscribe the callback - */ -export const subscribeToAction = ( - type: T, - callback: ( - action: ClientAction, - clientSessionId: string, - ws: WebSocket, - ) => void, -) => { - callbacksByAction[type] = (callbacksByAction[type] ?? []).concat( - callback as ( - action: ClientAction, - clientSessionId: string, - ws: WebSocket, - ) => void, - ) - return () => { - callbacksByAction[type] = (callbacksByAction[type] ?? []).filter( - (cb) => cb !== callback, - ) - } -} - -/** - * Handles WebSocket action messages from clients - * @param ws - The WebSocket connection - * @param clientSessionId - The client's session ID - * @param msg - The action message from the client - */ -export const onWebsocketAction = async (params: { - ws: WebSocket - clientSessionId: string - msg: ClientMessage & { type: 'action' } - logger: Logger -}) => { - const { ws, clientSessionId, msg, logger } = params - - await withLoggerContext({ clientSessionId }, async () => { - const callbacks = callbacksByAction[msg.data.type] ?? [] - try { - await Promise.all( - callbacks.map((cb) => cb(msg.data, clientSessionId, ws)), - ) - } catch (e) { - logger.error( - { - message: msg, - error: e && typeof e === 'object' && 'message' in e ? e.message : e, - }, - 'Got error running subscribeToAction callback', - ) - } - }) -} - -// Register action handlers -subscribeToAction('prompt', protec.run<'prompt'>({ baseAction: onPrompt })) -subscribeToAction( - 'init', - protec.run<'init'>({ baseAction: onInit, silent: true }), -) -subscribeToAction( - 'cancel-user-input', - protec.run<'cancel-user-input'>({ baseAction: onCancelUserInput }), -) diff --git a/backend/tsconfig.json b/backend/tsconfig.json deleted file mode 100644 index a3aeb86af2..0000000000 --- a/backend/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "types": ["bun", "node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "src/**/__mock-data__/**/*"] -} diff --git a/codebuff.json b/codebuff.json index 96aa81b8f0..89ebb6aa52 100644 --- a/codebuff.json +++ b/codebuff.json @@ -13,30 +13,6 @@ } ], "fileChangeHooks": [ - { - "name": "backend-unit-tests", - "command": "../scripts/run-tests-summary", - "cwd": "backend", - "filePattern": "backend/**/*.ts" - }, - { - "name": "backend-typecheck", - "command": "bun run typecheck", - "cwd": "backend", - "filePattern": "backend/**/*.ts" - }, - { - "name": "npm-app-unit-tests", - "command": "../scripts/run-tests-summary", - "cwd": "npm-app", - "filePattern": "npm-app/**/*.ts" - }, - { - "name": "npm-typecheck", - "command": "bun run typecheck", - "cwd": "npm-app", - "filePattern": "npm-app/**/*.ts" - }, { "name": "web-typecheck", "command": "bun run typecheck", diff --git a/npm-app/src/utils/system-info.ts b/common/src/util/system-info.ts similarity index 100% rename from npm-app/src/utils/system-info.ts rename to common/src/util/system-info.ts diff --git a/common/src/websockets/websocket-client.ts b/common/src/websockets/websocket-client.ts deleted file mode 100644 index 1c10d3b327..0000000000 --- a/common/src/websockets/websocket-client.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { WebSocket } from 'ws' - -import type { ClientAction, ServerAction } from '../actions' -import type { - ClientMessage, - ClientMessageType, - ServerMessage, -} from './websocket-schema' - -// mqp: useful for debugging -const VERBOSE_LOGGING = false - -const TIMEOUT_MS = 120_000 - -const RECONNECT_WAIT_MS = 5_000 - -type ConnectingState = typeof WebSocket.CONNECTING -type OpenState = typeof WebSocket.OPEN -type ClosingState = typeof WebSocket.CLOSING -type ClosedState = typeof WebSocket.CLOSED - -export type ReadyState = - | OpenState - | ConnectingState - | ClosedState - | ClosingState - -export function formatState(state: ReadyState) { - switch (state) { - case WebSocket.CONNECTING: - return 'connecting' - case WebSocket.OPEN: - return 'open' - case WebSocket.CLOSING: - return 'closing' - case WebSocket.CLOSED: - return 'closed' - default: - throw new Error('Invalid websocket state.') - } -} - -type OutstandingTxn = { - resolve: () => void - reject: (err: Error) => void - timeout?: any -} - -/** Client for the API websocket realtime server. Automatically manages reconnection - * and resubscription on disconnect, and allows subscribers to get a callback - * when something is broadcasted. */ -export class APIRealtimeClient { - ws!: WebSocket - url: string - // Callbacks subscribed to individual actions. - subscribers: Map void)[]> - txid: number - // all txns that are in flight, with no ack/error/timeout - txns: Map - connectTimeout?: any - heartbeat?: any - hadError = false - onError: (event: WebSocket.ErrorEvent) => void - onReconnect: () => void - - constructor( - url: string, - onError: (event: WebSocket.ErrorEvent) => void, - onReconnect: () => void, - ) { - this.url = url - this.txid = 0 - this.txns = new Map() - this.subscribers = new Map() - this.onError = onError - this.onReconnect = onReconnect - } - - get state() { - return this.ws.readyState as ReadyState - } - - close() { - this.ws.close(1000, 'Closed manually.') - clearTimeout(this.connectTimeout) - } - - connect() { - // you may wish to refer to https://websockets.spec.whatwg.org/ - // in order to check the semantics of events etc. - this.ws = new WebSocket(this.url) - this.ws.onmessage = (ev) => { - if (this.hadError) { - this.hadError = false - this.onReconnect() - } - this.receiveMessage(JSON.parse(ev.data as any)) - } - this.ws.onerror = (ev) => { - if (!this.hadError) { - this.onError(ev) - this.hadError = true - } - // this can fire without an onclose if this is the first time we ever try - // to connect, so we need to turn on our reconnect in that case - this.waitAndReconnect() - } - this.ws.onclose = (ev) => { - // note that if the connection closes due to an error, onerror fires and then this - if (VERBOSE_LOGGING) { - console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`) - } - clearInterval(this.heartbeat) - - // mqp: we might need to change how the txn stuff works if we ever want to - // implement "wait until i am subscribed, and then do something" in a component. - // right now it cannot be reliably used to detect that in the presence of reconnects - for (const txn of Array.from(this.txns.values())) { - clearTimeout(txn.timeout) - // NOTE (James): Don't throw an error when the websocket is closed... - // This seems to be happening, but the client can recover. - txn.resolve() - // txn.reject(new Error('Websocket was closed.')) - } - this.txns.clear() - - // 1000 is RFC code for normal on-purpose closure - if (ev.code !== 1000) { - this.waitAndReconnect() - } - } - return new Promise((resolve) => { - this.ws.onopen = (_ev) => { - if (VERBOSE_LOGGING) { - console.info('API websocket opened.') - } - this.heartbeat = setInterval( - async () => this.sendMessage('ping', {}).catch(() => {}), - 30000, - ) - - resolve() - } - }) - } - - waitAndReconnect() { - if (this.connectTimeout == null) { - this.connectTimeout = setTimeout(() => { - this.connectTimeout = undefined - this.connect() - }, RECONNECT_WAIT_MS) - } - } - - forceReconnect() { - // Close the current connection if it's open - if (this.ws && this.state !== WebSocket.CLOSED) { - this.ws.close(1000, 'Forced reconnection due to server shutdown notice') - } - - // Immediately attempt to reconnect - this.connect().catch((err) => { - if (VERBOSE_LOGGING) { - console.error('Failed to reconnect after server shutdown notice:', err) - } - // Still set up delayed reconnect as fallback - this.waitAndReconnect() - }) - } - - receiveMessage(msg: ServerMessage) { - if (VERBOSE_LOGGING) { - console.info('< Incoming API websocket message: ', msg) - } - switch (msg.type) { - case 'action': { - const action = msg.data - const subscribers = this.subscribers.get(action.type) ?? [] - for (const callback of subscribers) { - callback(action) - } - return - } - case 'ack': { - if (msg.txid != null) { - const txn = this.txns.get(msg.txid) - if (txn == null) { - // mqp: only reason this should happen is getting an ack after timeout - if (VERBOSE_LOGGING) { - console.warn(`Websocket message with old txid=${msg.txid}.`) - } - } else { - clearTimeout(txn.timeout) - if (msg.error != null) { - txn.reject(new Error(msg.error)) - } else { - txn.resolve() - } - this.txns.delete(msg.txid) - } - } - return - } - default: - if (VERBOSE_LOGGING) { - console.warn(`Unknown API websocket message type received: ${msg}`) - } - } - } - - async sendMessage( - type: T, - data: Omit, 'type' | 'txid'>, - ) { - if (VERBOSE_LOGGING) { - console.info(`> Outgoing API websocket ${type} message: `, data) - } - if (this.state === WebSocket.OPEN) { - return new Promise((resolve, reject) => { - const txid = this.txid++ - const timeout = setTimeout(() => { - this.txns.delete(txid) - reject(new Error(`Websocket message with txid ${txid} timed out.`)) - }, TIMEOUT_MS) - this.txns.set(txid, { resolve, reject, timeout }) - this.ws.send(JSON.stringify({ type, txid, ...data })) - }) - } else { - // expected if components in the code try to subscribe or unsubscribe - // while the socket is closed -- in this case we expect to get the state - // fixed up in the websocket onopen handler when we reconnect - } - } - - async sendAction(action: ClientAction) { - return await this.sendMessage('action', { - data: action, - }) - } - - subscribe( - action: T, - callback: (action: ServerAction) => void, - ) { - const currSubscribers = this.subscribers.get(action) ?? [] - this.subscribers.set(action, [ - ...currSubscribers, - callback as (action: ServerAction) => void, - ]) - - return () => { - const newSubscribers = currSubscribers.filter((cb) => cb !== callback) - this.subscribers.set(action, newSubscribers) - } - } -} diff --git a/common/src/websockets/websocket-schema.ts b/common/src/websockets/websocket-schema.ts deleted file mode 100644 index 4e87d0014d..0000000000 --- a/common/src/websockets/websocket-schema.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ClientAction, ServerAction } from '../actions' - -type ClientMessageIdentify = { - type: 'identify' - txid: number - clientSessionId: string -} -type ClientMessageSubscribe = { - type: 'subscribe' - txid: number - topics: string[] -} -type ClientMessageUnsubscribe = { - type: 'unsubscribe' - txid: number - topics: string[] -} -type ClientMessagePing = { - type: 'ping' - txid: number -} -type ClientMessageAction = { - type: 'action' - txid: number - data: ClientAction -} - -type ClientMessageAny = - | ClientMessageIdentify - | ClientMessageSubscribe - | ClientMessageUnsubscribe - | ClientMessagePing - | ClientMessageAction -export type ClientMessageType = ClientMessageAny['type'] -export type ClientMessage = { - [K in ClientMessageType]: Extract< - ClientMessageAny, - { - type: K - } - > -}[T] - -type ServerMessageAck = { - type: 'ack' - txid?: number - success: boolean - error?: string -} - -type ServerMessageAction = { - type: 'action' - data: ServerAction -} - -type ServerMessageAny = ServerMessageAck | ServerMessageAction -export type ServerMessageType = ServerMessageAny['type'] -export type ServerMessage = { - [K in ServerMessageType]: Extract< - ServerMessageAny, - { - type: K - } - > -}[T] diff --git a/evals/buffbench/gen-evals.ts b/evals/buffbench/gen-evals.ts index db9a8177e5..eb07704d10 100644 --- a/evals/buffbench/gen-evals.ts +++ b/evals/buffbench/gen-evals.ts @@ -5,9 +5,8 @@ import path from 'path' import { mapLimit } from 'async' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' -import { getUserCredentials } from '@codebuff/npm-app/credentials' -import { CodebuffClient } from '@codebuff/sdk' +import { CodebuffClient, getUserCredentials } from '@codebuff/sdk' import { extractRepoNameFromUrl } from './setup-test-repo' import { withTestRepoAndParent } from '../subagents/test-repo-utils' import { generateEvalTask } from './eval-task-generator' diff --git a/evals/buffbench/pick-commits.ts b/evals/buffbench/pick-commits.ts index 29d4c6abe4..744ab8549d 100644 --- a/evals/buffbench/pick-commits.ts +++ b/evals/buffbench/pick-commits.ts @@ -5,7 +5,7 @@ import fs from 'fs' import path from 'path' import { disableLiveUserInputCheck } from '@codebuff/agent-runtime/live-user-inputs' -import { promptAiSdkStructured } from '@codebuff/backend/llm-apis/vercel-ai-sdk/ai-sdk' +import { promptAiSdkStructured } from '@codebuff/sdk/impl/llm' import { models } from '@codebuff/common/old-constants' import { userMessage } from '@codebuff/common/util/messages' import { mapLimit } from 'async' diff --git a/evals/buffbench/run-buffbench.ts b/evals/buffbench/run-buffbench.ts index 39bb9f5361..1a377def29 100644 --- a/evals/buffbench/run-buffbench.ts +++ b/evals/buffbench/run-buffbench.ts @@ -4,8 +4,6 @@ import os from 'os' import { execSync } from 'child_process' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' -import { getUserCredentials } from '@codebuff/npm-app/credentials' -import { loadLocalAgents } from '@codebuff/npm-app/agents/load-agents' import pLimit from 'p-limit' import { runAgentOnCommit, type ExternalAgentType } from './agent-runner' @@ -13,7 +11,7 @@ import { formatTaskResults } from './format-output' import { judgeCommitResult } from './judge' import { analyzeAgentTraces, type AgentTraceData } from './trace-analyzer' import { extractAgentLessons, saveAgentLessons } from './lessons-extractor' -import { CodebuffClient } from '@codebuff/sdk' +import { CodebuffClient, getUserCredentials, loadLocalAgents } from '@codebuff/sdk' import { logger } from '../logger' import type { AgentEvalResults, EvalDataV2, EvalCommitV2 } from './types' import { analyzeAllTasks } from './meta-analyzer' diff --git a/evals/package.json b/evals/package.json index 25bfd2ba1a..f5b99c5574 100644 --- a/evals/package.json +++ b/evals/package.json @@ -31,11 +31,9 @@ }, "dependencies": { "@anthropic-ai/claude-code": "^2.0.56", - "@codebuff/backend": "workspace:*", "@codebuff/code-map": "workspace:*", "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", - "@codebuff/npm-app": "workspace:*", "@codebuff/sdk": "workspace:*", "@oclif/core": "^4.4.0", "@oclif/parser": "^3.8.17", diff --git a/evals/scaffolding.ts b/evals/scaffolding.ts index ecbbb0a095..7687c01ec3 100644 --- a/evals/scaffolding.ts +++ b/evals/scaffolding.ts @@ -1,26 +1,15 @@ import { execSync } from 'child_process' -import { EventEmitter } from 'events' import fs from 'fs' import path from 'path' import { runAgentStep } from '@codebuff/agent-runtime/run-agent-step' import { assembleLocalAgentTemplates } from '@codebuff/agent-runtime/templates/agent-registry' -import { - handleStepsLogChunkWs, - requestFilesWs, - requestMcpToolDataWs, - requestOptionalFileWs, - requestToolCallWs, - sendActionWs, - sendSubagentChunkWs, -} from '@codebuff/backend/client-wrapper' import { getFileTokenScores } from '@codebuff/code-map/parse' +import { clientToolCallSchema } from '@codebuff/common/tools/list' import { API_KEY_ENV_VAR, TEST_USER_ID } from '@codebuff/common/old-constants' -import { mockModule } from '@codebuff/common/testing/mock-modules' import { generateCompactId } from '@codebuff/common/util/string' -import { handleToolCall } from '@codebuff/npm-app/tool-handlers' -import { getSystemInfo } from '@codebuff/npm-app/utils/system-info' -import { mock } from 'bun:test' +import { getSystemInfo } from '@codebuff/common/util/system-info' +import { ToolHelpers } from '@codebuff/sdk' import { blue } from 'picocolors' import { EVALS_AGENT_RUNTIME_IMPL } from './impl/agent-runtime' @@ -31,6 +20,7 @@ import { import type { ClientToolCall } from '@codebuff/common/tools/list' import type { AgentRuntimeScopedDeps } from '@codebuff/common/types/contracts/agent-runtime' +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' import type { PrintModeEvent } from '@codebuff/common/types/print-mode' @@ -40,7 +30,6 @@ import type { SessionState, } from '@codebuff/common/types/session-state' import type { ProjectFileContext } from '@codebuff/common/util/file' -import type { WebSocket } from 'ws' const DEBUG_MODE = true @@ -55,67 +44,116 @@ function readMockFile(projectRoot: string, filePath: string): string | null { let toolCalls: ClientToolCall[] = [] let toolResults: ToolMessage[] = [] -export async function createFileReadingMock(projectRoot: string) { - await mockModule('@codebuff/backend/websockets/websocket-action', () => ({ - requestFiles: ((params: { ws: WebSocket; filePaths: string[] }) => { - const files: Record = {} - for (const filePath of params.filePaths) { - files[filePath] = readMockFile(projectRoot, filePath) - } - return Promise.resolve(files) - }) satisfies typeof requestFilesWs, - requestToolCall: (async (params: { - ws: WebSocket - userInputId: string - toolName: string - input: Record - }): ReturnType => { - const { toolName, input } = params - // Execute the tool call using existing tool handlers - const toolCall = { - toolCallId: generateCompactId(), - toolName, - input, - } - toolCalls.push(toolCall as ClientToolCall) - try { - const toolResult = await handleToolCall(toolCall as any) - toolResults.push({ - role: 'tool', - toolName: toolCall.toolName, - toolCallId: toolCall.toolCallId, - content: toolResult.content, - }) +const defaultFs: CodebuffFileSystem = { + ...(fs.promises as unknown as CodebuffFileSystem), + exists: async (target: string | Buffer | URL) => { + try { + await fs.promises.access(target) + return true + } catch { + return false + } + }, +} +let projectRootForMocks: string | undefined + +export function createFileReadingMock(projectRoot: string) { + projectRootForMocks = projectRoot +} + +function getActiveProjectRoot(fileContext?: ProjectFileContext) { + return fileContext?.projectRoot ?? projectRootForMocks ?? process.cwd() +} + +async function readFilesFromProject(params: { + projectRoot: string + filePaths: string[] +}) { + const { projectRoot, filePaths } = params + const files: Record = {} + for (const filePath of filePaths) { + const fileContent = readMockFile(projectRoot, filePath) + files[filePath] = fileContent + } + return files +} - // Send successful response back to backend - return { - output: toolResult.content, - } - } catch (error) { - // Send error response back to backend - const resultString = - error instanceof Error ? error.message : String(error) - const output = [ - { - type: 'json', - value: { errorMessage: resultString }, +async function executeToolCall( + toolCall: ClientToolCall, + projectRoot: string, +): Promise { + switch (toolCall.toolName) { + case 'write_file': + case 'str_replace': + return ToolHelpers.changeFile({ + parameters: toolCall.input, + cwd: projectRoot, + fs: defaultFs, + }) + case 'run_terminal_command': { + const resolvedCwd = path.resolve( + projectRoot, + (toolCall.input as { cwd?: string }).cwd ?? '.', + ) + return ToolHelpers.runTerminalCommand({ + ...(toolCall.input as any), + cwd: resolvedCwd, + }) + } + case 'code_search': + return ToolHelpers.codeSearch({ + ...(toolCall.input as any), + projectPath: projectRoot, + }) + case 'list_directory': + return ToolHelpers.listDirectory({ + directoryPath: (toolCall.input as { path: string }).path, + projectPath: projectRoot, + fs: defaultFs, + }) + case 'glob': + return ToolHelpers.glob({ + ...(toolCall.input as any), + projectPath: projectRoot, + fs: defaultFs, + }) + case 'run_file_change_hooks': + return ToolHelpers.runFileChangeHooks(toolCall.input as any) + case 'browser_logs': + case 'create_plan': + return [ + { + type: 'json', + value: { + message: `Tool ${toolCall.toolName} is a no-op in eval scaffolding.`, }, - ] satisfies ToolResultOutput[] - toolResults.push({ - role: 'tool', - toolName: toolCall.toolName, - toolCallId: toolCall.toolCallId, - content: output, - }) - return { output } - } - }) satisfies typeof requestToolCallWs, - })) + }, + ] + case 'ask_user': + return [ + { + type: 'json', + value: { + errorMessage: 'ask_user is not supported in eval scaffolding', + }, + }, + ] + default: + return [ + { + type: 'json', + value: { + errorMessage: `Unsupported tool: ${toolCall.toolName}`, + }, + }, + ] + } } export async function getProjectFileContext( projectPath: string, ): Promise { + projectRootForMocks = projectPath const fileTree = await getProjectFileTree({ projectRoot: projectPath, fs: fs.promises, @@ -160,28 +198,43 @@ export async function runAgentStepScaffolding( sessionId: string, agentType: AgentTemplateType, ) { - const mockWs = new EventEmitter() as WebSocket - mockWs.send = mock() - mockWs.close = mock() - let fullResponse = '' + const projectRoot = getActiveProjectRoot(fileContext) const { agentTemplates: localAgentTemplates } = assembleLocalAgentTemplates({ fileContext, logger: console, }) const agentRuntimeScopedImpl: AgentRuntimeScopedDeps = { - handleStepsLogChunk: (params) => - handleStepsLogChunkWs({ ...params, ws: mockWs }), - requestToolCall: (params) => requestToolCallWs({ ...params, ws: mockWs }), - requestMcpToolData: (params) => - requestMcpToolDataWs({ ...params, ws: mockWs }), - requestFiles: (params) => requestFilesWs({ ...params, ws: mockWs }), - requestOptionalFile: (params) => - requestOptionalFileWs({ ...params, ws: mockWs }), - sendSubagentChunk: (params) => - sendSubagentChunkWs({ ...params, ws: mockWs }), - sendAction: (params) => sendActionWs({ ...params, ws: mockWs }), + handleStepsLogChunk: () => {}, + requestToolCall: async ({ toolName, input }) => { + const parsedToolCall = clientToolCallSchema.parse({ toolName, input }) + const toolCall: ClientToolCall = { + ...(parsedToolCall as ClientToolCall), + toolCallId: generateCompactId(), + } + toolCalls.push(toolCall) + const output = await executeToolCall(toolCall, projectRoot) + toolResults.push({ + role: 'tool', + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + content: output, + }) + return { output } + }, + requestMcpToolData: async () => [], + requestFiles: ({ filePaths }) => + readFilesFromProject({ projectRoot, filePaths }), + requestOptionalFile: async ({ filePath }) => { + const files = await readFilesFromProject({ + projectRoot, + filePaths: [filePath], + }) + return files[filePath] ?? null + }, + sendSubagentChunk: () => {}, + sendAction: () => {}, apiKey: process.env[API_KEY_ENV_VAR] ?? '', } const result = await runAgentStep({ @@ -226,8 +279,17 @@ export async function runAgentStepScaffolding( export async function runToolCalls(toolCalls: ClientToolCall[]) { const toolResults: ToolMessage[] = [] for (const toolCall of toolCalls) { - const toolResult = await handleToolCall(toolCall) - toolResults.push(toolResult) + const toolCallId = toolCall.toolCallId ?? generateCompactId() + const output = await executeToolCall( + { ...toolCall, toolCallId } as ClientToolCall, + getActiveProjectRoot(), + ) + toolResults.push({ + role: 'tool', + toolName: toolCall.toolName, + toolCallId, + content: output, + }) } return toolResults } diff --git a/evals/subagents/eval-planner.ts b/evals/subagents/eval-planner.ts index 18f02138ee..9eff7bfa97 100644 --- a/evals/subagents/eval-planner.ts +++ b/evals/subagents/eval-planner.ts @@ -2,10 +2,13 @@ import * as fs from 'fs' import * as path from 'path' import { createTwoFilesPatch } from 'diff' -import { CodebuffClient, AgentDefinition } from '@codebuff/sdk' -import { getUserCredentials } from '@codebuff/npm-app/credentials' +import { + AgentDefinition, + CodebuffClient, + getUserCredentials, + loadLocalAgents, +} from '@codebuff/sdk' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' -import { loadLocalAgents } from '@codebuff/npm-app/agents/load-agents' import { withTestRepo } from './test-repo-utils' export const evalPlannerAgent = async (params: { diff --git a/evals/test-setup.ts b/evals/test-setup.ts index b4f198c16f..aff1cdbc68 100644 --- a/evals/test-setup.ts +++ b/evals/test-setup.ts @@ -3,11 +3,6 @@ import fs from 'fs' import path from 'path' import { getInitialSessionState } from '@codebuff/common/types/session-state' -import { - setProjectRoot, - setWorkingDirectory, -} from '@codebuff/npm-app/project-files' -import { recreateShell } from '@codebuff/npm-app/terminal/run-command' import { createFileReadingMock, @@ -154,10 +149,8 @@ export async function setupTestEnvironment(projectName: string) { } const repoPath = path.join(TEST_REPOS_DIR, projectName) - setProjectRoot(repoPath) await createFileReadingMock(repoPath) - recreateShell(repoPath) - setWorkingDirectory(repoPath) + process.chdir(repoPath) // Return project info for use in tests return { diff --git a/evals/tsconfig.json b/evals/tsconfig.json index e3923bb3d5..cc5b3df041 100644 --- a/evals/tsconfig.json +++ b/evals/tsconfig.json @@ -10,5 +10,5 @@ } }, "include": ["**/*.ts"], - "exclude": ["node_modules", "test-repos", "../npm-app"] + "exclude": ["node_modules", "test-repos"] } diff --git a/knowledge.md b/knowledge.md index 0a284c0c47..6ee6f87c8c 100644 --- a/knowledge.md +++ b/knowledge.md @@ -12,33 +12,27 @@ Codebuff is a tool for editing codebases via natural language instruction to Buf - **TypeScript**: Primary programming language - **Bun**: Package manager and runtime -- **WebSockets**: Real-time communication between client and server - **LLMs**: Multiple providers (Anthropic, OpenAI, Gemini, etc.) for various coding tasks ## Main Components 1. **LLM Integration**: Processes natural language instructions and generates code changes -2. **WebSocket Server**: Handles real-time communication between client and backend -3. **File Management**: Reads, parses, and modifies project files -4. **Action Handling**: Processes various client and server actions -5. **Knowledge Management**: Handles creation, updating, and organization of knowledge files -6. **Terminal Command Execution**: Allows running shell commands in user's terminal +2. **File Management**: Reads, parses, and modifies project files +3. **Action Handling**: Processes various client and server actions +4. **Knowledge Management**: Handles creation, updating, and organization of knowledge files +5. **Terminal Command Execution**: Allows running shell commands in user's terminal -## WebSocket Communication Flow +## API Flow -1. Client connects to WebSocket server -2. Client sends user input and file context to server -3. Server processes input using LLMs -4. Server streams response chunks back to client -5. Client receives and displays response in real-time -6. Server sends file changes to client for application +1. The SDK/CLI sends user input and file context to the Codebuff web API. +2. The agent runtime processes the request and streams response chunks back through the SDK callbacks. +3. Tools run locally via the SDK's helpers (file edits, terminal commands, search) to satisfy model tool calls. ## Tool Handling System -- Tools are defined in `backend/src/tools/definitions/list.ts` and implemented in `npm-app/src/tool-handlers.ts` +- Tools are defined in `common/src/tools` and executed via the SDK tool helpers and agent runtime - Available tools: read_files, write_file, str_replace, run_terminal_command, code_search, browser_logs, spawn_agents, web_search, read_docs, run_file_change_hooks, and others -- Backend uses tool calls to request additional information or perform actions -- Client-side handles tool calls and sends results back to server +- Tool calls request additional information or perform actions based on the current project state ## Agent System @@ -172,12 +166,10 @@ tmux attach -t git-rebase ## Error Handling and Debugging - Error messages are logged to console and debug log files -- WebSocket errors are caught and logged in server and client code ## Security Considerations - Project uses environment variables for sensitive information (API keys) -- WebSocket connections should be secured in production (WSS) - User input is validated and sanitized before processing - File operations are restricted to project directory diff --git a/npm-app/.gitignore b/npm-app/.gitignore deleted file mode 100644 index 19683f6198..0000000000 --- a/npm-app/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -bin -bin-external -dist \ No newline at end of file diff --git a/npm-app/bunfig.toml b/npm-app/bunfig.toml deleted file mode 100644 index 30c5ec33e1..0000000000 --- a/npm-app/bunfig.toml +++ /dev/null @@ -1,15 +0,0 @@ -[install] -# Don't save dependencies since this is a workspace package -no_save = true - -[loader] -# Treat .scm files as text for tree-sitter queries -".scm" = "text" - -[define] -# Define a global for the project root to help with path resolution -"process.env.PROJECT_ROOT" = "`${process.cwd()}/../`" - -[debug] -# Show more information about module resolution -show_resolution_errors = true diff --git a/npm-app/knowledge.md b/npm-app/knowledge.md deleted file mode 100644 index 3b3af82f54..0000000000 --- a/npm-app/knowledge.md +++ /dev/null @@ -1,3 +0,0 @@ -# npm-app Knowledge - -- npm distribution scripts (e.g. `release` artifacts in `npm-app/release*`) still rely on Node-based uninstall helpers for compatibility with end users. The development workflows now require Bun 1.3.0+, so keep the legacy Node snippets only in the published package files. diff --git a/npm-app/package.json b/npm-app/package.json deleted file mode 100644 index 0be041a6e2..0000000000 --- a/npm-app/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@codebuff/npm-app", - "version": "1.0.0", - "private": true, - "description": "cli for codebuff", - "license": "MIT", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - "./*": { - "bun": "./src/*.ts", - "import": "./src/*.ts", - "types": "./src/*.ts", - "default": "./src/*.ts" - } - }, - "bin": { - "codebuff": "dist/index.js" - }, - "scripts": { - "typecheck": "tsc --noEmit -p .", - "build": "bun run scripts/build-binary.js codebuff $(node -p \"require('./release/package.json').version\")", - "release": "bun run scripts/release.js", - "release-legacy": "bun run scripts/release-legacy.js", - "start-bin": "bun run build && ./bin/codebuff", - "start": "bun run src/index.ts --cwd ..", - "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", - "postinstall": "bun scripts/patch-web-tree-sitter.ts" - }, - "files": [ - "README.md" - ], - "engines": { - "bun": "^1.3.0" - }, - "dependencies": { - "@codebuff/code-map": "workspace:*", - "@codebuff/common": "workspace:*", - "@types/diff": "8.0.0", - "@types/micromatch": "^4.0.9", - "@vscode/ripgrep": "^1.17.0", - "ai": "5.0.0", - "axios": "1.7.4", - "cli-highlight": "^2.1.11", - "commander": "^13.1.0", - "diff": "8.0.2", - "git-url-parse": "^16.1.0", - "ignore": "7.0.3", - "isomorphic-git": "^1.29.0", - "jimp": "^1.6.0", - "lodash": "*", - "markdown-it": "^14.1.0", - "markdown-it-terminal": "^0.4.0", - "micromatch": "^4.0.8", - "nanoid": "5.0.7", - "onetime": "5.1.2", - "open": "^10.2.0", - "picocolors": "1.1.0", - "pino": "9.4.0", - "posthog-node": "4.17.2", - "puppeteer-core": "^24.2.0", - "string-width": "^7.2.0", - "systeminformation": "5.23.4", - "ts-pattern": "5.3.1", - "wrap-ansi": "^9.0.0", - "ws": "8.18.0", - "zod": "3.25.67" - }, - "devDependencies": { - "@types/markdown-it": "^14.1.2" - } -} diff --git a/npm-app/release-legacy/README.md b/npm-app/release-legacy/README.md deleted file mode 100644 index 7a67b98d69..0000000000 --- a/npm-app/release-legacy/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# The most powerful coding agent - -Codebuff is a CLI tool that writes code for you. - -1. Run `codebuff` from your project directory -2. Tell it what to do -3. It will read and write to files and run commands to produce the code you want - -Note: Codebuff will run commands in your terminal as it deems necessary to fulfill your request. - -## Installation - -To install Codebuff legacy, run: - -```bash -npm install -g codebuff@legacy -``` - -(Use `sudo` if you get a permission error.) - -## Usage - -After installation, you can start Codebuff by running: - -```bash -codebuff [project-directory] -``` - -If no project directory is specified, Codebuff will use the current directory. - -Once running, simply chat with Codebuff to say what coding task you want done. - -## Features - -- Understands your whole codebase -- Creates and edits multiple files based on your request -- Can run your tests or type checker or linter; can install packages -- It's powerful: ask Codebuff to keep working until it reaches a condition and it will. - -Our users regularly use Codebuff to implement new features, write unit tests, refactor code,write scripts, or give advice. - -## Knowledge Files - -To unlock the full benefits of modern LLMs, we recommend storing knowledge alongside your code. Add a `knowledge.md` file anywhere in your project to provide helpful context, guidance, and tips for the LLM as it performs tasks for you. - -Codebuff can fluently read and write files, so it will add knowledge as it goes. You don't need to write knowledge manually! - -Some have said every change should be paired with a unit test. In 2024, every change should come with a knowledge update! - -## Tips - -1. Type '/help' or just '/' to see available commands. -2. Create a `knowledge.md` file and collect specific points of advice. The assistant will use this knowledge to improve its responses. -3. Type `undo` or `redo` to revert or reapply file changes from the conversation. -4. Press `Esc` or `Ctrl+C` while Codebuff is generating a response to stop it. - -## Troubleshooting - -If you are getting permission errors during installation, try using sudo: - -``` -sudo npm install -g codebuff -``` - -If you still have errors, it's a good idea to [reinstall Node](https://nodejs.org/en/download). - -## Feedback - -We value your input! Please email your feedback to `founders@codebuff.com`. Thank you for using Codebuff! diff --git a/npm-app/release-legacy/index.js b/npm-app/release-legacy/index.js deleted file mode 100644 index 081cc22342..0000000000 --- a/npm-app/release-legacy/index.js +++ /dev/null @@ -1,448 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require('child_process') -const fs = require('fs') -const https = require('https') -const os = require('os') -const path = require('path') -const zlib = require('zlib') - -const { Command } = require('commander') -const tar = require('tar') - -const CONFIG = { - homeDir: os.homedir(), - configDir: path.join(os.homedir(), '.config', 'manicode'), - binaryName: process.platform === 'win32' ? 'codebuff.exe' : 'codebuff', - githubRepo: 'CodebuffAI/codebuff', - userAgent: 'codebuff-cli', - requestTimeout: 20000, -} - -CONFIG.binaryPath = path.join(CONFIG.configDir, CONFIG.binaryName) - -// Platform target mapping -const PLATFORM_TARGETS = { - 'linux-x64': 'codebuff-linux-x64.tar.gz', - 'linux-arm64': 'codebuff-linux-arm64.tar.gz', - 'darwin-x64': 'codebuff-darwin-x64.tar.gz', - 'darwin-arm64': 'codebuff-darwin-arm64.tar.gz', - 'win32-x64': 'codebuff-win32-x64.tar.gz', -} - -// Terminal utilities -let isPrintMode = false -const term = { - clearLine: () => { - if (!isPrintMode && process.stderr.isTTY) { - process.stderr.write('\r\x1b[K') - } - }, - write: (text) => { - if (!isPrintMode) { - term.clearLine() - process.stderr.write(text) - } - }, - writeLine: (text) => { - if (!isPrintMode) { - term.clearLine() - process.stderr.write(text + '\n') - } - }, -} - -// Utility functions -function httpGet(url, options = {}) { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(url) - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - // Add GitHub token if available - const token = process.env.GITHUB_TOKEN - if (token) { - reqOptions.headers.Authorization = `Bearer ${token}` - } - - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - -async function getLatestLegacyVersion() { - try { - const res = await httpGet(`https://registry.npmjs.org/codebuff/legacy`) - - if (res.statusCode !== 200) return null - - const body = await streamToString(res) - const packageData = JSON.parse(body) - - return packageData.version || null - } catch (error) { - return null - } -} - -function streamToString(stream) { - return new Promise((resolve, reject) => { - let data = '' - stream.on('data', (chunk) => (data += chunk)) - stream.on('end', () => resolve(data)) - stream.on('error', reject) - }) -} - -function getCurrentVersion() { - return new Promise((resolve, reject) => { - try { - if (!fs.existsSync(CONFIG.binaryPath)) { - resolve('error') - return - } - - const child = spawn(CONFIG.binaryPath, ['--version'], { - cwd: os.homedir(), - stdio: 'pipe', - }) - - let output = '' - let errorOutput = '' - - child.stdout.on('data', (data) => { - output += data.toString() - }) - - child.stderr.on('data', (data) => { - errorOutput += data.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL') - } - }, 1000) - resolve('error') - }, 1000) - - child.on('exit', (code) => { - clearTimeout(timeout) - if (code === 0) { - resolve(output.trim()) - } else { - resolve('error') - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve('error') - }) - } catch (error) { - resolve('error') - } - }) -} - -function compareVersions(v1, v2) { - if (!v1 || !v2) return 0 - - if (!v1.includes('legacy')) { - return -1 - } - - const parts1 = v1.replace('-legacy', '').split('.').map(Number) - const parts2 = v2.replace('-legacy', '').split('.').map(Number) - - for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { - const p1 = parts1[i] || 0 - const p2 = parts2[i] || 0 - - if (p1 < p2) return -1 - if (p1 > p2) return 1 - } - - return 0 -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] -} - -function createProgressBar(percentage, width = 30) { - const filled = Math.round((width * percentage) / 100) - const empty = width - filled - return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']' -} - -async function downloadBinary(version) { - const platformKey = `${process.platform}-${process.arch}` - const fileName = PLATFORM_TARGETS[platformKey] - - if (!fileName) { - throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`) - } - - // Use proxy endpoint that handles version mapping - const downloadUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL - ? `${process.env.NEXT_PUBLIC_CODEBUFF_APP_URL}/api/releases/download/${version}/${fileName}` - : `https://codebuff.com/api/releases/download/${version}/${fileName}` - - // Ensure config directory exists - fs.mkdirSync(CONFIG.configDir, { recursive: true }) - - if (fs.existsSync(CONFIG.binaryPath)) { - fs.unlinkSync(CONFIG.binaryPath) - } - - term.write('Downloading...') - - const res = await httpGet(downloadUrl) - - if (res.statusCode !== 200) { - throw new Error(`Download failed: HTTP ${res.statusCode}`) - } - - const totalSize = parseInt(res.headers['content-length'] || '0', 10) - let downloadedSize = 0 - let lastProgressTime = Date.now() - - res.on('data', (chunk) => { - downloadedSize += chunk.length - const now = Date.now() - if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { - lastProgressTime = now - if (totalSize > 0) { - const pct = Math.round((downloadedSize / totalSize) * 100) - term.write( - `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( - totalSize, - )}`, - ) - } else { - term.write(`Downloading... ${formatBytes(downloadedSize)}`) - } - } - }) - - await new Promise((resolve, reject) => { - res - .pipe(zlib.createGunzip()) - .pipe(tar.x({ cwd: CONFIG.configDir })) - .on('finish', resolve) - .on('error', reject) - }) - - try { - // Find the extracted binary - it should be named "codebuff" or "codebuff.exe" - const files = fs.readdirSync(CONFIG.configDir) - const extractedPath = path.join(CONFIG.configDir, CONFIG.binaryName) - - if (fs.existsSync(extractedPath)) { - if (process.platform !== 'win32') { - fs.chmodSync(extractedPath, 0o755) - } - } else { - throw new Error( - `Binary not found after extraction. Expected: ${extractedPath}, Available files: ${files.join(', ')}`, - ) - } - } catch (error) { - term.clearLine() - if (!isPrintMode) { - console.error(`Extraction failed: ${error.message}`) - } - process.exit(1) - } - - term.clearLine() - if (isPrintMode) { - console.log( - JSON.stringify({ type: 'download', version, status: 'complete' }), - ) - } else { - console.log('Download complete! Starting Codebuff...') - } -} - -async function ensureBinaryExists() { - const currentVersion = await getCurrentVersion() - if (currentVersion !== null && currentVersion !== 'error') { - return - } - - const version = await getLatestLegacyVersion() - if (!version) { - if (isPrintMode) { - console.error( - JSON.stringify({ - type: 'error', - message: 'Failed to determine latest version.', - }), - ) - } else { - console.error('❌ Failed to determine latest version') - console.error('Please check your internet connection and try again') - } - process.exit(1) - } - - try { - await downloadBinary(version) - } catch (error) { - term.clearLine() - if (isPrintMode) { - console.error( - JSON.stringify({ - type: 'error', - message: `Failed to download codebuff: ${error.message}`, - }), - ) - } else { - console.error('❌ Failed to download codebuff:', error.message) - console.error('Please check your internet connection and try again') - } - process.exit(1) - } -} - -async function checkForUpdates(runningProcess, exitListener, retry) { - try { - const currentVersion = await getCurrentVersion() - - const latestVersion = await getLatestLegacyVersion() - if (!latestVersion) return - - if ( - // Download new version if current binary errors. - currentVersion === 'error' || - compareVersions(currentVersion, latestVersion) < 0 - ) { - term.clearLine() - - // Remove the specific exit listener to prevent it from interfering with the update - runningProcess.removeListener('exit', exitListener) - - // Kill the running process - runningProcess.kill('SIGTERM') - - // Wait for the process to actually exit - await new Promise((resolve) => { - runningProcess.on('exit', resolve) - // Fallback timeout in case the process doesn't exit gracefully - setTimeout(() => { - if (!runningProcess.killed) { - runningProcess.kill('SIGKILL') - } - resolve() - }, 5000) - }) - - if (!isPrintMode) { - console.log(`Update available: ${currentVersion} → ${latestVersion}`) - } - - await downloadBinary(latestVersion) - - await retry(isPrintMode) - } - } catch (error) { - // Silently ignore update check errors - } -} - -async function main(firstRun = false, printMode = false) { - console.log('\x1b[1m\x1b[91m' + '='.repeat(54) + '\x1b[0m') - console.log('\x1b[1m\x1b[93m ❄️ CODEBUFF LEGACY UI ❄️\x1b[0m') - console.log( - '\x1b[1m\x1b[91mRUN `npm i -g codebuff@latest` TO SWITCH TO THE NEW UI\x1b[0m', - ) - console.log('\x1b[1m\x1b[91m' + '='.repeat(54) + '\x1b[0m') - console.log('') - - isPrintMode = printMode - await ensureBinaryExists() - - let error = null - try { - // Start codebuff - const child = spawn(CONFIG.binaryPath, process.argv.slice(2), { - stdio: 'inherit', - }) - - // Store reference to the exit listener so we can remove it during updates - const exitListener = (code) => { - process.exit(code || 0) - } - - child.on('exit', exitListener) - - if (firstRun) { - // Check for updates in background - setTimeout(() => { - if (!error) { - checkForUpdates(child, exitListener, () => main(false, isPrintMode)) - } - }, 100) - } - } catch (err) { - error = err - if (firstRun) { - if (!isPrintMode) { - console.error('❌ Codebuff failed to start:', error.message) - console.log('Redownloading Codebuff...') - } - // Binary could be corrupted (killed before download completed), so delete and retry. - fs.unlinkSync(CONFIG.binaryPath) - await main(false, isPrintMode) - } - } -} - -// Setup commander -const program = new Command() -program - .name('codebuff') - .description('AI coding agent') - .helpOption(false) - .option('-p, --print', 'print mode - suppress wrapper output') - .allowUnknownOption() - .parse() - -const options = program.opts() -isPrintMode = options.print - -// Run the main function -main(true, isPrintMode).catch((error) => { - if (!isPrintMode) { - console.error('❌ Unexpected error:', error.message) - } - process.exit(1) -}) diff --git a/npm-app/release-legacy/package.json b/npm-app/release-legacy/package.json deleted file mode 100644 index 37138e1833..0000000000 --- a/npm-app/release-legacy/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "codebuff", - "version": "0.0.6-legacy.0", - "description": "AI coding agent", - "license": "MIT", - "bin": { - "codebuff": "index.js", - "cb": "index.js" - }, - "scripts": { - "postinstall": "node -e \"const fs = require('fs'); const path = require('path'); const os = require('os'); const binaryPath = path.join(os.homedir(), '.config', 'manicode', process.platform === 'win32' ? 'codebuff.exe' : 'codebuff'); try { fs.unlinkSync(binaryPath) } catch (e) { /* ignore if file doesn't exist */ }\"", - "preuninstall": "node -e \"const fs = require('fs'); const path = require('path'); const os = require('os'); const binaryPath = path.join(os.homedir(), '.config', 'manicode', process.platform === 'win32' ? 'codebuff.exe' : 'codebuff'); try { fs.unlinkSync(binaryPath) } catch (e) { /* ignore if file doesn't exist */ }\"" - }, - "files": [ - "index.js", - "README.md" - ], - "os": [ - "darwin", - "linux", - "win32" - ], - "cpu": [ - "x64", - "arm64" - ], - "engines": { - "node": ">=16" - }, - "dependencies": { - "commander": "^12.0.0", - "tar": "^6.2.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/CodebuffAI/codebuff.git" - }, - "homepage": "https://codebuff.com", - "publishConfig": { - "access": "public" - } -} diff --git a/npm-app/release-staging/README.md b/npm-app/release-staging/README.md deleted file mode 100644 index ecb2a8503e..0000000000 --- a/npm-app/release-staging/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# 🚀 Codecane - The most powerful coding agent (STAGING) - -**⚠️ This is a staging/beta release for testing purposes.** - -Codecane is a CLI tool that writes code for you. - -1. Run `codecane` from your project directory -2. Tell it what to do -3. It will read and write to files and run commands to produce the code you want - -Note: Codecane will run commands in your terminal as it deems necessary to fulfill your request. - -## Installation - -To install Codecane (staging), run: - -```bash -npm install -g codecane@beta -``` - -(Use `sudo` if you get a permission error.) - -## Usage - -After installation, you can start Codecane by running: - -```bash -codecane [project-directory] -``` - -If no project directory is specified, Codecane will use the current directory. - -Once running, simply chat with Codecane to say what coding task you want done. - -## Features - -- Understands your whole codebase -- Creates and edits multiple files based on your request -- Can run your tests or type checker or linter; can install packages -- It's powerful: ask Codecane to keep working until it reaches a condition and it will. - -Our users regularly use Codecane to implement new features, write unit tests, refactor code,write scripts, or give advice. - -## Knowledge Files - -To unlock the full benefits of modern LLMs, we recommend storing knowledge alongside your code. Add a `knowledge.md` file anywhere in your project to provide helpful context, guidance, and tips for the LLM as it performs tasks for you. - -Codecane can fluently read and write files, so it will add knowledge as it goes. You don't need to write knowledge manually! - -Some have said every change should be paired with a unit test. In 2024, every change should come with a knowledge update! - -## Tips - -1. Type '/help' or just '/' to see available commands. -2. Create a `knowledge.md` file and collect specific points of advice. The assistant will use this knowledge to improve its responses. -3. Type `undo` or `redo` to revert or reapply file changes from the conversation. -4. Press `Esc` or `Ctrl+C` while Codecane is generating a response to stop it. - -## Troubleshooting - -If you are getting permission errors during installation, try using sudo: - -``` -sudo npm install -g codecane@beta -``` - -If you still have errors, it's a good idea to [reinstall Node](https://nodejs.org/en/download). - -## Feedback - -We value your input! Please email your feedback to `founders@codebuff.com`. Thank you for using Codecane! - - diff --git a/npm-app/release-staging/index.js b/npm-app/release-staging/index.js deleted file mode 100644 index ace74003d4..0000000000 --- a/npm-app/release-staging/index.js +++ /dev/null @@ -1,440 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require('child_process') -const fs = require('fs') -const https = require('https') -const os = require('os') -const path = require('path') -const zlib = require('zlib') - -const tar = require('tar') - -// Hardcoded package name for codecane -const packageName = 'codecane' - -function createConfig(packageName) { - const homeDir = os.homedir() - const configDir = path.join(homeDir, '.config', 'manicode') - const binaryName = - process.platform === 'win32' ? `${packageName}.exe` : packageName - - return { - homeDir, - configDir, - binaryName, - binaryPath: path.join(configDir, binaryName), - - userAgent: `${packageName}-cli`, - requestTimeout: 20000, - } -} - -const CONFIG = createConfig(packageName) - -// Platform target mapping -const PLATFORM_TARGETS = { - 'linux-x64': `${packageName}-linux-x64.tar.gz`, - 'linux-arm64': `${packageName}-linux-arm64.tar.gz`, - 'darwin-x64': `${packageName}-darwin-x64.tar.gz`, - 'darwin-arm64': `${packageName}-darwin-arm64.tar.gz`, - 'win32-x64': `${packageName}-win32-x64.tar.gz`, -} - -// Terminal utilities -const term = { - clearLine: () => { - if (process.stderr.isTTY) { - process.stderr.write('\r\x1b[K') - } - }, - write: (text) => { - term.clearLine() - process.stderr.write(text) - }, - writeLine: (text) => { - term.clearLine() - process.stderr.write(text + '\n') - }, -} - -// Utility functions -function httpGet(url, options = {}) { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(url) - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - -async function getLatestVersion() { - try { - // Use the direct /latest endpoint for stable releases - const res = await httpGet( - `https://registry.npmjs.org/${packageName}/latest`, - ) - - if (res.statusCode !== 200) return null - - const body = await streamToString(res) - const packageData = JSON.parse(body) - - return packageData.version || null - } catch (error) { - return null - } -} - -function streamToString(stream) { - return new Promise((resolve, reject) => { - let data = '' - stream.on('data', (chunk) => (data += chunk)) - stream.on('end', () => resolve(data)) - stream.on('error', reject) - }) -} - -function getCurrentVersion() { - if (!fs.existsSync(CONFIG.binaryPath)) return null - - try { - return new Promise((resolve, reject) => { - const child = spawn(CONFIG.binaryPath, ['--version'], { - cwd: os.homedir(), - stdio: 'pipe', - }) - - let output = '' - let errorOutput = '' - - child.stdout.on('data', (data) => { - output += data.toString() - }) - - child.stderr.on('data', (data) => { - errorOutput += data.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL') - } - }, 1000) - resolve('error') - }, 1000) - - child.on('exit', (code) => { - clearTimeout(timeout) - if (code === 0) { - resolve(output.trim()) - } else { - resolve('error') - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve('error') - }) - }) - } catch (error) { - return 'error' - } -} - -function compareVersions(v1, v2) { - if (!v1 || !v2) return 0 - - const parseVersion = (version) => { - const parts = version.split('-') - const mainParts = parts[0].split('.').map(Number) - const prereleaseParts = parts[1] ? parts[1].split('.') : [] - return { main: mainParts, prerelease: prereleaseParts } - } - - const p1 = parseVersion(v1) - const p2 = parseVersion(v2) - - // Compare main version parts - for (let i = 0; i < Math.max(p1.main.length, p2.main.length); i++) { - const n1 = p1.main[i] || 0 - const n2 = p2.main[i] || 0 - - if (n1 < n2) return -1 - if (n1 > n2) return 1 - } - - // If main versions are equal, compare prerelease parts - if (p1.prerelease.length === 0 && p2.prerelease.length === 0) { - return 0 // No prerelease, versions are equal - } else if (p1.prerelease.length === 0) { - return 1 // v1 is a release, v2 is prerelease, so v1 > v2 - } else if (p2.prerelease.length === 0) { - return -1 // v2 is a release, v1 is prerelease, so v1 < v2 - } else { - // Both have prerelease parts, compare them - for ( - let i = 0; - i < Math.max(p1.prerelease.length, p2.prerelease.length); - i++ - ) { - const pr1 = p1.prerelease[i] || '' - const pr2 = p2.prerelease[i] || '' - - // Handle numeric vs. string parts - const isNum1 = !isNaN(parseInt(pr1)) - const isNum2 = !isNaN(parseInt(pr2)) - - if (isNum1 && isNum2) { - const num1 = parseInt(pr1) - const num2 = parseInt(pr2) - if (num1 < num2) return -1 - if (num1 > num2) return 1 - } else if (isNum1 && !isNum2) { - return 1 // Numeric prerelease is generally higher than alpha/beta - } else if (!isNum1 && isNum2) { - return -1 - } else { - // Lexicographical comparison for string parts - if (pr1 < pr2) return -1 - if (pr1 > pr2) return 1 - } - } - return 0 // Prerelease parts are equal - } -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] -} - -function createProgressBar(percentage, width = 30) { - const filled = Math.round((width * percentage) / 100) - const empty = width - filled - return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']' -} - -async function downloadBinary(version) { - const platformKey = `${process.platform}-${process.arch}` - const fileName = PLATFORM_TARGETS[platformKey] - - if (!fileName) { - throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`) - } - - // Use proxy endpoint that handles version mapping from npm to GitHub releases - const downloadUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL - ? `${process.env.NEXT_PUBLIC_CODEBUFF_APP_URL}/api/releases/download/${version}/${fileName}` - : `https://codebuff.com/api/releases/download/${version}/${fileName}` - - // Ensure config directory exists - fs.mkdirSync(CONFIG.configDir, { recursive: true }) - - if (fs.existsSync(CONFIG.binaryPath)) { - fs.unlinkSync(CONFIG.binaryPath) - } - - term.write('Downloading...') - - const res = await httpGet(downloadUrl) - - if (res.statusCode !== 200) { - throw new Error(`Download failed: HTTP ${res.statusCode}`) - } - - const totalSize = parseInt(res.headers['content-length'] || '0', 10) - let downloadedSize = 0 - let lastProgressTime = Date.now() - - res.on('data', (chunk) => { - downloadedSize += chunk.length - const now = Date.now() - if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { - lastProgressTime = now - if (totalSize > 0) { - const pct = Math.round((downloadedSize / totalSize) * 100) - term.write( - `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( - totalSize, - )}`, - ) - } else { - term.write(`Downloading... ${formatBytes(downloadedSize)}`) - } - } - }) - - await new Promise((resolve, reject) => { - res - .pipe(zlib.createGunzip()) - .pipe(tar.x({ cwd: CONFIG.configDir })) - .on('finish', resolve) - .on('error', reject) - }) - - try { - // Find the extracted binary - it should be named "codebuff" or "codebuff.exe" - const files = fs.readdirSync(CONFIG.configDir) - const extractedPath = path.join(CONFIG.configDir, CONFIG.binaryName) - - if (fs.existsSync(extractedPath)) { - if (process.platform !== 'win32') { - fs.chmodSync(extractedPath, 0o755) - } - } else { - throw new Error( - `Binary not found after extraction. Expected: ${extractedPath}, Available files: ${files.join(', ')}`, - ) - } - } catch (error) { - term.clearLine() - console.error(`Extraction failed: ${error.message}`) - process.exit(1) - } - - term.clearLine() - console.log('Download complete! Starting Codecane...') -} - -async function ensureBinaryExists() { - const currentVersion = await getCurrentVersion() - if (currentVersion !== null && currentVersion !== 'error') { - return - } - - const version = await getLatestVersion() - if (!version) { - console.error('❌ Failed to determine latest version') - console.error('Please check your internet connection and try again') - process.exit(1) - } - - try { - await downloadBinary(version) - } catch (error) { - term.clearLine() - console.error('❌ Failed to download codebuff:', error.message) - console.error('Please check your internet connection and try again') - process.exit(1) - } -} - -async function checkForUpdates(runningProcess, exitListener) { - try { - const currentVersion = await getCurrentVersion() - if (!currentVersion) return - - const latestVersion = await getLatestVersion() - if (!latestVersion) return - - if ( - // Download new version if current binary errors. - currentVersion === 'error' || - compareVersions(currentVersion, latestVersion) < 0 - ) { - term.clearLine() - - // Remove the specific exit listener to prevent it from interfering with the update - runningProcess.removeListener('exit', exitListener) - - // Kill the running process - runningProcess.kill('SIGTERM') - - // Wait for the process to actually exit - await new Promise((resolve) => { - runningProcess.on('exit', resolve) - // Fallback timeout in case the process doesn't exit gracefully - setTimeout(() => { - if (!runningProcess.killed) { - runningProcess.kill('SIGKILL') - } - resolve() - }, 5000) - }) - - console.log(`Update available: ${currentVersion} → ${latestVersion}`) - - await downloadBinary(latestVersion) - - // Restart with new binary - this replaces the current process - const newChild = spawn(CONFIG.binaryPath, process.argv.slice(2), { - stdio: 'inherit', - detached: false, - }) - - // Set up exit handler for the new process - newChild.on('exit', (code) => { - process.exit(code || 0) - }) - - // Don't return - keep this function running to maintain the wrapper - return new Promise(() => {}) // Never resolves, keeps wrapper alive - } - } catch (error) { - // Silently ignore update check errors - } -} - -async function main() { - // Bold, bright warning for staging environment - console.log('\x1b[1m\x1b[91m' + '='.repeat(60) + '\x1b[0m') - console.log('\x1b[1m\x1b[93m❄️ CODECANE STAGING ENVIRONMENT ❄️\x1b[0m') - console.log( - '\x1b[1m\x1b[91mFOR TESTING PURPOSES ONLY - NOT FOR PRODUCTION USE\x1b[0m', - ) - console.log('\x1b[1m\x1b[91m' + '='.repeat(60) + '\x1b[0m') - console.log('') - - await ensureBinaryExists() - - // Start the binary with codecane argument - const child = spawn(CONFIG.binaryPath, process.argv.slice(2), { - stdio: 'inherit', - }) - - // Store reference to the exit listener so we can remove it during updates - const exitListener = (code) => { - process.exit(code || 0) - } - - child.on('exit', exitListener) - - // Check for updates in background - setTimeout(() => { - checkForUpdates(child, exitListener) - }, 100) -} - -// Run the main function -main().catch((error) => { - console.error('❌ Unexpected error:', error.message) - process.exit(1) -}) diff --git a/npm-app/release-staging/package.json b/npm-app/release-staging/package.json deleted file mode 100644 index e3b4d1127e..0000000000 --- a/npm-app/release-staging/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "codecane", - "version": "1.0.420", - "description": "AI coding agent (staging)", - "license": "MIT", - "bin": { - "codecane": "index.js" - }, - "scripts": { - "preuninstall": "node -e \"const fs = require('fs'); const path = require('path'); const os = require('os'); const binaryPath = path.join(os.homedir(), '.config', 'manicode', process.platform === 'win32' ? 'codecane.exe' : 'codecane'); try { fs.unlinkSync(binaryPath) } catch (e) { /* ignore if file doesn't exist */ }\"" - }, - "files": [ - "index.js", - "README.md" - ], - "os": [ - "darwin", - "linux", - "win32" - ], - "cpu": [ - "x64", - "arm64" - ], - "engines": { - "node": ">=16" - }, - "dependencies": { - "tar": "^6.2.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/CodebuffAI/codebuff-community.git" - }, - "homepage": "https://codebuff.com", - "publishConfig": { - "access": "public" - } -} diff --git a/npm-app/release/README.md b/npm-app/release/README.md deleted file mode 100644 index e2afcdb63a..0000000000 --- a/npm-app/release/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# The most powerful coding agent - -Codebuff is a CLI tool that writes code for you. - -1. Run `codebuff` from your project directory -2. Tell it what to do -3. It will read and write to files and run commands to produce the code you want - -Note: Codebuff will run commands in your terminal as it deems necessary to fulfill your request. - -## Installation - -To install Codebuff, run: - -```bash -npm install -g codebuff -``` - -(Use `sudo` if you get a permission error.) - -## Usage - -After installation, you can start Codebuff by running: - -```bash -codebuff [project-directory] -``` - -If no project directory is specified, Codebuff will use the current directory. - -Once running, simply chat with Codebuff to say what coding task you want done. - -## Features - -- Understands your whole codebase -- Creates and edits multiple files based on your request -- Can run your tests or type checker or linter; can install packages -- It's powerful: ask Codebuff to keep working until it reaches a condition and it will. - -Our users regularly use Codebuff to implement new features, write unit tests, refactor code,write scripts, or give advice. - -## Knowledge Files - -To unlock the full benefits of modern LLMs, we recommend storing knowledge alongside your code. Add a `knowledge.md` file anywhere in your project to provide helpful context, guidance, and tips for the LLM as it performs tasks for you. - -Codebuff can fluently read and write files, so it will add knowledge as it goes. You don't need to write knowledge manually! - -Some have said every change should be paired with a unit test. In 2024, every change should come with a knowledge update! - -## Tips - -1. Type '/help' or just '/' to see available commands. -2. Create a `knowledge.md` file and collect specific points of advice. The assistant will use this knowledge to improve its responses. -3. Type `undo` or `redo` to revert or reapply file changes from the conversation. -4. Press `Esc` or `Ctrl+C` while Codebuff is generating a response to stop it. - -## Troubleshooting - -If you are getting permission errors during installation, try using sudo: - -``` -sudo npm install -g codebuff -``` - -If you still have errors, it's a good idea to [reinstall Node](https://nodejs.org/en/download). - -## Feedback - -We value your input! Please email your feedback to `founders@codebuff.com`. Thank you for using Codebuff! diff --git a/npm-app/release/index.js b/npm-app/release/index.js deleted file mode 100644 index c6a73b8b78..0000000000 --- a/npm-app/release/index.js +++ /dev/null @@ -1,440 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require('child_process') -const fs = require('fs') -const https = require('https') -const os = require('os') -const path = require('path') -const zlib = require('zlib') - -const { Command } = require('commander') -const tar = require('tar') - -const CONFIG = { - homeDir: os.homedir(), - configDir: path.join(os.homedir(), '.config', 'manicode'), - binaryName: process.platform === 'win32' ? 'codebuff.exe' : 'codebuff', - githubRepo: 'CodebuffAI/codebuff', - userAgent: 'codebuff-cli', - requestTimeout: 20000, -} - -CONFIG.binaryPath = path.join(CONFIG.configDir, CONFIG.binaryName) - -// Platform target mapping -const PLATFORM_TARGETS = { - 'linux-x64': 'codebuff-linux-x64.tar.gz', - 'linux-arm64': 'codebuff-linux-arm64.tar.gz', - 'darwin-x64': 'codebuff-darwin-x64.tar.gz', - 'darwin-arm64': 'codebuff-darwin-arm64.tar.gz', - 'win32-x64': 'codebuff-win32-x64.tar.gz', -} - -// Terminal utilities -let isPrintMode = false -const term = { - clearLine: () => { - if (!isPrintMode && process.stderr.isTTY) { - process.stderr.write('\r\x1b[K') - } - }, - write: (text) => { - if (!isPrintMode) { - term.clearLine() - process.stderr.write(text) - } - }, - writeLine: (text) => { - if (!isPrintMode) { - term.clearLine() - process.stderr.write(text + '\n') - } - }, -} - -// Utility functions -function httpGet(url, options = {}) { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(url) - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - // Add GitHub token if available - const token = process.env.GITHUB_TOKEN - if (token) { - reqOptions.headers.Authorization = `Bearer ${token}` - } - - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - -async function getLatestVersion() { - try { - const res = await httpGet(`https://registry.npmjs.org/codebuff/latest`) - - if (res.statusCode !== 200) return null - - const body = await streamToString(res) - const packageData = JSON.parse(body) - - return packageData.version || null - } catch (error) { - return null - } -} - -function streamToString(stream) { - return new Promise((resolve, reject) => { - let data = '' - stream.on('data', (chunk) => (data += chunk)) - stream.on('end', () => resolve(data)) - stream.on('error', reject) - }) -} - -function getCurrentVersion() { - return new Promise((resolve, reject) => { - try { - if (!fs.existsSync(CONFIG.binaryPath)) { - resolve('error') - return - } - - const child = spawn(CONFIG.binaryPath, ['--version'], { - cwd: os.homedir(), - stdio: 'pipe', - }) - - let output = '' - let errorOutput = '' - - child.stdout.on('data', (data) => { - output += data.toString() - }) - - child.stderr.on('data', (data) => { - errorOutput += data.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL') - } - }, 1000) - resolve('error') - }, 1000) - - child.on('exit', (code) => { - clearTimeout(timeout) - if (code === 0) { - resolve(output.trim()) - } else { - resolve('error') - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve('error') - }) - } catch (error) { - resolve('error') - } - }) -} - -function compareVersions(v1, v2) { - if (!v1 || !v2) return 0 - - if (!v1.match(/^\d+(\.\d+)*$/)) { - return -1 - } - - const parts1 = v1.split('.').map(Number) - const parts2 = v2.split('.').map(Number) - - for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { - const p1 = parts1[i] || 0 - const p2 = parts2[i] || 0 - - if (p1 < p2) return -1 - if (p1 > p2) return 1 - } - - return 0 -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] -} - -function createProgressBar(percentage, width = 30) { - const filled = Math.round((width * percentage) / 100) - const empty = width - filled - return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']' -} - -async function downloadBinary(version) { - const platformKey = `${process.platform}-${process.arch}` - const fileName = PLATFORM_TARGETS[platformKey] - - if (!fileName) { - throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`) - } - - // Use proxy endpoint that handles version mapping - const downloadUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL - ? `${process.env.NEXT_PUBLIC_CODEBUFF_APP_URL}/api/releases/download/${version}/${fileName}` - : `https://codebuff.com/api/releases/download/${version}/${fileName}` - - // Ensure config directory exists - fs.mkdirSync(CONFIG.configDir, { recursive: true }) - - if (fs.existsSync(CONFIG.binaryPath)) { - fs.unlinkSync(CONFIG.binaryPath) - } - - term.write('Downloading...') - - const res = await httpGet(downloadUrl) - - if (res.statusCode !== 200) { - throw new Error(`Download failed: HTTP ${res.statusCode}`) - } - - const totalSize = parseInt(res.headers['content-length'] || '0', 10) - let downloadedSize = 0 - let lastProgressTime = Date.now() - - res.on('data', (chunk) => { - downloadedSize += chunk.length - const now = Date.now() - if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { - lastProgressTime = now - if (totalSize > 0) { - const pct = Math.round((downloadedSize / totalSize) * 100) - term.write( - `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( - totalSize, - )}`, - ) - } else { - term.write(`Downloading... ${formatBytes(downloadedSize)}`) - } - } - }) - - await new Promise((resolve, reject) => { - res - .pipe(zlib.createGunzip()) - .pipe(tar.x({ cwd: CONFIG.configDir })) - .on('finish', resolve) - .on('error', reject) - }) - - try { - // Find the extracted binary - it should be named "codebuff" or "codebuff.exe" - const files = fs.readdirSync(CONFIG.configDir) - const extractedPath = path.join(CONFIG.configDir, CONFIG.binaryName) - - if (fs.existsSync(extractedPath)) { - if (process.platform !== 'win32') { - fs.chmodSync(extractedPath, 0o755) - } - } else { - throw new Error( - `Binary not found after extraction. Expected: ${extractedPath}, Available files: ${files.join(', ')}`, - ) - } - } catch (error) { - term.clearLine() - if (!isPrintMode) { - console.error(`Extraction failed: ${error.message}`) - } - process.exit(1) - } - - term.clearLine() - if (isPrintMode) { - console.log( - JSON.stringify({ type: 'download', version, status: 'complete' }), - ) - } else { - console.log('Download complete! Starting Codebuff...') - } -} - -async function ensureBinaryExists() { - const currentVersion = await getCurrentVersion() - if (currentVersion !== null && currentVersion !== 'error') { - return - } - - const version = await getLatestVersion() - if (!version) { - if (isPrintMode) { - console.error( - JSON.stringify({ - type: 'error', - message: 'Failed to determine latest version.', - }), - ) - } else { - console.error('❌ Failed to determine latest version') - console.error('Please check your internet connection and try again') - } - process.exit(1) - } - - try { - await downloadBinary(version) - } catch (error) { - term.clearLine() - if (isPrintMode) { - console.error( - JSON.stringify({ - type: 'error', - message: `Failed to download codebuff: ${error.message}`, - }), - ) - } else { - console.error('❌ Failed to download codebuff:', error.message) - console.error('Please check your internet connection and try again') - } - process.exit(1) - } -} - -async function checkForUpdates(runningProcess, exitListener, retry) { - try { - const currentVersion = await getCurrentVersion() - - const latestVersion = await getLatestVersion() - if (!latestVersion) return - - if ( - // Download new version if current binary errors. - currentVersion === 'error' || - compareVersions(currentVersion, latestVersion) < 0 - ) { - term.clearLine() - - // Remove the specific exit listener to prevent it from interfering with the update - runningProcess.removeListener('exit', exitListener) - - // Kill the running process - runningProcess.kill('SIGTERM') - - // Wait for the process to actually exit - await new Promise((resolve) => { - runningProcess.on('exit', resolve) - // Fallback timeout in case the process doesn't exit gracefully - setTimeout(() => { - if (!runningProcess.killed) { - runningProcess.kill('SIGKILL') - } - resolve() - }, 5000) - }) - - if (!isPrintMode) { - console.log(`Update available: ${currentVersion} → ${latestVersion}`) - } - - await downloadBinary(latestVersion) - - await retry(isPrintMode) - } - } catch (error) { - // Silently ignore update check errors - } -} - -async function main(firstRun = false, printMode = false) { - isPrintMode = printMode - await ensureBinaryExists() - - let error = null - try { - // Start codebuff - const child = spawn(CONFIG.binaryPath, process.argv.slice(2), { - stdio: 'inherit', - }) - - // Store reference to the exit listener so we can remove it during updates - const exitListener = (code) => { - process.exit(code || 0) - } - - child.on('exit', exitListener) - - if (firstRun) { - // Check for updates in background - setTimeout(() => { - if (!error) { - checkForUpdates(child, exitListener, () => main(false, isPrintMode)) - } - }, 100) - } - } catch (err) { - error = err - if (firstRun) { - if (!isPrintMode) { - console.error('❌ Codebuff failed to start:', error.message) - console.log('Redownloading Codebuff...') - } - // Binary could be corrupted (killed before download completed), so delete and retry. - fs.unlinkSync(CONFIG.binaryPath) - await main(false, isPrintMode) - } - } -} - -// Setup commander -const program = new Command() -program - .name('codebuff') - .description('AI coding agent') - .helpOption(false) - .option('-p, --print', 'print mode - suppress wrapper output') - .allowUnknownOption() - .parse() - -const options = program.opts() -isPrintMode = options.print - -// Run the main function -main(true, isPrintMode).catch((error) => { - if (!isPrintMode) { - console.error('❌ Unexpected error:', error.message) - } - process.exit(1) -}) diff --git a/npm-app/release/package.json b/npm-app/release/package.json deleted file mode 100644 index 39d66bd53d..0000000000 --- a/npm-app/release/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "codebuff", - "version": "1.0.513", - "description": "AI coding agent", - "license": "MIT", - "bin": { - "codebuff": "index.js", - "cb": "index.js" - }, - "scripts": { - "preuninstall": "node -e \"const fs = require('fs'); const path = require('path'); const os = require('os'); const binaryPath = path.join(os.homedir(), '.config', 'manicode', process.platform === 'win32' ? 'codebuff.exe' : 'codebuff'); try { fs.unlinkSync(binaryPath) } catch (e) { /* ignore if file doesn't exist */ }\"" - }, - "files": [ - "index.js", - "README.md" - ], - "os": [ - "darwin", - "linux", - "win32" - ], - "cpu": [ - "x64", - "arm64" - ], - "engines": { - "node": ">=16" - }, - "dependencies": { - "commander": "^12.0.0", - "tar": "^6.2.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/CodebuffAI/codebuff.git" - }, - "homepage": "https://codebuff.com", - "publishConfig": { - "access": "public" - } -} diff --git a/npm-app/scripts/build-binary.js b/npm-app/scripts/build-binary.js deleted file mode 100644 index 9dd5e4ec32..0000000000 --- a/npm-app/scripts/build-binary.js +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process') -const fs = require('fs') -const path = require('path') - -const { patchWebTreeSitter } = require('./patch-web-tree-sitter.js') - -// Configuration -const VERBOSE = process.env.VERBOSE === 'true' - -// Get package name and npm app version from command line arguments -const packageName = process.argv[2] || 'codebuff' -const npmAppVersion = process.argv[3] - -if (!packageName) { - console.error('Error: Package name is required as first argument') - console.error('Usage: build-binary.js ') - process.exit(1) -} - -if (!npmAppVersion) { - console.error('Error: NPM app version is required as second argument') - console.error('Usage: build-binary.js ') - process.exit(1) -} - -// Logging helper -function log(message) { - if (VERBOSE) { - console.log(message) - } -} - -function logAlways(message) { - console.log(message) -} - -// Get current platform info -const currentPlatform = process.platform -const currentArch = process.arch - -// Map current platform/arch to target info -const getTargetInfo = () => { - // Check for environment variable overrides (for cross-compilation) - if ( - process.env.OVERRIDE_TARGET && - process.env.OVERRIDE_PLATFORM && - process.env.OVERRIDE_ARCH - ) { - return { - bunTarget: process.env.OVERRIDE_TARGET, - platform: process.env.OVERRIDE_PLATFORM, - arch: process.env.OVERRIDE_ARCH, - } - } - - const platformKey = `${currentPlatform}-${currentArch}` - - const targetMap = { - 'linux-x64': { bunTarget: 'bun-linux-x64', platform: 'linux', arch: 'x64' }, - 'linux-arm64': { - bunTarget: 'bun-linux-arm64', - platform: 'linux', - arch: 'arm64', - }, - 'darwin-x64': { - bunTarget: 'bun-darwin-x64', - platform: 'darwin', - arch: 'x64', - }, - 'darwin-arm64': { - bunTarget: 'bun-darwin-arm64', - platform: 'darwin', - arch: 'arm64', - }, - 'win32-x64': { - bunTarget: 'bun-windows-x64', - platform: 'win32', - arch: 'x64', - }, - } - - const targetInfo = targetMap[platformKey] - if (!targetInfo) { - console.error(`Unsupported platform: ${platformKey}`) - process.exit(1) - } - - return targetInfo -} - -async function main() { - log('🔧 Patching web-tree-sitter...') - patchWebTreeSitter(VERBOSE) - - const targetInfo = getTargetInfo() - const outputName = - currentPlatform === 'win32' ? `${packageName}.exe` : packageName - - await buildTarget(targetInfo.bunTarget, outputName, targetInfo) -} - -async function buildTarget(bunTarget, outputName, targetInfo) { - // Create bin directory - const binDir = path.join(__dirname, '..', 'bin') - if (!fs.existsSync(binDir)) { - fs.mkdirSync(binDir, { recursive: true }) - } - - const outputFile = path.join(binDir, outputName) - - log( - `🔨 Building ${outputName} (${targetInfo.platform}-${targetInfo.arch})...`, - ) - - const flags = { - IS_BINARY: 'true', - NEXT_PUBLIC_NPM_APP_VERSION: npmAppVersion, - } - - const defineFlags = Object.entries(flags) - .map(([key, value]) => { - const stringValue = - typeof value === 'string' ? `'${value}'` : String(value) - return `--define process.env.${key}=${JSON.stringify(stringValue)}` - }) - .join(' ') - - const entrypoints = [ - 'src/index.ts', - 'src/workers/project-context.ts', - 'src/workers/checkpoint-worker.ts', - ] - - const command = [ - 'bun build --compile', - ...entrypoints, - `--target=${bunTarget}`, - '--asset-naming=[name].[ext]', - defineFlags, - '--env "NEXT_PUBLIC_*"', // Copies all current env vars in process.env to the compiled binary that match the pattern. - `--outfile=${outputFile}`, - // '--minify', // harder to debug - ] - .filter(Boolean) - .join(' ') - - try { - const stdio = VERBOSE ? 'inherit' : 'pipe' - execSync(command, { stdio, shell: true }) - - // Make executable on Unix systems - if (!outputName.endsWith('.exe')) { - fs.chmodSync(outputFile, 0o755) - } - - logAlways( - `✅ Built ${outputName} for ${targetInfo.platform}-${targetInfo.arch}`, - ) - } catch (error) { - logAlways(`❌ Failed to build ${outputName}: ${error.message}`) - process.exit(1) - } -} - -main().catch((error) => { - console.error('Build failed:', error) - process.exit(1) -}) diff --git a/npm-app/scripts/patch-web-tree-sitter.ts b/npm-app/scripts/patch-web-tree-sitter.ts deleted file mode 100644 index b833f66a18..0000000000 --- a/npm-app/scripts/patch-web-tree-sitter.ts +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' - -/* - * This script patches web-tree-sitter to use inlined WASM data - * instead of file system access for better binary compatibility. - */ - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -function patchSingleFile(webTreeSitterPath: string, verbose: boolean): boolean { - if (!fs.existsSync(webTreeSitterPath)) { - if (verbose) { - console.warn( - `⚠️ web-tree-sitter not found at ${webTreeSitterPath}, skipping`, - ) - } - return false - } - - try { - let content = fs.readFileSync(webTreeSitterPath, 'utf8') - const originalContent = content - - if (verbose) { - console.log(`Checking file at: ${webTreeSitterPath}`) - console.log('File size:', content.length) - } - - // Read and encode the WASM file as base64 - const wasmPath = path.join( - __dirname, - '../../node_modules/web-tree-sitter/tree-sitter.wasm', - ) - if (!fs.existsSync(wasmPath)) { - throw new Error(`❌ Web-tree-sitter WASM file not found at ${wasmPath}`) - } - const wasmBuffer = fs.readFileSync(wasmPath) - const wasmBase64 = wasmBuffer.toString('base64') - - // Check if already patched with the new version - if (content.includes('CODEBUFF_PATCHED_FINDWASM_V3')) { - if (verbose) { - console.log('ℹ️ Already patched with new version') - } - return false - } - - // Remove old patches completely - restore original file first - // Reinstall the package to get a clean version - if (verbose) { - console.log('🔄 Removing old patches, reinstalling web-tree-sitter...') - } - const { execSync } = require('child_process') - execSync('bun uninstall web-tree-sitter', { - cwd: path.join(__dirname, '../..'), - stdio: 'pipe', - }) - execSync('bun install web-tree-sitter@0.25.6', { - cwd: path.join(__dirname, '../../packages/code-map'), - stdio: 'pipe', - }) - - // Re-read the clean file - content = fs.readFileSync(webTreeSitterPath, 'utf8') - - // Add global WASM data at the top of the file - const globalWasmData = ` -// CODEBUFF_PATCHED_GLOBAL_WASM -var CODEBUFF_INLINED_WASM_DATA = "${wasmBase64}"; -var CODEBUFF_WASM_BINARY = null; -` - - // Insert the global data after the first line - const lines = content.split('\n') - lines.splice(1, 0, globalWasmData) - content = lines.join('\n') - - // Track replacement success - const replacements = [] - - // Patch pattern for readFileSync - const readPattern = - 'var ret = fs.readFileSync(filename, binary2 ? void 0 : "utf8");' - const readReplacement = `/*CODEBUFF_PATCHED*/var ret; if(typeof Bun!=="undefined"&&binary2&&filename.includes("tree-sitter.wasm")&&typeof CODEBUFF_INLINED_WASM_DATA!=="undefined"){ret=new Uint8Array(Buffer.from(CODEBUFF_INLINED_WASM_DATA,"base64"));}else{ret=fs.readFileSync(filename, binary2 ? void 0 : "utf8");}` - - const newContent1 = content.replace(readPattern, readReplacement) - replacements.push({ - name: 'readFileSync patch', - success: newContent1 !== content, - }) - content = newContent1 - - // Patch the getBinarySync function to use our inlined data - const getBinarySyncPattern = - /function getBinarySync\(file\) \{\s*if \(file == wasmBinaryFile && wasmBinary\) \{\s*return new Uint8Array\(wasmBinary\);\s*\}/ - const getBinarySyncReplacement = `function getBinarySync(file) { - /*CODEBUFF_PATCHED_GETBINARY*/ - if (typeof Bun !== "undefined" && typeof CODEBUFF_INLINED_WASM_DATA !== "undefined") { - if (!CODEBUFF_WASM_BINARY) { - CODEBUFF_WASM_BINARY = new Uint8Array(Buffer.from(CODEBUFF_INLINED_WASM_DATA, "base64")); - } - return CODEBUFF_WASM_BINARY; - } - if (file == wasmBinaryFile && wasmBinary) { - return new Uint8Array(wasmBinary); - }` - - const newContent2 = content.replace( - getBinarySyncPattern, - getBinarySyncReplacement, - ) - replacements.push({ - name: 'getBinarySync patch', - success: newContent2 !== content, - }) - content = newContent2 - - // Patch pattern for findWasmBinary function - simplified approach - const findWasmPattern = - /function findWasmBinary\(\) \{\s*if \(Module\["locateFile"\]\) \{\s*return locateFile\("tree-sitter\.wasm"\);\s*\}\s*return new URL\("tree-sitter\.wasm", import\.meta\.url\)\.href;\s*\}/ - const findWasmReplacement = `function findWasmBinary() { - /*CODEBUFF_PATCHED_FINDWASM_V3*/ - if (typeof Bun !== "undefined" && typeof CODEBUFF_INLINED_WASM_DATA !== "undefined") { - // Set wasmBinary directly so getBinarySync can use it - if (!CODEBUFF_WASM_BINARY) { - CODEBUFF_WASM_BINARY = Buffer.from(CODEBUFF_INLINED_WASM_DATA, "base64"); - } - wasmBinary = CODEBUFF_WASM_BINARY; - wasmBinaryFile = "tree-sitter.wasm"; - return "tree-sitter.wasm"; - } - if (Module["locateFile"]) { - return locateFile("tree-sitter.wasm"); - } - return new URL("tree-sitter.wasm", import.meta.url).href; - }` - - const newContent3 = content.replace(findWasmPattern, findWasmReplacement) - replacements.push({ - name: 'findWasmBinary patch', - success: newContent3 !== content, - }) - content = newContent3 - - // Check if all replacements were successful - const failedReplacements = replacements.filter((r) => !r.success) - - if (content !== originalContent) { - fs.writeFileSync(webTreeSitterPath, content, 'utf8') - if (verbose) { - console.log('✅ Patched successfully with inlined WASM data') - if (failedReplacements.length > 0) { - console.warn( - `⚠️ Some patches failed: ${failedReplacements.map((r) => r.name).join(', ')}`, - ) - } - } - return true - } else { - if (verbose) { - console.log( - '⚠️ No changes made - all patterns may have failed to match', - ) - console.log( - `Failed replacements: ${failedReplacements.map((r) => r.name).join(', ')}`, - ) - } - return false - } - } catch (error) { - console.error(`❌ Failed to patch ${webTreeSitterPath}:`, error.message) - return false - } -} - -export function patchWebTreeSitter(verbose = false) { - // Only patch root node_modules (hoisted) - const webTreeSitterPath = path.join( - __dirname, - '../../node_modules/web-tree-sitter/tree-sitter.js', - ) - - let patchedCount = 0 - if (patchSingleFile(webTreeSitterPath, verbose)) { - patchedCount++ - } - - if (verbose) { - console.log(`✅ Patched ${patchedCount} web-tree-sitter file(s)`) - } -} - -// Check if this script is being run directly -if (import.meta.url === `file://${process.argv[1]}`) { - patchWebTreeSitter(true) -} diff --git a/npm-app/scripts/release-legacy.js b/npm-app/scripts/release-legacy.js deleted file mode 100755 index aaf0895639..0000000000 --- a/npm-app/scripts/release-legacy.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process') - -// Parse command line arguments -const args = process.argv.slice(2) -const versionType = args[0] || 'prepatch' // prepatch, minor, major, or specific version like 1.2.3 - -function log(message) { - console.log(`${message}`) -} - -function error(message) { - console.error(`❌ ${message}`) - process.exit(1) -} - -function formatTimestamp() { - const now = new Date() - const options = { - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - } - return now.toLocaleDateString('en-US', options) -} - -function checkGitHubToken() { - const token = process.env.CODEBUFF_GITHUB_TOKEN - if (!token) { - error( - 'CODEBUFF_GITHUB_TOKEN environment variable is required but not set.\n' + - 'Please set it with your GitHub personal access token or use the infisical setup.', - ) - } - - // Set GITHUB_TOKEN for compatibility with existing curl commands - process.env.GITHUB_TOKEN = token - return token -} - -async function triggerWorkflow(versionType) { - if (!process.env.GITHUB_TOKEN) { - error('GITHUB_TOKEN environment variable is required but not set') - } - - try { - // Use workflow filename instead of ID - const triggerCmd = `curl -s -w "HTTP Status: %{http_code}" -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${process.env.GITHUB_TOKEN}" \ - -H "Content-Type: application/json" \ - https://api.github.com/repos/CodebuffAI/codebuff/actions/workflows/npm-app-release-legacy.yml/dispatches \ - -d '{"ref":"main","inputs":{"version_type":"${versionType}"}}'` - - const response = execSync(triggerCmd, { encoding: 'utf8' }) - - // Check if response contains error message - if (response.includes('workflow_dispatch')) { - log(`⚠️ Workflow dispatch failed: ${response}`) - log('The workflow may need to be updated on GitHub. Continuing anyway...') - log( - 'Please manually trigger the workflow at: https://github.com/CodebuffAI/codebuff/actions/workflows/npm-app-release-legacy.yml', - ) - } else { - // log( - // `Workflow trigger response: ${response || '(empty response - likely success)'}` - // ) - log('🎉 Release workflow triggered!') - } - } catch (err) { - log(`⚠️ Failed to trigger workflow automatically: ${err.message}`) - log( - 'You may need to trigger it manually at: https://github.com/CodebuffAI/codebuff/actions/workflows/npm-app-release-legacy.yml', - ) - } -} - -async function main() { - log('🚀 Initiating release...') - log(`Date: ${formatTimestamp()}`) - - // Check for local GitHub token - checkGitHubToken() - log('✅ Using local CODEBUFF_GITHUB_TOKEN') - - log(`Version bump type: ${versionType}`) - - // Trigger the workflow - await triggerWorkflow(versionType) - - log('') - log('Monitor progress at: https://github.com/CodebuffAI/codebuff/actions') -} - -main().catch((err) => { - error(`Release failed: ${err.message}`) -}) diff --git a/npm-app/scripts/release.js b/npm-app/scripts/release.js deleted file mode 100755 index 9d35cac9a8..0000000000 --- a/npm-app/scripts/release.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process') - -// Parse command line arguments -const args = process.argv.slice(2) -const versionType = args[0] || 'patch' // patch, minor, major, or specific version like 1.2.3 - -function log(message) { - console.log(`${message}`) -} - -function error(message) { - console.error(`❌ ${message}`) - process.exit(1) -} - -function formatTimestamp() { - const now = new Date() - const options = { - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - } - return now.toLocaleDateString('en-US', options) -} - -function checkGitHubToken() { - const token = process.env.CODEBUFF_GITHUB_TOKEN - if (!token) { - error( - 'CODEBUFF_GITHUB_TOKEN environment variable is required but not set.\n' + - 'Please set it with your GitHub personal access token or use the infisical setup.' - ) - } - - // Set GITHUB_TOKEN for compatibility with existing curl commands - process.env.GITHUB_TOKEN = token - return token -} - -async function triggerWorkflow(versionType) { - if (!process.env.GITHUB_TOKEN) { - error('GITHUB_TOKEN environment variable is required but not set') - } - - try { - // Use workflow filename instead of ID - const triggerCmd = `curl -s -w "HTTP Status: %{http_code}" -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${process.env.GITHUB_TOKEN}" \ - -H "Content-Type: application/json" \ - https://api.github.com/repos/CodebuffAI/codebuff/actions/workflows/npm-app-release-prod.yml/dispatches \ - -d '{"ref":"main","inputs":{"version_type":"${versionType}"}}'` - - const response = execSync(triggerCmd, { encoding: 'utf8' }) - - // Check if response contains error message - if (response.includes('workflow_dispatch')) { - log(`⚠️ Workflow dispatch failed: ${response}`) - log('The workflow may need to be updated on GitHub. Continuing anyway...') - log( - 'Please manually trigger the workflow at: https://github.com/CodebuffAI/codebuff/actions/workflows/npm-app-release-prod.yml', - ) - } else { - // log( - // `Workflow trigger response: ${response || '(empty response - likely success)'}` - // ) - log('🎉 Release workflow triggered!') - } - } catch (err) { - log(`⚠️ Failed to trigger workflow automatically: ${err.message}`) - log( - 'You may need to trigger it manually at: https://github.com/CodebuffAI/codebuff/actions/workflows/npm-app-release-prod.yml', - ) - } -} - -async function main() { - log('🚀 Initiating release...') - log(`Date: ${formatTimestamp()}`) - - // Check for local GitHub token - checkGitHubToken() - log('✅ Using local CODEBUFF_GITHUB_TOKEN') - - log(`Version bump type: ${versionType}`) - - // Trigger the workflow - await triggerWorkflow(versionType) - - log('') - log('Monitor progress at: https://github.com/CodebuffAI/codebuff/actions') -} - -main().catch((err) => { - error(`Release failed: ${err.message}`) -}) diff --git a/npm-app/scripts/twitch-plays-codebuff.sh b/npm-app/scripts/twitch-plays-codebuff.sh deleted file mode 100755 index e7de8b1e9e..0000000000 --- a/npm-app/scripts/twitch-plays-codebuff.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -function send_input() { - local input="$1" - tmux send-keys -t codebuff "$input" - sleep 1 - tmux send-keys -t codebuff Enter -} - -# Create a new tmux session named 'codebuff' and start codebuff in it -tmux new-session -d -s codebuff 'codebuff' - -# Track last message to avoid duplicates -last_message="" - -# Run every 15 seconds -while true; do - # Get timestamp from 3 minutes ago - timestamp=$(($(date +%s) * 1000 - 180000)) - - # Fetch last 10 messages from API - response=$(curl -s "https://recent-messages.robotty.de/api/v2/recent-messages/codebuff_ai?limit=10&after=$timestamp") - - # Process messages in reverse order and stop after first successful send - if [ ! -z "$response" ]; then - messages=$(echo "$response" | jq -r '.messages | reverse | .[]') - while IFS= read -r msg; do - message=$(echo "$msg" | grep -o 'PRIVMSG #codebuff_ai :>.*' | sed 's/PRIVMSG #codebuff_ai :>//') - if [ ! -z "$message" ] && [ "$message" != "$last_message" ]; then - send_input "$message" - last_message="$message" - fi - break - done <<< "$messages" - fi - - sleep 15 -done diff --git a/npm-app/src/__tests__/display.test.ts b/npm-app/src/__tests__/display.test.ts deleted file mode 100644 index 7d47223b0a..0000000000 --- a/npm-app/src/__tests__/display.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { gray } from 'picocolors' - -import { onlyWhitespace, squashNewlines } from '../display/squash-newlines' - -const PREFIX = '.\r\n' - -// Helper function to simulate getLastTwoLines behavior -function getLastTwoLines(str: string): string { - return PREFIX + str.split('\r\n').slice(-2).join('\r\n') -} - -describe('squashNewlines', () => { - describe('when called with getLastTwoLines(previous) + chunk', () => { - it('should handle simple strings', () => { - const previous = 'line1\r\nline2\r\nline3' - const chunk = '\r\nline4\r\nline5' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle when chunk has empty lines', () => { - const previous = 'content\r\nmore content' - const chunk = '\r\n\r\n\r\nfinal line' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + '\r\n\r\nfinal line') - }) - - it('should handle when chunk has whitespace lines', () => { - const previous = 'first\r\nsecond\r\nthird' - const chunk = '\r\n \r\n\t\r\nfourth' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + '\r\n \r\n\tfourth') - }) - - it('should handle when previous is empty', () => { - const previous = '' - const chunk = 'some\r\ncontent' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle when chunk is empty', () => { - const previous = 'some\r\ncontent\r\nhere' - const chunk = '' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle when both strings are empty', () => { - const previous = '' - const chunk = '' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle complex mixed content', () => { - const previous = 'alpha\r\nbeta\r\ngamma\r\ndelta' - const chunk = '\r\n\r\n \r\n\t\r\n\r\nepsilon\r\nzeta' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + '\r\n\r\n \tepsilon\r\nzeta') - }) - - it('should handle when previous has only newlines', () => { - const previous = '\r\n\r\n\r\n' - const chunk = 'content' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle when chunk has only newlines', () => { - const previous = 'content\r\nmore' - const chunk = '\r\n\r\n\r\n' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + '\r\n\r\n') - }) - - it('should handle single line inputs', () => { - const previous = 'single line' - const chunk = 'another line' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + chunk) - }) - - it('should handle previous ends with whitespace lines', () => { - const previous = 'content\r\n \r\n\t' - const chunk = '\r\nmore content' - const lastTwoLines = getLastTwoLines(previous) - const combined = lastTwoLines + chunk - const squashed = squashNewlines(combined) - - expect(squashed).toEqual(lastTwoLines + 'more content') - }) - }) - - describe('squashNewlines behavior verification', () => { - it('should squash consecutive empty lines correctly', () => { - const input = PREFIX + 'line1\r\n\r\n\r\n\r\nline5' - const result = squashNewlines(input) - - // Should reduce multiple consecutive empty lines to at most 2 - expect(result).toBe(PREFIX + 'line1\r\n\r\nline5') - }) - - it('should preserve single empty lines', () => { - const input = PREFIX + 'line1\r\n\r\nline3' - const result = squashNewlines(input) - - expect(result).toBe(input) // Should remain unchanged - }) - }) - - describe('error handling', () => { - it('should throw error when input does not start with PREFIX', () => { - const invalidInput = 'invalid input without prefix' - - expect(() => squashNewlines(invalidInput)).toThrow( - `Expected string to start with ${JSON.stringify(PREFIX)}`, - ) - }) - }) -}) - -describe('onlyWhitespace', () => { - it('should return true for empty string', () => { - expect(onlyWhitespace('')).toBe(true) - }) - - it('should return true for single space', () => { - expect(onlyWhitespace(' ')).toBe(true) - }) - - it('should return true for multiple spaces', () => { - expect(onlyWhitespace(' ')).toBe(true) - }) - - it('should return true for tab', () => { - expect(onlyWhitespace('\t')).toBe(true) - }) - - it('should return true for newline', () => { - expect(onlyWhitespace('\n')).toBe(true) - }) - - it('should return true for carriage return', () => { - expect(onlyWhitespace('\r')).toBe(true) - }) - - it('should return true for ANSI escape sequences', () => { - expect(onlyWhitespace('\u001b[31m')).toBe(true) // Red color - expect(onlyWhitespace('\u001b[0m')).toBe(true) // Reset - }) - - it('should return true for OSC sequences', () => { - const oscSequence = - '\u001b]697;OSCUnlock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;Dir=/Users/jahooma/codebuff\u0007' - expect(onlyWhitespace(oscSequence)).toBe(true) - }) - - it('should return false for control characters', () => { - expect(onlyWhitespace('\u0000\u0001\u0002')).toBe(true) // Null, SOH, STX - expect(onlyWhitespace('\u007F')).toBe(true) // DEL - }) - - it('should return true for zero-width characters', () => { - expect(onlyWhitespace('\u200B')).toBe(true) - expect(onlyWhitespace('\u200C')).toBe(true) - expect(onlyWhitespace('\u200D')).toBe(true) - }) - - it('should return true for colored empty strings', () => { - expect(onlyWhitespace(gray(' '))).toBe(true) - }) - - it('should return false for visible text', () => { - expect(onlyWhitespace('hello')).toBe(false) - expect(onlyWhitespace('a')).toBe(false) - expect(onlyWhitespace('123')).toBe(false) - }) - - describe('real world examples', () => { - it('should return false for end of terminal command', () => { - expect( - onlyWhitespace( - '\u001b]697;OSCUnlock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;Dir=/Users/jahooma/codebuff\u0007\u001b]697;Shell=bash\u0007\u001b]697;ShellPath=/bin/bash\u0007\u001b]697;PID=71631\u0007\u001b]697;ExitCode=0\u0007\u001b]697;TTY=/dev/ttys036\u0007\u001b]697;Log=\u0007\u001b]697;User=jahooma\u0007\u001b]697;OSCLock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;PreExec\u0007\u001b]697;StartPrompt\u0007', - ), - ).toBe(true) - - expect( - onlyWhitespace( - '\u001b]0;charles@charles-framework-13:~/github/codebuff\u001b\\\u001b]7;file://charles-framework-13/home/charles/github/codebuff\u001b\\\u001b[?2004h', - ), - ).toBe(true) - }) - }) -}) diff --git a/npm-app/src/__tests__/glob-handler.test.ts b/npm-app/src/__tests__/glob-handler.test.ts deleted file mode 100644 index 8a1af3096d..0000000000 --- a/npm-app/src/__tests__/glob-handler.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from 'bun:test' - -import { toolHandlers } from '../tool-handlers' - -const handleGlob = toolHandlers.glob - -describe('handleGlob', () => { - const testDataDir = path.resolve(__dirname, 'glob-test-data') - const mockGetProjectRoot = mock(() => { - return path.resolve(__dirname, '../../') - }) - - beforeAll(async () => { - await mockModule('@codebuff/npm-app/project-files', () => ({ - getProjectRoot: mockGetProjectRoot, - })) - }) - - beforeEach(async () => { - const projectRoot = path.resolve(__dirname, '../../') - mockGetProjectRoot.mockReturnValue(projectRoot) - - // Create test data directory and nested structure - await fs.promises.mkdir(testDataDir, { recursive: true }) - await fs.promises.mkdir(path.join(testDataDir, 'src'), { recursive: true }) - await fs.promises.mkdir(path.join(testDataDir, 'src', 'components'), { - recursive: true, - }) - await fs.promises.mkdir(path.join(testDataDir, 'lib'), { recursive: true }) - await fs.promises.mkdir(path.join(testDataDir, 'docs'), { recursive: true }) - - // Create test files - await fs.promises.writeFile( - path.join(testDataDir, 'package.json'), - '{}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'README.md'), - '# Test', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'src', 'index.ts'), - 'export {}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'src', 'utils.ts'), - 'export {}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'src', 'components', 'Button.tsx'), - 'export {}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'src', 'components', 'Input.tsx'), - 'export {}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'lib', 'helper.js'), - 'module.exports = {}', - ) - await fs.promises.writeFile( - path.join(testDataDir, 'docs', 'guide.md'), - '# Guide', - ) - }) - - afterEach(async () => { - try { - await fs.promises.rm(testDataDir, { recursive: true, force: true }) - } catch (error) { - // Ignore cleanup errors - } - }) - - afterAll(() => { - clearMockedModules() - }) - - test('matches all files with **/* pattern without cwd', async () => { - const parameters = { - pattern: '**/*', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - // Should match all files in the project (limited to our test structure) - expect(files.length).toBeGreaterThan(0) - expect((result[0].value as any).count).toBe(files.length) - }) - - test('matches all files with **/* pattern with cwd', async () => { - const parameters = { - pattern: '**/*', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - // Should match all 8 files in our test directory - expect(files.length).toBe(8) - expect(files).toContain('src/__tests__/glob-test-data/package.json') - expect(files).toContain('src/__tests__/glob-test-data/README.md') - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - expect(files).toContain('src/__tests__/glob-test-data/lib/helper.js') - expect(files).toContain('src/__tests__/glob-test-data/docs/guide.md') - }) - - test('matches specific extension with *.ts pattern', async () => { - const parameters = { - pattern: '*.ts', - cwd: 'src/__tests__/glob-test-data/src', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(2) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).not.toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - }) - - test('matches nested files with **/*.tsx pattern', async () => { - const parameters = { - pattern: '**/*.tsx', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(2) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - }) - - test('matches files in subdirectory with src/**/* pattern', async () => { - const parameters = { - pattern: 'src/**/*', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(4) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - expect(files).not.toContain('src/__tests__/glob-test-data/lib/helper.js') - }) - - test('matches markdown files with **/*.md pattern', async () => { - const parameters = { - pattern: '**/*.md', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(2) - expect(files).toContain('src/__tests__/glob-test-data/README.md') - expect(files).toContain('src/__tests__/glob-test-data/docs/guide.md') - }) - - test('matches single file with exact name', async () => { - const parameters = { - pattern: 'package.json', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(1) - expect(files).toContain('src/__tests__/glob-test-data/package.json') - }) - - test('matches no files with non-matching pattern', async () => { - const parameters = { - pattern: '*.py', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(0) - expect((result[0].value as any).message).toContain('Found 0 file(s)') - }) - - test('matches TypeScript files only with **/*.ts pattern', async () => { - const parameters = { - pattern: '**/*.ts', - cwd: 'src/__tests__/glob-test-data/src', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - // Should match only .ts files, not .tsx files - expect(files.length).toBe(2) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).not.toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).not.toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - }) - - test('matches files with brace expansion for multiple extensions', async () => { - const parameters = { - pattern: '**/*.{ts,js}', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - // Should match all .ts and .js files recursively using brace expansion - expect(files.length).toBe(3) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).toContain('src/__tests__/glob-test-data/lib/helper.js') - }) - - test('handles cwd with trailing slash', async () => { - const parameters = { - pattern: '**/*', - cwd: 'src/__tests__/glob-test-data/', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(8) - }) - - test('returns appropriate message in result', async () => { - const parameters = { - pattern: '**/*.ts', - cwd: 'src/__tests__/glob-test-data/src', - } - - const result = await handleGlob(parameters, 'test-id') - - expect((result[0].value as any).message).toContain('Found 2 file(s)') - expect((result[0].value as any).message).toContain('**/*.ts') - expect((result[0].value as any).message).toContain( - 'src/__tests__/glob-test-data/src', - ) - }) - - test('handles pattern matching in nested cwd', async () => { - const parameters = { - pattern: '*.tsx', - cwd: 'src/__tests__/glob-test-data/src/components', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(2) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - }) - - test('matches all TypeScript files recursively', async () => { - const parameters = { - pattern: '**/*.ts', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(2) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - }) - - test('matches with brace expansion pattern', async () => { - const parameters = { - pattern: '**/*.{ts,tsx}', - cwd: 'src/__tests__/glob-test-data', - } - - const result = await handleGlob(parameters, 'test-id') - const files = (result[0].value as any).files - - expect(files.length).toBe(4) - expect(files).toContain('src/__tests__/glob-test-data/src/index.ts') - expect(files).toContain('src/__tests__/glob-test-data/src/utils.ts') - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Button.tsx', - ) - expect(files).toContain( - 'src/__tests__/glob-test-data/src/components/Input.tsx', - ) - }) -}) diff --git a/npm-app/src/__tests__/image-upload.test.ts b/npm-app/src/__tests__/image-upload.test.ts deleted file mode 100644 index c51ff2b8c5..0000000000 --- a/npm-app/src/__tests__/image-upload.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { writeFileSync, mkdirSync, rmSync } from 'fs' -import path from 'path' - -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' - -import { - processImageFile, - isImageFile, - extractImagePaths, -} from '../utils/image-handler' - -const TEST_DIR = path.join(__dirname, 'temp-test-images') -const TEST_IMAGE_PATH = path.join(TEST_DIR, 'test-image.png') -const TEST_LARGE_IMAGE_PATH = path.join(TEST_DIR, 'large-image.jpg') - -// Create a minimal PNG file (43 bytes) -const MINIMAL_PNG = Buffer.from([ - 0x89, - 0x50, - 0x4e, - 0x47, - 0x0d, - 0x0a, - 0x1a, - 0x0a, // PNG signature - 0x00, - 0x00, - 0x00, - 0x0d, // IHDR chunk length - 0x49, - 0x48, - 0x44, - 0x52, // IHDR - 0x00, - 0x00, - 0x00, - 0x01, // width: 1 - 0x00, - 0x00, - 0x00, - 0x01, // height: 1 - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, // bit depth, color type, compression, filter, interlace - 0x90, - 0x77, - 0x53, - 0xde, // CRC - 0x00, - 0x00, - 0x00, - 0x00, // IEND chunk length - 0x49, - 0x45, - 0x4e, - 0x44, // IEND - 0xae, - 0x42, - 0x60, - 0x82, // CRC -]) - -beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }) - writeFileSync(TEST_IMAGE_PATH, MINIMAL_PNG) - - // Create a large fake image (10MB) - const largeBuffer = Buffer.alloc(10 * 1024 * 1024, 0xff) - // Add minimal JPEG header - largeBuffer.writeUInt16BE(0xffd8, 0) // JPEG SOI marker - largeBuffer.writeUInt16BE(0xffd9, largeBuffer.length - 2) // JPEG EOI marker - writeFileSync(TEST_LARGE_IMAGE_PATH, largeBuffer) -}) - -afterEach(() => { - try { - rmSync(TEST_DIR, { recursive: true, force: true }) - } catch { - // Ignore cleanup errors - } -}) - -describe('Image Upload Functionality', () => { - describe('isImageFile', () => { - test('should detect valid image extensions', () => { - expect(isImageFile('test.jpg')).toBe(true) - expect(isImageFile('test.jpeg')).toBe(true) - expect(isImageFile('test.png')).toBe(true) - expect(isImageFile('test.webp')).toBe(true) - expect(isImageFile('test.gif')).toBe(true) - expect(isImageFile('test.bmp')).toBe(true) - expect(isImageFile('test.tiff')).toBe(true) - }) - - test('should reject non-image extensions', () => { - expect(isImageFile('test.txt')).toBe(false) - expect(isImageFile('test.js')).toBe(false) - expect(isImageFile('test.pdf')).toBe(false) - expect(isImageFile('test')).toBe(false) - }) - }) - - describe('extractImagePaths', () => { - test('should extract image paths from text with @ syntax', () => { - const input = 'Look at this @test.png and @image.jpg files' - const paths = extractImagePaths(input) - expect(paths).toEqual(['test.png', 'image.jpg']) - }) - - test('should ignore non-image paths', () => { - const input = 'Check @script.js and @test.png' - const paths = extractImagePaths(input) - expect(paths).toEqual(['test.png']) - }) - - test('should return empty array when no image paths found', () => { - const input = 'No images here @script.js @readme.txt' - const paths = extractImagePaths(input) - expect(paths).toEqual([]) - }) - - test('should auto-detect absolute paths', () => { - const input = 'Look at /path/to/image.png and ~/screenshots/photo.jpg' - const paths = extractImagePaths(input) - expect(paths).toEqual(['/path/to/image.png', '~/screenshots/photo.jpg']) - }) - - test('should auto-detect relative paths with separators', () => { - const input = 'Check ./assets/logo.png and ../images/banner.jpg' - const paths = extractImagePaths(input) - expect(paths).toEqual(['./assets/logo.png', '../images/banner.jpg']) - }) - - test('should auto-detect quoted paths', () => { - const input = - 'Files: "./my folder/image.png" and \'../photos/vacation.jpg\'' - const paths = extractImagePaths(input) - expect(paths).toEqual(['./my folder/image.png', '../photos/vacation.jpg']) - }) - - test('should ignore paths in code blocks', () => { - const input = - 'See ```./test.png``` and `inline.jpg` but process ./real.png' - const paths = extractImagePaths(input) - expect(paths).toEqual(['./real.png']) - }) - - test('should remove trailing punctuation from auto-detected paths', () => { - const input = 'Look at /path/image.png, and ./other.jpg!' - const paths = extractImagePaths(input) - expect(paths).toEqual(['/path/image.png', './other.jpg']) - }) - - test('should deduplicate paths', () => { - const input = '@test.png and /absolute/test.png and @test.png again' - const paths = extractImagePaths(input) - expect(paths).toEqual(['test.png', '/absolute/test.png']) - }) - - test('should NOT auto-detect bare filenames without separators', () => { - const input = 'Mentioned logo.png and banner.jpg in the text' - const paths = extractImagePaths(input) - expect(paths).toEqual([]) - }) - - test('should auto-detect bare relative paths with separators', () => { - const input = 'Check assets/multi-agents.png and images/logo.jpg' - const paths = extractImagePaths(input) - expect(paths).toEqual(['assets/multi-agents.png', 'images/logo.jpg']) - }) - - test('should auto-detect Windows-style bare relative paths', () => { - const input = 'See assets\\windows\\image.png' - const paths = extractImagePaths(input) - expect(paths).toEqual(['assets\\windows\\image.png']) - }) - - test('should NOT auto-detect URLs', () => { - const input = - 'Visit https://example.com/image.png and http://site.com/photo.jpg' - const paths = extractImagePaths(input) - expect(paths).toEqual([]) - }) - - test('should handle expanded trailing punctuation', () => { - const input = - 'Files: assets/logo.png), ./images/banner.jpg], and ~/photos/pic.png>' - const paths = extractImagePaths(input) - expect(paths.sort()).toEqual( - ['./images/banner.jpg', '~/photos/pic.png', 'assets/logo.png'].sort(), - ) - }) - - test('should handle weird characters and spaces in quoted paths', () => { - const input = - 'Files: "./ConstellationFS Demo · 1.21am · 09-11.jpeg" and \'../images/café ñoño (2024).png\'' - const paths = extractImagePaths(input) - expect(paths).toEqual([ - './ConstellationFS Demo · 1.21am · 09-11.jpeg', - '../images/café ñoño (2024).png', - ]) - }) - - test('should require quotes for paths with spaces to avoid false positives', () => { - const input = - '/Users/brandonchen/Downloads/ConstellationFS Demo · 1.21am · 09-11.jpeg' - const paths = extractImagePaths(input) - // Unquoted paths with spaces are not auto-detected to avoid false positives - expect(paths).toEqual([]) - }) - - test('should detect quoted paths with spaces', () => { - const input = '"/Users/test/My Documents/screenshot file.png"' - const paths = extractImagePaths(input) - expect(paths).toEqual(['/Users/test/My Documents/screenshot file.png']) - }) - }) - - describe('processImageFile', () => { - test('should successfully process a valid image file', async () => { - const result = await processImageFile(TEST_IMAGE_PATH, TEST_DIR) - - expect(result.success).toBe(true) - expect(result.imagePart).toBeDefined() - expect(result.imagePart!.type).toBe('image') - expect(['image/jpeg', 'image/png']).toContain(result.imagePart!.mediaType) // May be compressed to JPEG - expect(result.imagePart!.filename).toBe('test-image.png') - expect(result.imagePart!.image).toMatch(/^[A-Za-z0-9+/]+=*$/) // Base64 regex - }) - - test('should reject file that does not exist', async () => { - const result = await processImageFile('nonexistent.png', TEST_DIR) - - expect(result.success).toBe(false) - expect(result.error).toContain('File not found') - }) - - test.skip('should reject files that are too large', async () => { - const result = await processImageFile(TEST_LARGE_IMAGE_PATH, TEST_DIR) - - expect(result.success).toBe(false) - expect(result.error).toContain('File too large') - }) - - test('should reject non-image files', async () => { - const textFilePath = path.join(TEST_DIR, 'test.txt') - writeFileSync(textFilePath, 'hello world') - - const result = await processImageFile(textFilePath, TEST_DIR) - - expect(result.success).toBe(false) - expect(result.error).toContain('Unsupported image format') - }) - - test('should normalize unicode escape sequences in provided paths', async () => { - const actualFilename = 'Screenshot 2025-09-29 at 4.09.19 PM.png' - const filePath = path.join(TEST_DIR, actualFilename) - writeFileSync(filePath, MINIMAL_PNG) - - const variations = [ - 'Screenshot 2025-09-29 at 4.09.19\\u{202f}PM.png', - 'Screenshot 2025-09-29 at 4.09.19\\u202fPM.png', - ] - - for (const candidate of variations) { - const result = await processImageFile(candidate, TEST_DIR) - expect(result.success).toBe(true) - expect(result.imagePart?.filename).toBe(actualFilename) - } - }) - - test('should handle shell-escaped characters in paths', async () => { - const spacedFilename = 'My Screenshot (Final).png' - const filePath = path.join(TEST_DIR, spacedFilename) - writeFileSync(filePath, MINIMAL_PNG) - - const result = await processImageFile( - 'My\\ Screenshot\\ (Final).png', - TEST_DIR, - ) - - expect(result.success).toBe(true) - expect(result.imagePart?.filename).toBe(spacedFilename) - }) - }) -}) diff --git a/npm-app/src/__tests__/markdown-renderer.test.ts b/npm-app/src/__tests__/markdown-renderer.test.ts deleted file mode 100644 index e5a363e542..0000000000 --- a/npm-app/src/__tests__/markdown-renderer.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { MarkdownStreamRenderer } from '../display/markdown-renderer' - -describe('MarkdownStreamRenderer', () => { - describe('ordered list rendering', () => { - it('should render consecutive numbered list items correctly', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `1. First item -2. Second item -3. Third item` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - // Should have sequential numbering - expect(output).toContain('1. First item') - expect(output).toContain('2. Second item') - // Note: Due to streaming behavior, third item might sometimes be numbered as 1 - // This is expected behavior in the current implementation - expect(output).toMatch(/[13]\. Third item/) - }) - - it('should handle list items separated by blank lines', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `1. First item with description - -2. Second item with description - -3. Third item with description` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - // Should maintain proper numbering despite blank lines - expect(output).toContain('1. First item') - expect(output).toContain('2. Second item') - // Third item might be numbered as 1 due to streaming - this is acceptable - expect(output).toMatch(/[13]\. Third item/) - - // Should have some proper sequential numbering (1, 2 at minimum) - expect(output).toContain('1. ') - expect(output).toContain('2. ') - }) - - it('should handle streaming list items', async () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - - // Simulate streaming input - let results1 = renderer.write('1. First item\n\n2. Second item\n\n') - - // Wait a bit to simulate real streaming - await new Promise((resolve) => setTimeout(resolve, 10)) - - let results2 = renderer.write('3. Third item\n\n4. Fourth item') - const final = renderer.end() - - const output = [...results1, ...results2, final].filter(Boolean).join('') - - // Most items should be numbered correctly (allowing for some streaming edge cases) - expect(output).toContain('1. First item') - expect(output).toMatch(/[12]\. Second item/) // Could be 1 or 2 due to streaming - expect(output).toMatch(/[123]\. Third item/) // Could be 1, 2, or 3 due to streaming - }) - - it('should handle mixed content with lists', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `Here are the features: - -1. Feature one - -2. Feature two - -And some conclusion text.` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - expect(output).toContain('Here are the features:') - expect(output).toContain('1. Feature one') - expect(output).toContain('2. Feature two') - expect(output).toContain('And some conclusion text.') - }) - - it('should handle long list items with multiple lines', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `1. First item with a very long description that spans multiple words and explains complex concepts - -2. Second item that also has detailed explanation and covers important points - -3. Third item completing the sequence` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - expect(output).toContain('1. First item with a very long description') - expect(output).toContain('2. Second item that also has detailed') - // Third item might be numbered as 1 due to streaming behavior - expect(output).toMatch(/[13]\. Third item completing/) - }) - }) - - describe('unordered list rendering', () => { - it('should render bullet points correctly', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `- First bullet -- Second bullet -- Third bullet` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - // Should use bullet character (•) instead of asterisk (*) - expect(output).toContain('• First bullet') - expect(output).toContain('• Second bullet') - expect(output).toContain('• Third bullet') - - // Should not contain asterisks for bullets - expect(output).not.toMatch(/^\s*\* /m) - }) - - it('should handle mixed bullet and asterisk syntax', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = `* Asterisk bullet -- Dash bullet -* Another asterisk` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - // All should be converted to bullet points - expect(output).toContain('• Asterisk bullet') - expect(output).toContain('• Dash bullet') - expect(output).toContain('• Another asterisk') - }) - }) - - describe('normalizeListItems function', () => { - it('should normalize separated numbered list items', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: false }) - - // Access private method for testing - const normalizeMethod = renderer['normalizeListItems'].bind(renderer) - - const input = `1. First item - -2. Second item - -3. Third item` - - const normalized = normalizeMethod(input) - - // Should remove blank lines between consecutive list items - expect(normalized).toBe(`1. First item -2. Second item -3. Third item`) - }) - - it('should preserve blank lines before non-list content', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: false }) - const normalizeMethod = renderer['normalizeListItems'].bind(renderer) - - const input = `1. First item - -2. Second item - -Some other content` - - const normalized = normalizeMethod(input) - - // Should normalize list but preserve blank line before other content - expect(normalized).toContain( - '1. First item\n2. Second item\n\nSome other content', - ) - }) - - it('should handle non-list content correctly', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: false }) - const normalizeMethod = renderer['normalizeListItems'].bind(renderer) - - const input = `Regular paragraph - -Another paragraph - -Not a list at all` - - const normalized = normalizeMethod(input) - - // Should leave non-list content unchanged - expect(normalized).toBe(input) - }) - }) - - describe('edge cases', () => { - it('should handle empty input', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - - const results = renderer.write('') - const final = renderer.end() - - expect(results).toEqual([]) - expect(final).toBeNull() - }) - - it('should handle single list item', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: true }) - const markdown = '1. Only item' - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - expect(output).toContain('1. Only item') - }) - - it('should handle non-TTY mode', () => { - const renderer = new MarkdownStreamRenderer({ isTTY: false }) - const markdown = `1. First item - -2. Second item` - - const results = renderer.write(markdown) - const final = renderer.end() - const output = [...results, final].filter(Boolean).join('') - - // In non-TTY mode, should return raw markdown - expect(output).toBe(markdown) - }) - }) -}) diff --git a/npm-app/src/__tests__/tool-handlers.test.ts b/npm-app/src/__tests__/tool-handlers.test.ts deleted file mode 100644 index 99833f638d..0000000000 --- a/npm-app/src/__tests__/tool-handlers.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from 'bun:test' - -import { handleCodeSearch } from '../tool-handlers' - -describe('handleCodeSearch', () => { - const testDataDir = path.resolve(__dirname, 'data') - // Mock getProjectRoot to point to npm-app directory - const mockGetProjectRoot = mock(() => { - const projectRoot = path.resolve(__dirname, '../../') - return projectRoot - }) - - beforeAll(async () => { - await mockModule('@codebuff/npm-app/project-files', () => ({ - getProjectRoot: mockGetProjectRoot, - })) - }) - - beforeEach(async () => { - const projectRoot = path.resolve(__dirname, '../../') - mockGetProjectRoot.mockReturnValue(projectRoot) - console.log('Setting mock project root to:', projectRoot) - console.log('testDataDir', testDataDir) - - // Create test data directory and files - await fs.promises.mkdir(testDataDir, { recursive: true }) - - // Create test files with specific content - await fs.promises.writeFile( - path.join(testDataDir, 'test-content.js'), - `// Test file for code search -export function testFunction() { - console.log('UNIQUE_SEARCH_STRING_12345'); - return 'findme_xyz789'; -} - -export const FINDME_XYZ789 = 'uppercase version'; -`, - ) - - await fs.promises.writeFile( - path.join(testDataDir, 'another-file.ts'), - `// Another test file -export interface TestInterface { - UNIQUE_SEARCH_STRING_12345: string; -} -`, - ) - }) - - afterEach(async () => { - // Clean up test data directory - try { - await fs.promises.rm(testDataDir, { recursive: true, force: true }) - } catch (error) { - // Ignore cleanup errors - } - }) - - afterAll(() => { - clearMockedModules() - }) - - test('calls getProjectRoot and handles execution', async () => { - const parameters = { - pattern: 'testFunction', - cwd: '__tests__/data', - maxResults: 30, - } - - await handleCodeSearch(parameters, 'test-id') - - expect(mockGetProjectRoot).toHaveBeenCalled() - }) - - test('handles basic search without cwd', async () => { - const parameters = { - pattern: 'export', - maxResults: 30, - } - - const result = await handleCodeSearch(parameters, 'test-id') - - expect(result[0].value).toHaveProperty('message') - }) - - test('finds specific content in test file', async () => { - const parameters = { - pattern: 'UNIQUE_SEARCH_STRING_12345', - cwd: 'src/__tests__/data', - maxResults: 30, - } - - const result = await handleCodeSearch(parameters, 'test-id') - - expect(mockGetProjectRoot).toHaveBeenCalled() - expect((result[0].value as any).stdout).toContain( - 'UNIQUE_SEARCH_STRING_12345', - ) - expect((result[0].value as any).stdout).toContain('test-content.js') - }) - - test('searches with case-insensitive flag', async () => { - const parameters = { - pattern: 'findme_xyz789', - flags: '-i', - cwd: 'src/__tests__/data', - maxResults: 30, - } - - const result = await handleCodeSearch(parameters, 'test-id') - - expect((result[0].value as any).stdout).toContain('findme_xyz789') - }) - - test('limits results when maxResults is specified', async () => { - const parameters = { - pattern: 'export', - maxResults: 1, - cwd: 'src/__tests__/data', - } - - const result = await handleCodeSearch(parameters, 'test-id') - - // Should contain results limited message when there are more results than maxResults - const stdout = (result[0].value as any).stdout - if (stdout.includes('Results limited to')) { - expect(stdout).toContain('Results limited to') - } - }) - - test('uses default limit of 15 per file when maxResults not specified', async () => { - // Create a file with many lines matching the pattern - const manyLinesContent = Array.from( - { length: 30 }, - (_, i) => `export const TEST_VAR_${i} = 'value${i}';`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, 'many-matches.ts'), - manyLinesContent, - ) - - // Explicitly not passing maxResults to test default - const parameters: any = { - pattern: 'TEST_VAR_', - cwd: 'src/__tests__/data', - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Should limit to 15 results per file by default - const lines = stdout - .split('\n') - .filter((line: string) => line.includes('TEST_VAR_')) - expect(lines.length).toBeLessThanOrEqual(15) - expect(stdout).toContain('Results limited to 15 per file') - }) - - test('applies per-file limit correctly across multiple files', async () => { - // Create multiple files with many matches each - for (let fileNum = 1; fileNum <= 3; fileNum++) { - const content = Array.from( - { length: 20 }, - (_, i) => `export const VAR_F${fileNum}_${i} = 'value';`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, `file${fileNum}.ts`), - content, - ) - } - - const parameters = { - pattern: 'VAR_F', - cwd: 'src/__tests__/data', - maxResults: 10, - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Each file should be limited to 10 results - expect(stdout).toContain('Results limited to 10 per file') - - // Count actual result lines (not truncation messages) - // Split by the truncation message section to only count actual results - const resultsSection = stdout.split('[Results limited to')[0] - const file1Matches = (resultsSection.match(/file1\.ts:/g) || []).length - const file2Matches = (resultsSection.match(/file2\.ts:/g) || []).length - const file3Matches = (resultsSection.match(/file3\.ts:/g) || []).length - - // Each file should have at most 10 result lines - expect(file1Matches).toBeLessThanOrEqual(10) - expect(file2Matches).toBeLessThanOrEqual(10) - expect(file3Matches).toBeLessThanOrEqual(10) - }) - - test('respects global limit of 250 results', async () => { - // Create many files with multiple matches to exceed global limit - for (let fileNum = 1; fileNum <= 30; fileNum++) { - const content = Array.from( - { length: 15 }, - (_, i) => `export const GLOBAL_VAR_${fileNum}_${i} = 'value';`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, `global-test-${fileNum}.ts`), - content, - ) - } - - // Using default maxResults of 15 - const parameters: any = { - pattern: 'GLOBAL_VAR_', - cwd: 'src/__tests__/data', - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Count total result lines - const totalMatches = (stdout.match(/GLOBAL_VAR_/g) || []).length - - // Should not exceed 250 results - expect(totalMatches).toBeLessThanOrEqual(250) - - // Should mention global limit if reached - if (totalMatches === 250) { - expect(stdout).toContain('Global limit of 250 results reached') - } - }) - - test('shows correct truncation message with per-file limits', async () => { - // Create a file with many matches - const content = Array.from( - { length: 25 }, - (_, i) => `const TRUNC_VAR_${i} = 'value${i}';`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, 'truncate-test.ts'), - content, - ) - - const parameters = { - pattern: 'TRUNC_VAR_', - cwd: 'src/__tests__/data', - maxResults: 10, - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Should show which file was truncated - expect(stdout).toContain('Results limited to 10 per file') - expect(stdout).toContain('truncate-test.ts') - expect(stdout).toMatch(/25 results \(showing 10\)/) - }) - - test('handles global limit with skipped files message', async () => { - // Create enough files to trigger global limit - for (let fileNum = 1; fileNum <= 25; fileNum++) { - const content = Array.from( - { length: 12 }, - (_, i) => `const SKIP_VAR_${fileNum}_${i} = ${i};`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, `skip-test-${fileNum}.ts`), - content, - ) - } - - // Using default maxResults of 15 - const parameters: any = { - pattern: 'SKIP_VAR_', - cwd: 'src/__tests__/data', - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Should show skipped files message - const totalMatches = (stdout.match(/SKIP_VAR_/g) || []).length - - if (totalMatches >= 250) { - expect(stdout).toContain('Global limit of 250 results reached') - expect(stdout).toMatch(/\d+ file\(s\) skipped/) - } - }) - - test('applies remaining global space correctly', async () => { - // Create files where global limit is hit mid-file - for (let fileNum = 1; fileNum <= 20; fileNum++) { - const content = Array.from( - { length: 15 }, - (_, i) => `const SPACE_VAR_${fileNum}_${i} = ${i};`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, `space-test-${fileNum}.ts`), - content, - ) - } - - const parameters = { - pattern: 'SPACE_VAR_', - cwd: 'src/__tests__/data', - maxResults: 15, - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Count total matches - should not exceed 250 - const totalMatches = (stdout.match(/SPACE_VAR_/g) || []).length - expect(totalMatches).toBeLessThanOrEqual(250) - }) - - test('handles case when no results exceed limits', async () => { - // Create files with few matches - await fs.promises.writeFile( - path.join(testDataDir, 'small-file.ts'), - 'const SMALL_VAR = 1;\nconst SMALL_VAR_2 = 2;', - ) - - const parameters = { - pattern: 'SMALL_VAR', - cwd: 'src/__tests__/data', - maxResults: 15, - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Should not contain truncation messages - expect(stdout).not.toContain('Results limited to') - expect(stdout).not.toContain('Global limit') - }) - - test('combines per-file and global limit messages correctly', async () => { - // Create scenario where both limits are triggered - for (let fileNum = 1; fileNum <= 22; fileNum++) { - const content = Array.from( - { length: 20 }, - (_, i) => `const COMBINED_VAR_${fileNum}_${i} = ${i};`, - ).join('\n') - - await fs.promises.writeFile( - path.join(testDataDir, `combined-test-${fileNum}.ts`), - content, - ) - } - - const parameters = { - pattern: 'COMBINED_VAR_', - cwd: 'src/__tests__/data', - maxResults: 12, - } - - const result = await handleCodeSearch(parameters, 'test-id') - const stdout = (result[0].value as any).stdout - - // Should contain both messages - const totalMatches = (stdout.match(/COMBINED_VAR_/g) || []).length - - if (totalMatches >= 250) { - expect(stdout).toContain('Results limited to 12 per file') - expect(stdout).toContain('Global limit of 250 results reached') - } - }) - - test('handles glob pattern flags correctly without regex parse errors', async () => { - // Create test files with different extensions - await fs.promises.writeFile( - path.join(testDataDir, 'typescript-file.ts'), - `export const GLOB_TEST_TS = 'typescript file';`, - ) - - await fs.promises.writeFile( - path.join(testDataDir, 'javascript-file.js'), - `export const GLOB_TEST_JS = 'javascript file';`, - ) - - await fs.promises.writeFile( - path.join(testDataDir, 'text-file.txt'), - `GLOB_TEST_TXT in text file`, - ) - - // Search with glob flags to only match .ts and .tsx files - const parameters = { - pattern: 'GLOB_TEST', - flags: '-g *.ts -g *.tsx', - cwd: 'src/__tests__/data', - maxResults: 30, - } - - const result = await handleCodeSearch(parameters, 'test-id') - - // Should not have a stderr with regex parse error - expect((result[0].value as any).stderr).toBeUndefined() - - const stdout = (result[0].value as any).stdout - - // Should find the .ts file - expect(stdout).toContain('typescript-file.ts') - expect(stdout).toContain('GLOB_TEST_TS') - - // Should not find the .js or .txt files - expect(stdout).not.toContain('javascript-file.js') - expect(stdout).not.toContain('text-file.txt') - }) -}) diff --git a/npm-app/src/__tests__/validate-agent-passthrough.test.ts b/npm-app/src/__tests__/validate-agent-passthrough.test.ts deleted file mode 100644 index 6b5ba44a2f..0000000000 --- a/npm-app/src/__tests__/validate-agent-passthrough.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, - spyOn, - mock, -} from 'bun:test' - -import { validateAgent } from '../cli' -import * as SpinnerMod from '../utils/spinner' - -describe('validateAgent agent pass-through', () => { - let fetchSpy: ReturnType - let spinnerSpy: ReturnType - - beforeEach(() => { - fetchSpy = spyOn(globalThis as any, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ valid: true }), - } as any) - - spinnerSpy = spyOn(SpinnerMod.Spinner, 'get').mockReturnValue({ - start: () => {}, - stop: () => {}, - } as any) - }) - - afterEach(() => { - mock.restore() - }) - - it('passes published agent id unchanged to backend (publisher/name@version)', async () => { - const agent = 'codebuff/file-explorer@0.0.1' - await validateAgent(agent, {}) - - expect(fetchSpy).toHaveBeenCalled() - const url = (fetchSpy.mock.calls[0] as any[])[0] as string - const u = new URL(url) - expect(u.searchParams.get('agentId')).toBe(agent) - }) - - it('short-circuits when agent is found locally (by id)', async () => { - const agent = 'codebuff/file-explorer@0.0.1' - fetchSpy.mockClear() - - await validateAgent(agent, { - [agent]: { displayName: 'File Explorer' }, - }) - - expect(fetchSpy).not.toHaveBeenCalled() - }) -}) diff --git a/npm-app/src/agents/agent-utils.ts b/npm-app/src/agents/agent-utils.ts deleted file mode 100644 index decbd25c14..0000000000 --- a/npm-app/src/agents/agent-utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import * as os from 'os' - -import { getProjectRoot } from '../project-files' - -const agentTemplatesSubdir = ['.agents'] as const - -/** - * Get all TypeScript files recursively from a directory - */ -export function getAllTsFiles(dir: string): string[] { - const files: string[] = [] - - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - - if (entry.isDirectory()) { - // Recursively scan subdirectories - files.push(...getAllTsFiles(fullPath)) - } else if ( - entry.isFile() && - entry.name.endsWith('.ts') && - !entry.name.endsWith('.d.ts') && - !entry.name.endsWith('.test.ts') - ) { - files.push(fullPath) - } - } - } catch (error) { - // Ignore errors reading directories - } - - return files -} - -/** - * Get the agents directory path - */ -export function getAgentsDirectory(): string { - return path.join(getProjectRoot(), ...agentTemplatesSubdir) -} - -/** - * Get the user's home agents directory path - */ -export function getUserAgentsDirectory(): string { - return path.join(os.homedir(), ...agentTemplatesSubdir) -} diff --git a/npm-app/src/agents/load-agents.ts b/npm-app/src/agents/load-agents.ts deleted file mode 100644 index aee192dc0f..0000000000 --- a/npm-app/src/agents/load-agents.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as fs from 'fs' - -import { cyan, green } from 'picocolors' - -import { - getAllTsFiles, - getAgentsDirectory, - getUserAgentsDirectory, -} from './agent-utils' - -import type { CodebuffConfig } from '@codebuff/common/json-config/constants' - -export let loadedAgents: Record = {} -export async function loadLocalAgents({ - agentsPath, - verbose = false, -}: { - agentsPath?: string - verbose?: boolean -}): Promise { - loadedAgents = {} - - // Collect agents from both directories - const agentsDirs = agentsPath - ? [agentsPath] - : [getAgentsDirectory(), getUserAgentsDirectory()] - - const allTsFiles: string[] = [] - for (const dir of agentsDirs) { - if (fs.existsSync(dir)) { - allTsFiles.push(...getAllTsFiles(dir)) - } - } - - if (allTsFiles.length === 0) { - return loadedAgents - } - - try { - for (const fullPath of allTsFiles) { - let agentDefinition: any - let agentModule: any - try { - agentModule = await require(fullPath) - } catch (error: any) { - if (verbose) { - console.error( - `Error importing agent: ${error.name} - ${error.message}\n${error.stack}`, - fullPath, - ) - } - continue - } - delete require.cache[fullPath] - - try { - agentDefinition = agentModule.default - } catch (error: any) { - const errorMessage = - error instanceof Error - ? error.stack || error.message - : typeof error === 'string' - ? error - : JSON.stringify(error) - console.error('Error loading agent from file:', fullPath, errorMessage) - continue - } - - if (!agentDefinition) continue - - // Validate that agent has required attributes - if (!agentDefinition.id || !agentDefinition.model) { - if (verbose) { - console.error( - 'Agent definition missing required attributes (id, model):', - fullPath, - 'Found:', - { id: agentDefinition.id, model: agentDefinition.model }, - ) - } - continue - } - - // Convert handleSteps function to string if present - let processedAgentDefinition = { ...agentDefinition } - - if (agentDefinition.handleSteps) { - processedAgentDefinition.handleSteps = - agentDefinition.handleSteps.toString() - } - - loadedAgents[processedAgentDefinition.id] = processedAgentDefinition - } - } catch (error) {} - return loadedAgents -} - -export function getLoadedAgentNames(): Record { - return Object.fromEntries( - Object.entries(loadedAgents).map(([agentType, agentConfig]) => { - return [agentType, agentConfig.displayName] - }), - ) -} - -/** - * Display loaded agents to the user - */ -export function displayLoadedAgents(codebuffConfig: CodebuffConfig) { - const baseAgent = codebuffConfig.baseAgent - if (baseAgent) { - console.log(`\n${green('Configured base agent:')} ${cyan(baseAgent)}`) - } - - const spawnableAgents = codebuffConfig.spawnableAgents - if (spawnableAgents) { - console.log( - `${green('Configured spawnable agents:')} ${spawnableAgents - .map((name) => cyan(name)) - .join(', ')}\n`, - ) - } else if (Object.keys(loadedAgents).length > 0) { - const loadedAgentNames = Object.values(getLoadedAgentNames()) - // Calculate terminal width and format agents in columns - const terminalWidth = process.stdout.columns || 80 - const columnWidth = - Math.max(...loadedAgentNames.map((name) => name.length)) + 2 // Column width based on longest name + padding - const columnsPerRow = Math.max(1, Math.floor(terminalWidth / columnWidth)) - - const formattedLines: string[] = [] - for (let i = 0; i < loadedAgentNames.length; i += columnsPerRow) { - const rowAgents = loadedAgentNames.slice(i, i + columnsPerRow) - const formattedRow = rowAgents - .map((name) => cyan(name.padEnd(columnWidth))) - .join('') - formattedLines.push(formattedRow) - } - - console.log( - `\n${green('Found custom agents:')}\n${formattedLines.join('\n')}\n`, - ) - } else if (baseAgent) { - // One more new line. - console.log() - } -} diff --git a/npm-app/src/background-process-manager.ts b/npm-app/src/background-process-manager.ts deleted file mode 100644 index 7d48f918ee..0000000000 --- a/npm-app/src/background-process-manager.ts +++ /dev/null @@ -1,507 +0,0 @@ -import assert from 'assert' -import { spawn } from 'child_process' -import { - mkdirSync, - readdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'fs' -import path from 'path' -import process from 'process' - -import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { jsonToolResult } from '@codebuff/common/util/messages' -import { truncateStringWithMessage } from '@codebuff/common/util/string' -import { gray, red } from 'picocolors' -import { z } from 'zod/v4' - -import { CONFIG_DIR } from './credentials' -import { logger } from './utils/logger' - -import type { JSONObject } from '@codebuff/common/types/json' -import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message' -import type { - ChildProcessByStdio, - ChildProcessWithoutNullStreams, - SpawnOptionsWithoutStdio, -} from 'child_process' - -const COMMAND_OUTPUT_LIMIT = 5000 // Limit output to 10KB per stream -const COMMAND_KILL_TIMEOUT_MS = 5000 -const POLLING_INTERVAL_MS = 200 - -const LOCK_DIR = path.join(CONFIG_DIR, 'background_processes') - -/** - * Interface describing the information stored for each background process. - */ -export interface BackgroundProcessInfo { - // OS-assigned Process ID - pid: number - toolCallId: string - command: string - // The actual child process object - process: ChildProcessByStdio - // Buffer to store stdout chunks - stdoutBuffer: string[] - // Buffer to store stderr chunks - stderrBuffer: string[] - // Current status of the process - status: 'running' | 'completed' | 'error' - // Timestamp when the process was started - startTime: number - // Timestamp when the process ended (completed or errored) - endTime: number | null - // Length of stdout content that has been reported - lastReportedStdoutLength: number - // Length of stderr content that has been reported - lastReportedStderrLength: number - // Last reported status - lastReportedStatus: 'running' | 'completed' | 'error' | null - // Path to file where stdout is being written (if specified) - stdoutFile?: string - // Path to file where stderr is being written (if specified) - stderrFile?: string -} - -/** - * Global map storing information about active and completed background processes. - * Keyed by the OS-assigned Process ID (PID). - */ -export const backgroundProcesses = new Map() - -/** - * Gets output with context about whether there was previous content - */ -function getOutputWithContext( - newContent: string, - lastReportedLength: number, -): string { - if (newContent) { - const hasOldContent = lastReportedLength > 0 - return hasOldContent ? '[PREVIOUS OUTPUT]\n' + newContent : newContent - } - return '[NO NEW OUTPUT]' -} - -/** - * Formats a single background process's info into a string - */ -export function getBackgroundProcessUpdate(info: BackgroundProcessInfo) { - const previousStdoutLength = info.lastReportedStdoutLength - const newStdout = info.stdoutBuffer - .join('') - .slice(info.lastReportedStdoutLength) - info.lastReportedStdoutLength += newStdout.length - const previousStderrLength = info.lastReportedStderrLength - const newStderr = info.stderrBuffer - .join('') - .slice(info.lastReportedStderrLength) - info.lastReportedStderrLength += newStderr.length - - // Only report finished processes if there are changes - const newStatus = info.status - if ( - newStatus !== 'running' && - !newStdout && - !newStderr && - newStatus === info.lastReportedStatus - ) { - return null - } - info.lastReportedStatus = newStatus - - // Calculate duration in milliseconds - const duration = info.endTime - ? info.endTime - info.startTime - : Date.now() - info.startTime - - return { - command: info.command, - processId: info.pid, - startTimeUtc: new Date(info.startTime).toISOString(), - durationMs: duration, - ...(newStdout - ? { - stdout: truncateStringWithMessage({ - str: getOutputWithContext(newStdout, previousStdoutLength), - maxLength: COMMAND_OUTPUT_LIMIT, - remove: 'START', - }), - } - : {}), - ...(newStderr - ? { - stderr: truncateStringWithMessage({ - str: getOutputWithContext(newStderr, previousStderrLength), - maxLength: COMMAND_OUTPUT_LIMIT, - remove: 'START', - }), - } - : {}), - backgroundProcessStatus: newStatus, - ...(info.process.exitCode !== null - ? { exitCode: info.process.exitCode } - : {}), - ...(info.process.signalCode ? { signalCode: info.process.signalCode } : {}), - } -} - -/** - * Gets updates from all background processes and updates tracking info - */ -export function getBackgroundProcessUpdates(): ToolMessage[] { - const updates = Array.from(backgroundProcesses.values()) - .map((bgProcess) => { - return [ - getBackgroundProcessUpdate(bgProcess), - bgProcess.toolCallId, - ] satisfies [JSONObject | null, string] - }) - .filter( - ( - update, - ): update is [NonNullable<(typeof update)[0]>, (typeof update)[1]] => - Boolean(update[0]), - ) - - // Update tracking info after getting updates - for (const process of backgroundProcesses.values()) { - process.lastReportedStdoutLength = process.stdoutBuffer.join('').length - process.lastReportedStderrLength = process.stderrBuffer.join('').length - process.lastReportedStatus = process.status - } - - // Clean up completed processes that we've already reported - cleanupReportedProcesses() - - return updates.map(([update, toolCallId]) => { - return { - role: 'tool', - toolCallId, - toolName: 'background_process_update', - content: jsonToolResult(update), - } satisfies ToolMessage - }) -} - -function deleteFileIfExists(fileName: string) { - try { - unlinkSync(fileName) - } catch {} -} - -const zodMaybeNumber = z.preprocess((val) => { - const n = Number(val) - return typeof val === 'undefined' || isNaN(n) ? undefined : n -}, z.number().optional()) - -const lockFileSchema = z.object({ - parentPid: zodMaybeNumber, -}) - -type LockFileSchema = z.infer - -/** - * Creates a lock file for a background process with the current process's PID. - * This allows tracking parent-child process relationships for cleanup. - * - * @param filePath - Path where the lock file should be created - */ -function createLockFile(filePath: string): void { - const data: LockFileSchema = { - parentPid: process.pid, - } - - writeFileSync(filePath, JSON.stringify(data, null, 1)) -} - -/** - * Checks if a process with the given PID is still running. - * - * @param pid - Process ID to check - * @returns true if the process is running, false otherwise - */ -function isRunning(pid: number) { - try { - process.kill(pid, 0) - } catch (error: any) { - if (error.code === 'ESRCH') { - return false - } - } - - return true -} - -/** - * Determines whether the process associated with a given PID should be - * terminated, based on the lock file contents stored for that PID. - * - * If the parent process is no longer active or the file is invalid, the - * function assumes the process is orphaned and should be killed. - * - * @param lockFile - The path of the lock file. - * @returns `true` if the process should be killed (e.g. parent no longer exists or file is invalid), - * `false` if the parent process is still alive and the process should be kept running. - */ -function shouldKillProcessUsingLock(lockFile: string): boolean { - const fileContents = String(readFileSync(lockFile)) - - let data: LockFileSchema - try { - data = lockFileSchema.parse(JSON.parse(fileContents)) - } catch (error) { - data = { - parentPid: undefined, - } - } - - if (data.parentPid && isRunning(data.parentPid)) { - return false - } - - return true -} - -export function spawnAndTrack( - command: string, - args: string[] = [], - options: SpawnOptionsWithoutStdio, -): ChildProcessWithoutNullStreams { - const child = spawn(command, args, { - ...options, - detached: true, - }) - assert(child.pid !== undefined) - logger.info( - { - eventId: AnalyticsEvent.BACKGROUND_PROCESS_START, - pid: child.pid, - }, - `Process start: \`${command} ${args.join(' ')}\``, - ) - - mkdirSync(LOCK_DIR, { recursive: true }) - const filePath = path.join(LOCK_DIR, `${child.pid}`) - createLockFile(filePath) - - child.on('exit', () => { - deleteFileIfExists(filePath) - logger.info( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_END, pid: child.pid }, - `Graceful exit: \`${command} ${args.join(' ')}\``, - ) - }) - return child -} - -/** - * Removes completed processes that have been fully reported - */ -function cleanupReportedProcesses(): void { - for (const [pid, info] of backgroundProcesses.entries()) { - if ( - (info.status === 'completed' || info.status === 'error') && - info.lastReportedStatus === info.status && - info.lastReportedStdoutLength === info.stdoutBuffer.join('').length && - info.lastReportedStderrLength === info.stderrBuffer.join('').length - ) { - backgroundProcesses.delete(pid) - } - } -} - -function waitForProcessExit(pid: number): Promise { - return new Promise((resolve) => { - let resolved = false - - const interval = setInterval(() => { - if (!isRunning(pid)) { - clearInterval(interval) - clearTimeout(timeout) - resolved = true - resolve(true) - } - }, POLLING_INTERVAL_MS) - - const timeout = setTimeout(() => { - if (!resolved) { - clearInterval(interval) - resolve(false) - } - }, COMMAND_KILL_TIMEOUT_MS) - }) -} - -function killProcessTreeSoftly(pid: number): void { - if (process.platform === 'win32') { - // /T = kill tree, no /F = soft kill - spawn('taskkill', ['/PID', String(pid), '/T'], { - stdio: 'ignore', - detached: true, - }).unref() - } else { - try { - process.kill(-pid, 'SIGTERM') - } catch (err) { - if ((err as any)?.code !== 'ESRCH') throw err - } - } -} - -async function killAndWait(pid: number): Promise { - try { - killProcessTreeSoftly(pid) - if (await waitForProcessExit(pid)) { - return - } - } catch (error: any) { - if (error.code === 'ESRCH') { - return - } else { - throw error - } - } - throw new Error(`Unable to kill process ${pid}`) -} - -// Only to be run on exit -export function sendKillSignalToAllBackgroundProcesses(): void { - for (const [pid, p] of backgroundProcesses.entries()) { - if (p.status !== 'running') { - continue - } - - try { - killProcessTreeSoftly(pid) - } catch {} - } -} - -export async function killAllBackgroundProcesses(): Promise { - const killPromises = Array.from(backgroundProcesses.entries()) - .filter(([, p]) => p.status === 'running') - .map(async ([pid, processInfo]) => { - try { - await killAndWait(pid) - // console.log(gray(`Killed process: \`${processInfo.command}\``)) - } catch (error: any) { - console.error( - red( - `Failed to kill: \`${processInfo.command}\` (pid ${pid}): ${error?.message || error}`, - ), - ) - logger.error( - { - errorMessage: error?.message || String(error), - pid, - command: processInfo.command, - }, - 'Failed to kill process', - ) - } - }) - - await Promise.all(killPromises) - backgroundProcesses.clear() -} - -/** - * Cleans up stale lock files and attempts to kill orphaned processes found in the lock directory. - * This function is intended to run on startup to handle cases where the application might have - * exited uncleanly, leaving orphaned processes or lock files. - * - * @returns Object containing: - * - shouldStartNewProcesses: boolean indicating if it's safe to start new processes - * - cleanUpPromise: Promise that resolves when cleanup is complete - */ -export function cleanupStoredProcesses(): { - separateCodebuffInstanceRunning: boolean - cleanUpPromise: Promise -} { - // Determine which processes to kill (sync) - let separateCodebuffInstanceRunning = false - const locksToProcess: string[] = [] - try { - mkdirSync(LOCK_DIR, { recursive: true }) - const files = readdirSync(LOCK_DIR) - - for (const file of files) { - const lockFile = path.join(LOCK_DIR, file) - if (shouldKillProcessUsingLock(lockFile)) { - locksToProcess.push(file) - } else { - separateCodebuffInstanceRunning = true - } - } - } catch {} - - if (locksToProcess.length) { - console.log(gray('Detected running codebuff processes. Cleaning...\n')) - logger.info({ - eventId: AnalyticsEvent.BACKGROUND_PROCESS_LEFTOVER_DETECTED, - pids: locksToProcess, - }) - } - - // Actually kill processes (async) - const processLockFile = async (pidName: string) => { - const lockFile = path.join(LOCK_DIR, pidName) - - const pid = parseInt(pidName, 10) - if (isNaN(pid)) { - deleteFileIfExists(lockFile) - logger.info( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_END, pid }, - 'Lock found but process not running.', - ) - return - } - - if (backgroundProcesses.has(pid)) { - logger.error( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_END, pid }, - 'Process running in current session. Should not occur.', - ) - return - } - - try { - killProcessTreeSoftly(pid) - if (await waitForProcessExit(pid)) { - deleteFileIfExists(lockFile) - logger.info( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_END, pid }, - 'Process successfully killed.', - ) - } else { - logger.warn( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_CONTINUE, pid }, - 'Process unable to be killed. Leaving lock file.', - ) - } - } catch (err: any) { - if (err.code === 'ESRCH') { - deleteFileIfExists(lockFile) - logger.info( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_END, pid }, - 'Leftover process (with lock) died naturally.', - ) - } else { - logger.error( - { eventId: AnalyticsEvent.BACKGROUND_PROCESS_CONTINUE, err, pid }, - 'Error killing process', - ) - } - } - } - - const cleanUpPromise = Promise.all(locksToProcess.map(processLockFile)) - - return { - separateCodebuffInstanceRunning, - cleanUpPromise, - } -} diff --git a/npm-app/src/browser-runner.ts b/npm-app/src/browser-runner.ts deleted file mode 100644 index 5f97fcde65..0000000000 --- a/npm-app/src/browser-runner.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { BROWSER_DEFAULTS } from '@codebuff/common/browser-actions' -import { sleep } from '@codebuff/common/util/promise' -import { ensureUrlProtocol } from '@codebuff/common/util/string' -import puppeteer from 'puppeteer-core' - -import { logger } from './utils/logger' - -import type { - BrowserAction, - BrowserConfig, - BrowserResponse, -} from '@codebuff/common/browser-actions' -import type { Browser, HTTPRequest, HTTPResponse, Page } from 'puppeteer-core' - -type NonOptional = T & { [P in K]-?: T[P] } - -export class BrowserRunner { - // Add getter methods for diagnostic loop - getLogs(): BrowserResponse['logs'] { - return this.logs - } - - getNetworkEvents(): BrowserResponse['networkEvents'] { - return this.networkEvents - } - private browser: Browser | null = null - private page: Page | null = null - private logs: BrowserResponse['logs'] = [] - private jsErrorCount = 0 - private retryCount = 0 - private startTime: number = 0 - - // Error tracking - private consecutiveErrors = 0 - private totalErrors = 0 - - constructor() {} - - // Error tracking configuration - private maxConsecutiveErrors = 3 - private totalErrorThreshold = 10 - private performanceMetrics: { - ttfb?: number - lcp?: number - fcp?: number - domContentLoaded?: number - } = {} - private networkEvents: Array<{ - url: string - method: string - status?: number - errorText?: string - timestamp: number - }> = [] - - private async executeWithRetry( - action: BrowserAction, - ): Promise { - const retryOptions = action.retryOptions ?? BROWSER_DEFAULTS.retryOptions - let lastError: Error | null = null - - for ( - let attempt = 0; - attempt <= (retryOptions.maxRetries ?? 3); - attempt++ - ) { - try { - const result = await this.executeAction(action) - // Reset consecutive errors on success - this.consecutiveErrors = 0 - return result - } catch (error: any) { - // Track errors - this.consecutiveErrors++ - this.totalErrors++ - - // Log error analysis - this.logErrorForAnalysis(error) - - // Check error thresholds - if (this.consecutiveErrors >= this.maxConsecutiveErrors) { - const msg = `Max consecutive errors reached (${this.maxConsecutiveErrors}).` - this.logs.push({ - type: 'error', - message: msg, - timestamp: Date.now(), - source: 'tool', - }) - await this.shutdown() - return { - success: false, - error: msg, - logs: this.logs, - } - } - - if (this.totalErrors >= this.totalErrorThreshold) { - const msg = `Total error threshold reached (${this.totalErrorThreshold}).` - this.logs.push({ - type: 'error', - message: msg, - timestamp: Date.now(), - source: 'tool', - }) - await this.shutdown() - return { - success: false, - error: msg, - logs: this.logs, - } - } - lastError = error - const shouldRetry = retryOptions.retryOnErrors?.includes(error.name) - if (!shouldRetry || attempt === retryOptions.maxRetries) { - throw error - } - await new Promise((resolve) => - setTimeout(resolve, retryOptions.retryDelay ?? 1000), - ) - this.logs.push({ - type: 'info', - message: `Retrying action (attempt ${attempt + 1}/${retryOptions.maxRetries})`, - timestamp: Date.now(), - category: 'retry', - source: 'tool', - }) - } - } - throw lastError - } - - private async executeAction(action: BrowserAction): Promise { - try { - // Only take pre-action screenshot if browser is already running - let preActionResult = null - if (this.browser && this.page) { - // preActionResult = await this.takeScreenshot( - // { - // type: 'screenshot', - // }, - // this.page - // ) - } - - let response: BrowserResponse - switch (action.type) { - case 'start': - await this.getBrowser(action) - if (!action.url) { - break - } - case 'navigate': - response = await this.navigate({ ...action, type: 'navigate' }) - break - case 'click': - console.log('Clicking has not been implemented yet') - break - case 'type': - await this.typeText(action) - break - case 'scroll': - await this.scroll(action) - break - case 'screenshot': - break - case 'stop': - await this.shutdown() - return { - success: true, - logs: this.logs, - metrics: await this.collectMetrics(), - } - default: - throw new Error( - `Unknown action type: ${(action as BrowserAction).type}`, - ) - } - - // Take post-action screenshot - let postActionResult = null - if (this.page) { - // postActionResult = await this.takeScreenshot( - // { - // type: 'screenshot', - // }, - // this.page - // ) - } - - const metrics = await this.collectMetrics() - response = { - success: true, - logs: this.logs, - metrics, - // ...(postActionResult && { - // screenshots: { - // ...(preActionResult && { - // pre: { - // type: 'image', - // source: { - // type: 'base64', - // media_type: 'image/jpeg', - // data: preActionResult.data, - // }, - // }, - // }), - // post: { - // type: 'image', - // source: { - // type: 'base64', - // media_type: 'image/jpeg', - // data: postActionResult.data, - // }, - // }, - // }, - // }), - } - - return response - } catch (err: any) { - await this.shutdown() - return { - success: false, - error: err?.message ?? String(err), - logs: this.logs, - } - } - } - - private logErrorForAnalysis(error: Error) { - // Add helpful hints based on error patterns - const errorPatterns: Record = { - 'not defined': - 'Check for missing script dependencies or undefined variables', - 'Failed to fetch': 'Verify endpoint URLs and network connectivity', - '404': 'Resource not found - verify URLs and paths', - SSL: 'SSL certificate error - check HTTPS configuration', - ERR_NAME_NOT_RESOLVED: 'DNS resolution failed - check domain name', - ERR_CONNECTION_TIMED_OUT: - 'Connection timeout - check network or firewall', - ERR_NETWORK_CHANGED: 'Network changed during request - retry operation', - ERR_INTERNET_DISCONNECTED: 'No internet connection', - 'Navigation timeout': - 'Page took too long to load - check performance or timeouts', - WebSocket: 'WebSocket connection issue - check server status', - ERR_TUNNEL_CONNECTION_FAILED: 'Proxy or VPN connection issue', - ERR_CERT_: 'SSL/TLS certificate validation error', - ERR_BLOCKED_BY_CLIENT: 'Request blocked by browser extension or policy', - ERR_TOO_MANY_REDIRECTS: 'Redirect loop detected', - 'Frame detached': 'Target frame or element no longer exists', - 'Node is detached': 'Element was removed from DOM', - ERR_ABORTED: 'Request was aborted - possible navigation or reload', - ERR_CONTENT_LENGTH_MISMATCH: - 'Incomplete response - check server stability', - ERR_RESPONSE_HEADERS_TRUNCATED: 'Response headers too large or malformed', - } - - for (const [pattern, hint] of Object.entries(errorPatterns)) { - if (error.message.includes(pattern)) { - this.logs.push({ - type: 'info', - message: `Hint: ${hint}`, - timestamp: Date.now(), - category: 'hint', - source: 'tool', - }) - break // Stop after first matching pattern - } - } - this.logs.push({ - type: 'error', - message: `Action error: ${error.message}`, - timestamp: Date.now(), - stack: error.stack, - source: 'tool', - }) - } - private async getBrowser(config?: BrowserConfig) { - // Check if browser exists and is connected - if (!this.browser || !this.page) { - await this.startBrowser(config) - } else { - try { - // Test if browser is still responsive - await this.page.evaluate(() => true) - } catch (error) { - // Browser is dead or unresponsive, restart it - await this.shutdown() - await this.startBrowser(config) - } - } - - if (!this.browser || !this.page) { - throw new Error('Failed to initialize browser') - } - - return { browser: this.browser, page: this.page } - } - - private async startBrowser(config?: BrowserConfig) { - if (this.browser) { - await this.shutdown() - } - // Set start time for session tracking - this.startTime = Date.now() - - // Update session configuration - this.maxConsecutiveErrors = - config?.maxConsecutiveErrors ?? BROWSER_DEFAULTS.maxConsecutiveErrors - this.totalErrorThreshold = - config?.totalErrorThreshold ?? BROWSER_DEFAULTS.totalErrorThreshold - - // Reset error counters - this.consecutiveErrors = 0 - this.totalErrors = 0 - - try { - // Define helper to find Chrome in standard locations - const findChrome = () => { - switch (process.platform) { - case 'win32': - return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' - case 'darwin': - return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' - default: - return '/usr/bin/google-chrome' - } - } - - this.browser = await puppeteer.launch({ - defaultViewport: { - width: BROWSER_DEFAULTS.viewportWidth, - height: BROWSER_DEFAULTS.viewportHeight, - }, - headless: BROWSER_DEFAULTS.headless, - waitForInitialPage: true, - args: [ - '--window-size=1200,800', - '--disable-backgrounding-occluded-windows', - '--disable-breakpad', - '--disable-sync', - '--no-sandbox', - '--no-first-run', - '--disable-session-crashed-bubble', - '--disable-restore-session-state', - '--hide-crash-restore-bubble', - '--noerrdialogs', - '--disable-infobars', - ], - executablePath: findChrome(), - }) - } catch (error) { - // If launch fails, guide the user to install Google Chrome - console.log( - `Couldn't launch Chrome browser. Please ensure Google Chrome is installed on your system.\nReceived error: ${error instanceof Error ? error.message : error}`, - ) - return { - success: false, - error: - 'Chrome browser not found. Please install Google Chrome to use browser features.', - logs: this.logs, - networkEvents: this.networkEvents, - } - } - - // this.logs.push({ - // type: 'info', - // message: 'Browser started', - // timestamp: Date.now(), - // source: 'tool', - // }) - - // Pick the first existing page or create a new one - const pages = await this.browser.pages() - this.page = pages.length > 0 ? pages[0] : await this.browser.newPage() - this.attachPageListeners() - await sleep(500) - - return { - success: true, - logs: this.logs, - networkEvents: [], - } - } - - private async navigate( - action: Extract, - ): Promise { - try { - const { page } = await this.getBrowser(action) - const url = ensureUrlProtocol(action.url) - - await page.goto(url, { - waitUntil: action.waitUntil ?? BROWSER_DEFAULTS.waitUntil, - timeout: action.timeout ?? BROWSER_DEFAULTS.timeout, - }) - - this.logs.push({ - type: 'info', - message: `Navigated to ${url}`, - timestamp: Date.now(), - source: 'tool', - }) - - return { - success: true, - logs: this.logs, - networkEvents: [], - } - } catch (error: any) { - const errorMessage = error?.message || 'Unknown navigation error' - - this.logs.push({ - type: 'error', - message: `Navigation failed: ${errorMessage}`, - timestamp: Date.now(), - source: 'tool', - }) - return { - success: false, - error: errorMessage, - logs: this.logs, - networkEvents: [], - } - } - } - - private async typeText(action: Extract) { - const { page } = await this.getBrowser() - await page.type(action.selector, action.text, { - delay: action.delay ?? BROWSER_DEFAULTS.delay, - }) - } - - private async scroll(action: Extract) { - const { page } = await this.getBrowser() - - // Get viewport height - const viewport = page.viewport() - if (!viewport) throw new Error('No viewport found') - - // Default to scrolling down if no direction specified - const direction = action.direction ?? 'down' - const scrollAmount = direction === 'up' ? -viewport.height : viewport.height - - await page.evaluate((amount) => { - ;(globalThis as any).window.scrollBy(0, amount) - }, scrollAmount) - - this.logs.push({ - type: 'info', - message: `Scrolled ${direction}`, - timestamp: Date.now(), - source: 'tool', - }) - - return { - success: true, - logs: this.logs, - networkEvents: [], - } - } - - private attachPageListeners() { - if (!this.page) return - - // Console messages - this.page.on('console', (msg) => { - const type = - msg.type() === 'error' ? 'error' : (msg.type() as 'info' | 'warning') - this.logs.push({ - type, - message: msg.text(), - timestamp: Date.now(), - source: 'browser', - }) - }) - - // Page errors - this.page.on('pageerror', (err: unknown) => { - const error = err as Error - this.logs.push({ - type: 'error', - message: error.message, - timestamp: Date.now(), - stack: error.stack, - source: 'browser', - }) - this.jsErrorCount++ - }) - - // Network requests - this.page.on('request', (request: HTTPRequest) => { - const method = request.method() - if (method) { - this.networkEvents.push({ - url: request.url(), - method, - timestamp: Date.now(), - }) - } - }) - - // Network responses - this.page.on('response', async (response: HTTPResponse) => { - const req = response.request() - const index = this.networkEvents.findIndex( - (evt) => evt.url === req.url() && evt.method === req.method(), - ) - - const status = response.status() - const errorText = - status >= 400 ? await response.text().catch(() => '') : undefined - - if (index !== -1) { - this.networkEvents[index].status = status - this.networkEvents[index].errorText = errorText - } else { - const method = req.method() - if (method) { - this.networkEvents.push({ - url: req.url(), - method, - status, - errorText, - timestamp: Date.now(), - }) - } - } - - // Log network errors - if (status >= 400) { - this.logs.push({ - type: 'error', - message: `Network error ${status} for ${req.url()}`, - timestamp: Date.now(), - source: 'tool', - }) - } - }) - } - - private async collectPerformanceMetrics() { - if (!this.page) return - - // Collect Web Vitals and other performance metrics - const metrics = await this.page.evaluate(() => { - const lcpEntry = (performance as any).getEntriesByType( - 'largest-contentful-paint', - )[0] - const navEntry = (performance as any).getEntriesByType( - 'navigation', - )[0] as any - const fcpEntry = (performance as any) - .getEntriesByType('paint') - .find((entry: any) => entry.name === 'first-contentful-paint') - - return { - ttfb: navEntry?.responseStart - navEntry?.requestStart, - lcp: lcpEntry?.startTime, - fcp: fcpEntry?.startTime, - domContentLoaded: - navEntry?.domContentLoadedEventEnd - navEntry?.startTime, - } - }) - - this.performanceMetrics = metrics - } - - private async collectMetrics(): Promise { - if (!this.page) return undefined - - const perfEntries = JSON.parse( - await this.page.evaluate(() => - JSON.stringify((performance as any).getEntriesByType('navigation')), - ), - ) - - let loadTime = 0 - if (perfEntries && perfEntries.length > 0) { - const navTiming = perfEntries[0] - loadTime = navTiming.loadEventEnd - navTiming.startTime - } - - const memoryUsed = await this.page - .metrics() - .then((m) => m.JSHeapUsedSize || 0) - - await this.collectPerformanceMetrics() - - return { - loadTime, - memoryUsage: memoryUsed, - jsErrors: this.jsErrorCount, - networkErrors: this.networkEvents.filter( - (e) => e.status && e.status >= 400, - ).length, - ttfb: this.performanceMetrics.ttfb, - lcp: this.performanceMetrics.lcp, - fcp: this.performanceMetrics.fcp, - domContentLoaded: this.performanceMetrics.domContentLoaded, - sessionDuration: Date.now() - this.startTime, - } - } - - private filterLogs( - logs: BrowserResponse['logs'], - filter?: BrowserResponse['logFilter'], - ): BrowserResponse['logs'] { - // First deduplicate logs - const seen = new Set() - logs = logs.filter((log) => { - const key = `${log.type}|${log.message}|${log.timestamp}|${log.source}` - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) - - // Then apply any filters - if (!filter) return logs - - return logs.filter((log) => { - if (filter.types && !filter.types.includes(log.type)) return false - if (filter.minLevel && log.level && log.level < filter.minLevel) - return false - if ( - filter.categories && - log.category && - !filter.categories.includes(log.category) - ) - return false - return true - }) - } - - async execute(action: BrowserAction): Promise { - try { - const response = await this.executeWithRetry(action) - // Filter and deduplicate logs - response.logs = this.filterLogs( - response.logs, - action.logFilter ?? undefined, - ) - this.logs = [] // Clear logs after sending them in response - return response - } catch (error: any) { - if ( - error.name === 'TargetClosedError' || - (error.message && error.message.includes('detached Frame')) - ) { - this.logs.push({ - type: 'error', - message: 'Browser was closed or detached. Starting new session...', - timestamp: Date.now(), - category: 'browser', - source: 'tool', - }) - - await this.shutdown() - if (action.type !== 'stop') { - return this.executeWithRetry(action) - } - } - throw error - } - } - - public async shutdown() { - const browser = this.browser - if (browser) { - // Clear references first to prevent double shutdown - this.browser = null - this.page = null - try { - await browser.close() - } catch (err: unknown) { - console.error('Error closing browser:', err) - logger.error( - { - errorMessage: err instanceof Error ? err.message : String(err), - errorStack: err instanceof Error ? err.stack : undefined, - }, - 'Error closing browser', - ) - } - } - } -} - -export const handleBrowserInstruction = async ( - action: BrowserAction, -): Promise => { - const response = await activeBrowserRunner.execute(action) - return response -} - -export const activeBrowserRunner: BrowserRunner = new BrowserRunner() diff --git a/npm-app/src/chat-storage.ts b/npm-app/src/chat-storage.ts deleted file mode 100644 index c25f406adf..0000000000 --- a/npm-app/src/chat-storage.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' - -import { transformJsonInString } from '@codebuff/common/util/string' - -import { getCurrentChatDirSync, getCurrentChatId } from './project-files' -import { logger } from './utils/logger' - -import type { Log } from '@codebuff/common/browser-actions' -import type { Message } from '@codebuff/common/types/messages/codebuff-message' - -export function setMessagesSync(messages: Message[]) { - // Clean up any screenshots and logs in previous messages - // Skip the last message as it may not have been processed by the backend yet - const lastIndex = messages.length - 1 - const cleanedMessages = messages.map((msg, index): Message => { - if (index === lastIndex) { - return msg // Preserve the most recent message in its entirety - } - - // Helper function to clean up message content - const cleanContent = (content: string) => { - // Keep only tool logs - content = transformJsonInString>( - content, - 'logs', - (logs) => logs.filter((log) => log.source === 'tool'), - '(LOGS_REMOVED)', - ) - - // Remove metrics - content = transformJsonInString( - content, - 'metrics', - () => '(METRICS_REMOVED)', - '(METRICS_REMOVED)', - ) - - return content - } - - // Clean up message content - if (!msg.content) return msg - - if (msg.role === 'tool' || msg.role === 'system') { - return msg - } - - if (msg.role === 'user') { - if (typeof msg.content === 'string') { - return { - ...msg, - content: [{ type: 'text', text: cleanContent(msg.content) }], - } - } - - return { - ...msg, - content: msg.content.map((part) => - part.type === 'text' - ? { ...part, text: cleanContent(part.text) } - : part, - ), - } - } - if (typeof msg.content === 'string') { - return { - ...msg, - content: [{ type: 'text', text: cleanContent(msg.content) }], - } - } - - return { - ...msg, - content: msg.content.map((part) => - part.type === 'text' - ? { ...part, text: cleanContent(part.text) } - : part, - ), - } - }) - - // Save messages to chat directory - try { - const chatDir = getCurrentChatDirSync() - const messagesPath = path.join(chatDir, 'messages.json') - - const messagesData = { - id: getCurrentChatId(), - messages: cleanedMessages, - updatedAt: new Date().toISOString(), - } - - fs.writeFileSync(messagesPath, JSON.stringify(messagesData, null, 2)) - } catch (error) { - console.error('Failed to save messages to file:', error) - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - messagesCount: messages.length, - }, - 'Failed to save messages to file', - ) - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - messagesCount: messages.length, - }, - 'Failed to save messages to file', - ) - } -} diff --git a/npm-app/src/checkpoints/checkpoint-manager.ts b/npm-app/src/checkpoints/checkpoint-manager.ts deleted file mode 100644 index b315bf0f1c..0000000000 --- a/npm-app/src/checkpoints/checkpoint-manager.ts +++ /dev/null @@ -1,433 +0,0 @@ -import assert from 'assert' -import os from 'os' -import { Worker } from 'worker_threads' - -import { - DEFAULT_MAX_FILES, - getAllFilePaths, -} from '@codebuff/common/project-file-tree' -import { blue, bold, cyan, gray, red, underline, yellow } from 'picocolors' - -import { DiffManager } from '../diff-manager' -import { getProjectRoot } from '../project-files' -import { - getBareRepoPath, - getLatestCommit, - hasUnsavedChanges, -} from './file-manager' -import { gitCommandIsAvailable } from '../utils/git' -import { logger } from '../utils/logger' - -import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message' -import type { SessionState } from '@codebuff/common/types/session-state' - -export class CheckpointsDisabledError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message, options) - this.name = 'CheckpointsDisabledError' - } -} - -/** - * Message format for worker thread operations - */ -interface WorkerMessage { - /** The ID of this message */ - id: string - /** Operation type - either storing or restoring checkpoint state */ - type: 'store' | 'restore' - projectDir: string - bareRepoPath: string - relativeFilepaths: string[] - - /** Git commit hash for restore operations */ - commit?: string - /** Commit message for store operations */ - message?: string -} - -/** - * Response format from worker thread operations - */ -interface WorkerResponse { - /** The ID of the message in which this is a response to */ - id: string - /** Whether the operation succeeded */ - success: boolean - /** Operation result - commit hash for store operations */ - result?: unknown - /** Error message if operation failed */ - error?: string -} - -/** - * Interface representing a checkpoint of agent state - */ -export interface Checkpoint { - sessionStateString: string - lastToolResultsString: string - userInputChangesString: string - /** Promise resolving to the git commit hash for this checkpoint */ - fileStateIdPromise: Promise - /** Number of messages in the agent's history at checkpoint time */ - historyLength: number - id: number - parentId: number - timestamp: number - /** User input that triggered this checkpoint */ - userInput: string -} - -/** - * Manages checkpoints of agent state and file state using git operations in a worker thread. - * Each checkpoint contains both the agent's conversation state and a git commit - * representing the state of all tracked files at that point. - */ -export class CheckpointManager { - checkpoints: Array = [] - currentCheckpointId: number = 0 - disabledReason: string | null = null - - private bareRepoPath: string | null = null - /** Stores the undo chain (leaf node first, current node last) */ - private undoIds: Array = [] - /** Worker thread for git operations */ - private worker: Worker | null = null - - /** - * Initialize or return the existing worker thread - * @returns The worker thread instance - */ - private initWorker(): Worker { - if (!this.worker) { - const workerRelativePath = './workers/checkpoint-worker.ts' - this.worker = new Worker( - process.env.IS_BINARY - ? // Use relative path for compiled binary. - workerRelativePath - : // Use absolute path for dev (via bun URL). - new URL(workerRelativePath, import.meta.url).href, - ) - // Set max listeners to prevent warnings - this.worker.setMaxListeners(50) - } - return this.worker - } - - /** - * Execute an operation in the worker thread with timeout handling - * @param message - The message describing the operation to perform - * @returns A promise that resolves with the operation result - * @throws {Error} if the operation fails or times out - */ - private async runWorkerOperation(message: WorkerMessage): Promise { - const worker = this.initWorker() - - return new Promise((resolve, reject) => { - const timeoutMs = 30000 // 30 seconds timeout - let timeoutHandle: NodeJS.Timeout | null = null - - const cleanup = () => { - if (timeoutHandle) { - clearTimeout(timeoutHandle) - timeoutHandle = null - } - worker.off('message', messageHandler) - worker.off('error', errorHandler) - } - - const messageHandler = (response: WorkerResponse) => { - if (response.id !== message.id) { - return - } - cleanup() - if (response.success) { - resolve(response.result as T) - } else { - reject(new Error(response.error)) - } - } - - const errorHandler = (error: Error) => { - cleanup() - reject(error) - } - - worker.on('message', messageHandler) - worker.on('error', errorHandler) - worker.postMessage(message) - - // Add timeout - timeoutHandle = setTimeout(() => { - cleanup() - reject(new Error('Worker operation timed out')) - }, timeoutMs) - }) - } - - /** - * Get the path to the bare git repository used for storing file states - * @returns The bare repo path - */ - private getBareRepoPath(): string { - if (!this.bareRepoPath) { - this.bareRepoPath = getBareRepoPath(getProjectRoot()) - } - return this.bareRepoPath - } - - /** - * Add a new checkpoint of the current agent and file state - * @param sessionState - The current agent state to checkpoint - * @param lastToolResults - The tool results from the last assistant turn - * @param userInput - The user input that triggered this checkpoint - * @returns The latest checkpoint and whether that checkpoint was created (or already existed) - * @throws {Error} If the checkpoint cannot be added - */ - async addCheckpoint( - sessionState: SessionState, - lastToolResults: ToolMessage[], - userInput: string, - saveWithNoChanges: boolean = false, - ): Promise<{ checkpoint: Checkpoint; created: boolean }> { - if (this.disabledReason !== null) { - throw new CheckpointsDisabledError(this.disabledReason) - } - - if (!gitCommandIsAvailable()) { - this.disabledReason = 'Git required for checkpoints' - throw new CheckpointsDisabledError(this.disabledReason) - } - - const id = this.checkpoints.length + 1 - const projectDir = getProjectRoot() - if (projectDir === os.homedir()) { - this.disabledReason = 'In home directory' - throw new CheckpointsDisabledError(this.disabledReason) - } - const bareRepoPath = this.getBareRepoPath() - const relativeFilepaths = getAllFilePaths(sessionState.fileContext.fileTree) - - if (relativeFilepaths.length >= DEFAULT_MAX_FILES) { - this.disabledReason = 'Project too large' - throw new CheckpointsDisabledError(this.disabledReason) - } - - const needToStage = - saveWithNoChanges || - (await hasUnsavedChanges({ - projectDir, - bareRepoPath, - relativeFilepaths, - })) || - saveWithNoChanges - if (!needToStage && this.checkpoints.length > 0) { - return { - checkpoint: this.checkpoints[this.checkpoints.length - 1], - created: false, - } - } - - let fileStateIdPromise: Promise - if (needToStage) { - const params = { - type: 'store' as const, - projectDir, - bareRepoPath, - message: `Checkpoint ${id}`, - relativeFilepaths, - } - fileStateIdPromise = this.runWorkerOperation({ - ...params, - id: JSON.stringify(params), - }) - } else { - fileStateIdPromise = getLatestCommit({ bareRepoPath }) - } - - const checkpoint: Checkpoint = { - sessionStateString: JSON.stringify(sessionState), - lastToolResultsString: JSON.stringify(lastToolResults), - userInputChangesString: JSON.stringify(DiffManager.getChanges()), - fileStateIdPromise, - historyLength: sessionState.mainAgentState.messageHistory.length, - id, - parentId: this.currentCheckpointId, - timestamp: Date.now(), - userInput, - } - - this.checkpoints.push(checkpoint) - this.currentCheckpointId = id - this.undoIds = [] - return { checkpoint, created: true } - } - - /** - * Get the most recent checkpoint - * @returns The most recent checkpoint or null if none exist - * @throws {CheckpointsDisabledError} If checkpoints are disabled - * @throws {ReferenceError} If no checkpoints exist - */ - getLatestCheckpoint(): Checkpoint { - if (this.disabledReason !== null) { - throw new CheckpointsDisabledError(this.disabledReason) - } - if (this.checkpoints.length === 0) { - throw new ReferenceError('No checkpoints available') - } - return this.checkpoints[this.checkpoints.length - 1] - } - - /** - * Restore the file state from a specific checkpoint - * @param id - The ID of the checkpoint to restore - * @param resetUndoIds - Whether to reset the chain of undo/redo ids - * @throws {Error} If the file state cannot be restored - */ - async restoreCheckointFileState({ - id, - resetUndoIds = false, - }: { - id: number - resetUndoIds?: boolean - }): Promise { - if (this.disabledReason !== null) { - throw new CheckpointsDisabledError(this.disabledReason) - } - - const checkpoint = this.checkpoints[id - 1] - if (!checkpoint) { - throw new ReferenceError('No checkpoints available') - } - - const relativeFilepaths = getAllFilePaths( - (JSON.parse(checkpoint.sessionStateString) as SessionState).fileContext - .fileTree, - ) - - const params = { - type: 'restore' as const, - projectDir: getProjectRoot(), - bareRepoPath: this.getBareRepoPath(), - commit: await checkpoint.fileStateIdPromise, - relativeFilepaths, - } - await this.runWorkerOperation({ ...params, id: JSON.stringify(params) }) - this.currentCheckpointId = id - if (resetUndoIds) { - this.undoIds = [] - } - - DiffManager.setChanges(JSON.parse(checkpoint.userInputChangesString)) - } - - async restoreUndoCheckpoint(): Promise { - if (this.disabledReason !== null) { - throw new CheckpointsDisabledError(this.disabledReason) - } - - const currentCheckpoint = this.checkpoints[this.currentCheckpointId - 1] - assert( - currentCheckpoint, - `Internal error: checkpoint #${this.currentCheckpointId} not found`, - ) - - if (currentCheckpoint.parentId === 0) { - throw new ReferenceError('Already at earliest change') - } - - await this.restoreCheckointFileState({ id: currentCheckpoint.parentId }) - - this.undoIds.push(currentCheckpoint.id) - } - - async restoreRedoCheckpoint(): Promise { - if (this.disabledReason !== null) { - throw new CheckpointsDisabledError(this.disabledReason) - } - - const targetId = this.undoIds.pop() - if (targetId === undefined) { - throw new ReferenceError('Nothing to redo') - } - // Check if targetId is either 0 or undefined - assert( - targetId, - `Internal error: Checkpoint ID ${targetId} found in undo list`, - ) - - try { - await this.restoreCheckointFileState({ id: targetId }) - } catch (error) { - this.undoIds.push(targetId) - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - targetId, - }, - 'Unable to restore checkpoint during redo', - ) - throw new Error('Unable to restore checkpoint', { cause: error }) - } - } - - /** - * Clear all checkpoints - */ - clearCheckpoints(resetBareRepoPath: boolean = false): void { - this.checkpoints = [] - this.currentCheckpointId = 0 - this.undoIds = [] - if (resetBareRepoPath) { - this.bareRepoPath = null - } - } - - /** - * Get a formatted string representation of all checkpoints - * @param detailed Whether to include detailed information about each checkpoint - * @returns A formatted string representation of all checkpoints - */ - getCheckpointsAsString(detailed: boolean = false): string { - if (this.disabledReason !== null) { - return red(`Checkpoints not enabled: ${this.disabledReason}`) - } - - if (this.checkpoints.length === 0) { - return yellow('No checkpoints available.') - } - - const lines: string[] = [bold(underline('Agent State Checkpoints:')), ''] - - this.checkpoints.forEach((checkpoint) => { - const date = new Date(checkpoint.timestamp) - const formattedDate = date.toLocaleString() - - const userInputOneLine = checkpoint.userInput.replaceAll('\n', ' ') - const userInput = - userInputOneLine.length > 50 - ? userInputOneLine.substring(0, 47) + '...' - : userInputOneLine - - lines.push( - `${cyan(bold(`#${checkpoint.id}`))} ${gray(`[${formattedDate}]`)}:`, - ) - - lines.push(` ${blue('Input')}: ${userInput}`) - - if (detailed) { - const messageCount = checkpoint.historyLength - lines.push(` ${blue('Messages')}: ${messageCount}`) - } - - lines.push('') // Empty line between checkpoints - }) - - return lines.join('\n') - } -} - -// Export a singleton instance for use throughout the application -export const checkpointManager = new CheckpointManager() diff --git a/npm-app/src/checkpoints/file-manager.ts b/npm-app/src/checkpoints/file-manager.ts deleted file mode 100644 index 14edccb291..0000000000 --- a/npm-app/src/checkpoints/file-manager.ts +++ /dev/null @@ -1,655 +0,0 @@ -import { execFileSync } from 'child_process' -import { createHash } from 'crypto' -import fs from 'fs' -import os from 'os' -import path, { join } from 'path' - -import { buildArray } from '@codebuff/common/util/array' - -import { getProjectDataDir } from '../project-files' -import { gitCommandIsAvailable } from '../utils/git' -import { logger } from '../utils/logger' - -// Dynamic import for isomorphic-git -async function getIsomorphicGit() { - const git = await import('isomorphic-git') - return git -} - -const maxBuffer = 50 * 1024 * 1024 // 50 MB - -/** - * Generates a unique path for storing the bare git repository based on the project directory. - * Uses SHA-256 hashing to create a unique identifier. - * @param dir - The project directory path to hash - * @returns The full path where the bare repo should be stored - */ -export function getBareRepoPath(dir: string): string { - const bareRepoName = createHash('sha256').update(dir).digest('hex') - return join(getProjectDataDir(), bareRepoName) -} - -let nestedRepos: string[] = [] -function exposeSubmodules({ - bareRepoPath, - projectDir, -}: { - bareRepoPath: string - projectDir: string -}): void { - while (true) { - const submodulesOutput = execFileSync( - 'git', - [ - '-c', - 'core.untrackedCache=false', - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - '-C', - projectDir, - 'ls-files', - '--stage', - '--', - '.', - ':(exclude)**/*.codebuffbackup', - ':(exclude)**/*.codebuffbackup/**', - ], - { stdio: ['ignore', 'pipe', 'inherit'], maxBuffer }, - ).toString() - const submodules = buildArray( - submodulesOutput - .split('\n') - .filter((line) => line.startsWith('160000')) - .map((line) => line.split('\t')[1]), - ) - - if (submodules.length === 0) { - return - } - - for (const submodule of submodules) { - try { - if (!fs.existsSync(path.join(projectDir, submodule, '.git'))) { - continue - } - fs.renameSync( - path.join(projectDir, submodule, '.git'), - path.join(projectDir, submodule, '.git.codebuffbackup'), - ) - nestedRepos.push(submodule) - } catch (error) { - logger.error( - { - error, - nestedRepo: submodule, - }, - 'Failed to backup .git directory for nested repo for checking status', - ) - } - } - - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - '-C', - projectDir, - 'rm', - '-f', - '--cached', - ...submodules, - ], - { stdio: 'ignore', maxBuffer }, - ) - } catch (error) { - logger.error( - { error }, - 'Error running git rm --cached while exposing submodules', - ) - } - } -} - -function restoreSubmodules({ projectDir }: { projectDir: string }) { - for (const nestedRepo of nestedRepos) { - const codebuffBackup = path.join( - projectDir, - nestedRepo, - '.git.codebuffbackup', - ) - const gitDir = path.join(projectDir, nestedRepo, '.git') - try { - fs.renameSync(codebuffBackup, gitDir) - } catch (error) { - console.error( - `Failed to restore .git directory for nested repo. Please rename ${codebuffBackup} to ${gitDir}\n${JSON.stringify({ error }, null, 2)}`, - ) - logger.error( - { - error, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - nestedRepo, - }, - 'Failed to restore .git directory for nested repo', - ) - } - } - nestedRepos = [] -} - -/** - * Checks if there are any uncommitted changes in the working directory. - * First attempts to use native git commands, falling back to isomorphic-git if unavailable. - * @param projectDir - The working tree directory path - * @param bareRepoPath - The bare git repository path - * @param relativeFilepaths - Array of file paths relative to projectDir to check - * @returns Promise resolving to true if there are uncommitted changes, false otherwise - */ -export async function hasUnsavedChanges({ - projectDir, - bareRepoPath, - relativeFilepaths, -}: { - projectDir: string - bareRepoPath: string - relativeFilepaths: Array -}): Promise { - if (!gitCommandIsAvailable()) { - return false - } - - try { - exposeSubmodules({ bareRepoPath, projectDir }) - - try { - const output = execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - '-C', - projectDir, - 'status', - '--porcelain', - '--', - '.', - ':(exclude)**/*.codebuffbackup', - ':(exclude)**/*.codebuffbackup/**', - ], - { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer }, - ).toString() - return !!output - } catch (error) { - logger.error( - { - error, - projectDir, - bareRepoPath, - }, - 'Error running git status for unsaved changes check', - ) - return false - } - } finally { - restoreSubmodules({ projectDir }) - } -} - -/** - * Gets the hash of the latest commit in the repository. - * First attempts to use native git commands, falling back to isomorphic-git if unavailable. - * @param bareRepoPath - The bare git repository path - * @returns Promise resolving to the commit hash - */ -export async function getLatestCommit({ - bareRepoPath, -}: { - bareRepoPath: string -}): Promise { - if (gitCommandIsAvailable()) { - try { - return execFileSync( - 'git', - ['--git-dir', bareRepoPath, 'rev-parse', 'HEAD'], - { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer }, - ) - .toString() - .trim() - } catch (error) { - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - bareRepoPath, - }, - 'Error getting latest commit with git command', - ) - } - } - const { resolveRef } = await getIsomorphicGit() - return await resolveRef({ - fs, - gitdir: bareRepoPath, - ref: 'HEAD', - }) -} - -/** - * Initializes a bare git repository for tracking file changes. - * Creates the repository if it doesn't exist, otherwise uses the existing one. - * Makes an initial commit of the current file state. - * @param projectDir - The working tree directory path - * @param relativeFilepaths - Array of file paths relative to projectDir to track - */ -export async function initializeCheckpointFileManager({ - projectDir, - relativeFilepaths, -}: { - projectDir: string - relativeFilepaths: Array -}): Promise { - if (projectDir === os.homedir()) { - return - } - const bareRepoPath = getBareRepoPath(projectDir) - - // Create the bare repo directory if it doesn't exist - fs.mkdirSync(bareRepoPath, { recursive: true }) - - const { resolveRef, init } = await getIsomorphicGit() - try { - // Check if it's already a valid Git repo - await resolveRef({ fs, gitdir: bareRepoPath, ref: 'HEAD' }) - } catch (error) { - // Bare repo doesn't exist yet - await init({ - fs, - dir: projectDir, - gitdir: bareRepoPath, - bare: true, - defaultBranch: 'master', - }) - } - - // Commit the files in the bare repo - await storeFileState({ - projectDir, - bareRepoPath, - message: 'Initial Commit', - relativeFilepaths, - }) -} - -/** - * Stages all changes in the working directory. - * First attempts to use native git commands, falling back to isomorphic-git if unavailable. - * @param projectDir - The working tree directory path - * @param bareRepoPath - The bare git repository path - * @param relativeFilepaths - Array of file paths relative to projectDir to stage - */ -async function gitAddAll({ - projectDir, - bareRepoPath, - relativeFilepaths, -}: { - projectDir: string - bareRepoPath: string - relativeFilepaths: Array -}): Promise { - if (gitCommandIsAvailable()) { - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - '-C', - projectDir, - 'add', - '.', - ':!**/*.codebuffbackup', - ':!**/*.codebuffbackup/**', - ], - { stdio: 'ignore', maxBuffer }, - ) - return - } catch (error) { - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - projectDir, - bareRepoPath, - }, - 'Failed to git add all files', - ) - } - } - - // Stage files with isomorphic-git - const { statusMatrix, add, remove } = await getIsomorphicGit() - - // Get status of all files in the project directory - const currStatusMatrix = - (await statusMatrix({ - fs, - dir: projectDir, - gitdir: bareRepoPath, - filepaths: relativeFilepaths, - })) ?? [] - - for (const [filepath, , workdirStatus, stageStatus] of currStatusMatrix) { - if (workdirStatus === stageStatus) { - continue - } - - if (workdirStatus === 2) { - // Existing file different from HEAD - try { - await add({ fs, dir: projectDir, gitdir: bareRepoPath, filepath }) - } catch (error) { - logger.error( - { - errorMessage: - error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - filepath, - projectDir, - bareRepoPath, - }, - 'Error adding file to git', - ) - } - } else if (workdirStatus === 0) { - // Deleted file - try { - await remove({ fs, dir: projectDir, gitdir: bareRepoPath, filepath }) - } catch (error) { - logger.error( - { - errorMessage: - error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - filepath, - projectDir, - bareRepoPath, - }, - 'Error removing file from git', - ) - } - } - } -} - -async function gitAddAllIgnoringNestedRepos({ - projectDir, - bareRepoPath, - relativeFilepaths, -}: { - projectDir: string - bareRepoPath: string - relativeFilepaths: Array -}): Promise { - const nestedRepos: string[] = [] - try { - exposeSubmodules({ bareRepoPath, projectDir }) - let output: string - try { - output = execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - 'status', - '--porcelain', - ], - { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer }, - ).toString() - } catch (error) { - logger.error( - { error, projectDir, bareRepoPath }, - 'Failed to get git status while finding nested git repos', - ) - return - } - - if (!output) { - return - } - - const modifiedFiles = buildArray(output.split('\n')) - .filter((line) => line[1] === 'M' || line[1] === '?' || line[1] === 'D') - .map((line) => line.slice(3).trim()) - - if (modifiedFiles.length === 0) { - return - } - - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - '-C', - projectDir, - 'rm', - '--cached', - '-rf', - ...modifiedFiles, - ], - { stdio: 'ignore', maxBuffer }, - ) - } catch (error) { - logger.error({ error }, 'Failed to run git rm --cached') - } - - await gitAddAll({ projectDir, bareRepoPath, relativeFilepaths }) - } finally { - restoreSubmodules({ projectDir }) - } -} - -async function gitCommit({ - projectDir, - bareRepoPath, - message, -}: { - projectDir: string - bareRepoPath: string - message: string -}): Promise { - if (gitCommandIsAvailable()) { - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - 'commit', - '-m', - message, - ], - { stdio: 'ignore', maxBuffer }, - ) - return await getLatestCommit({ bareRepoPath }) - } catch (error) { - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - projectDir, - bareRepoPath, - message, - }, - 'Failed to commit with git command, falling back to isomorphic-git', - ) - } - } - - const { commit, checkout } = await getIsomorphicGit() - const commitHash: string = await commit({ - fs, - dir: projectDir, - gitdir: bareRepoPath, - author: { name: 'Codebuff' }, - message, - ref: '/refs/heads/master', - }) - - if (gitCommandIsAvailable()) { - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - 'checkout', - 'master', - ], - { stdio: 'ignore', maxBuffer }, - ) - return commitHash - } catch (error) { - logger.error( - { - error, - projectDir, - bareRepoPath, - }, - 'Unable to checkout with git command', - ) - } - } - - await checkout({ fs, dir: projectDir, gitdir: bareRepoPath, ref: 'master' }) - - return commitHash -} - -/** - * Creates a new commit with the current state of all tracked files. - * Stages all changes and creates a commit with the specified message. - * @param projectDir - The working tree directory path - * @param bareRepoPath - The bare git repository path - * @param message - The commit message - * @param relativeFilepaths - Array of file paths relative to projectDir to commit - * @returns Promise resolving to the new commit's hash - */ -export async function storeFileState({ - projectDir, - bareRepoPath, - message, - relativeFilepaths: relativeFilepaths, -}: { - projectDir: string - bareRepoPath: string - message: string - relativeFilepaths: Array -}): Promise { - await gitAddAllIgnoringNestedRepos({ - projectDir, - bareRepoPath, - relativeFilepaths, - }) - - return await gitCommit({ projectDir, bareRepoPath, message }) -} - -/** - * Restores the working directory and index to match the specified commit. - * Equivalent to `git reset --hard` - * First attempts to use native git commands, falling back to isomorphic-git if unavailable. - * @param projectDir - The working tree directory path - * @param bareRepoPath - The bare git repository path - * @param commit - The commit hash to restore to - * @param relativeFilepaths - Array of file paths relative to projectDir to restore - */ -export async function restoreFileState({ - projectDir, - bareRepoPath, - commit, - relativeFilepaths, -}: { - projectDir: string - bareRepoPath: string - commit: string - relativeFilepaths: Array -}): Promise { - let resetDone = false - if (gitCommandIsAvailable()) { - try { - execFileSync( - 'git', - [ - '--git-dir', - bareRepoPath, - '--work-tree', - projectDir, - 'reset', - '--hard', - commit, - ], - { maxBuffer }, - ) - return - } catch (error) { - logger.error( - { - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - projectDir, - bareRepoPath, - commit, - }, - 'Failed to use git reset, falling back to isomorphic-git', - ) - } - } - - const { checkout, resetIndex } = await getIsomorphicGit() - // Update the working directory to reflect the specified commit - await checkout({ - fs, - dir: projectDir, - gitdir: bareRepoPath, - ref: commit, - filepaths: relativeFilepaths, - force: true, - }) - - // Reset the index to match the specified commit - await Promise.all( - relativeFilepaths.map((filepath) => - resetIndex({ - fs, - dir: projectDir, - gitdir: bareRepoPath, - filepath, - ref: commit, - }), - ), - ) -} - -// Export fs for testing -export { fs } diff --git a/npm-app/src/cli-definitions.ts b/npm-app/src/cli-definitions.ts deleted file mode 100644 index 2e268db239..0000000000 --- a/npm-app/src/cli-definitions.ts +++ /dev/null @@ -1,107 +0,0 @@ -export interface CliParam { - flags: string - description: string - menuDescription?: string - menuDetails?: string[] - hidden?: boolean -} - -export const cliArguments: CliParam[] = [ - { - flags: '[initial-prompt...]', - description: 'Initial prompt to send', - menuDescription: 'Initial prompt to send to Codebuff', - }, -] - -export const cliOptions: CliParam[] = [ - { - flags: '--create