From 9a34e90b41fa41501ef955bedc19983741967eb4 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Mon, 1 Jun 2026 18:37:44 +0530 Subject: [PATCH 01/26] chore: shelve v1 codebase into /v1 Pure relocation, no logic changes. Move all old code (the 2024 codegen pipeline) into /v1 via git mv to preserve history, in preparation for the polyglot monorepo rewrite. --- .dockerignore => v1/.dockerignore | 0 .../.github}/ISSUE_TEMPLATE/1.BUG_REPORT.yml | 0 {.github => v1/.github}/PULL_REQUEST_TEMPLATE.md | 0 .../.github}/workflows/build-ide-image.yaml | 0 CODE_OF_CONDUCT.md => v1/CODE_OF_CONDUCT.md | 0 Dockerfile => v1/Dockerfile | 0 LICENSE => v1/LICENSE | 0 README.md => v1/README.md | 0 .../client/git_provider/gitness_git_provider.go | 0 {app => v1/app}/client/http_client.go | 0 .../app}/client/workspace/workspace_service.go | 0 {app => v1/app}/config/ai_developer_execution.go | 0 {app => v1/app}/config/aws_config.go | 0 {app => v1/app}/config/config.go | 0 {app => v1/app}/config/db.go | 0 {app => v1/app}/config/env.go | 0 {app => v1/app}/config/filestore.go | 0 {app => v1/app}/config/frontend_workspace.go | 0 {app => v1/app}/config/github_oauth.go | 0 {app => v1/app}/config/gitness.go | 0 {app => v1/app}/config/jwt.go | 0 {app => v1/app}/config/local_filestore_config.go | 0 {app => v1/app}/config/logger.go | 0 {app => v1/app}/config/model.go | 0 {app => v1/app}/config/monitoring_slack.go | 0 {app => v1/app}/config/new_relic.go | 0 {app => v1/app}/config/redis.go | 0 {app => v1/app}/config/s3_config.go | 0 {app => v1/app}/config/s3_filestore_config.go | 0 {app => v1/app}/config/workspace_config.go | 0 {app => v1/app}/config/workspace_service.go | 0 {app => v1/app}/constants/app_env.go | 0 {app => v1/app}/constants/asynq_task_names.go | 0 {app => v1/app}/constants/filestore_type.go | 0 {app => v1/app}/constants/models.go | 0 {app => v1/app}/constants/pr_status.go | 0 {app => v1/app}/constants/pr_type.go | 0 {app => v1/app}/constants/project_workflows.go | 0 {app => v1/app}/constants/redis_ttl.go | 0 {app => v1/app}/constants/story_status.go | 0 {app => v1/app}/constants/story_type.go | 0 .../app}/controllers/activity_log_controller.go | 0 {app => v1/app}/controllers/auth_controller.go | 0 .../controllers/design_story_review_controller.go | 0 {app => v1/app}/controllers/execution_controller.go | 0 .../app}/controllers/execution_output_controller.go | 0 {app => v1/app}/controllers/health.go | 0 {app => v1/app}/controllers/llm_api_key.go | 0 {app => v1/app}/controllers/project_controller.go | 0 .../controllers/pull_request_comments_controller.go | 0 .../app}/controllers/pull_request_controller.go | 0 {app => v1/app}/controllers/story_controller.go | 0 {app => v1/app}/controllers/user_controller.go | 0 .../migrations/000001_create_users_table.down.sql | 0 .../db/migrations/000001_create_users_table.up.sql | 0 .../000002_create_organisations_table.down.sql | 0 .../000002_create_organisations_table.up.sql | 0 .../000003_create_projects_table.down.sql | 0 .../migrations/000003_create_projects_table.up.sql | 0 .../migrations/000004_create_stories_table.down.sql | 0 .../migrations/000004_create_stories_table.up.sql | 0 .../000005_create_story_files_table.down.sql | 0 .../000005_create_story_files_table.up.sql | 0 .../000006_create_story_instructions_table.down.sql | 0 .../000006_create_story_instructions_table.up.sql | 0 .../000007_create_story_test_cases_table.down.sql | 0 .../000007_create_story_test_cases_table.up.sql | 0 .../000008_create_executions_table.down.sql | 0 .../000008_create_executions_table.up.sql | 0 .../000009_create_execution_steps_table.down.sql | 0 .../000009_create_execution_steps_table.up.sql | 0 .../000010_create_execution_outputs_table.down.sql | 0 .../000010_create_execution_outputs_table.up.sql | 0 .../000011_create_activity_logs_table.down.sql | 0 .../000011_create_activity_logs_table.up.sql | 0 .../000012_create_execution_files_table.down.sql | 0 .../000012_create_execution_files_table.up.sql | 0 .../000013_add_reexecution_to_executions.down.sql | 0 .../000013_add_reexecution_to_executions.up.sql | 0 .../000014_alter_execution_output_table.down.sql | 0 .../000014_alter_execution_output_table.up.sql | 0 .../000015_create_pull_request_table.down.sql | 0 .../000015_create_pull_request_table.up.sql | 0 ...0016_create_pull_request_comments_table.down.sql | 0 ...000016_create_pull_request_comments_table.up.sql | 0 .../000017_remove_pull_req_exec_output.down.sql | 0 .../000017_remove_pull_req_exec_output.up.sql | 0 .../000018_add_is_deleted_column_story.down.sql | 0 .../000018_add_is_deleted_column_story.up.sql | 0 .../000019_remove_pull_req_exec_output.down.sql | 0 .../000019_remove_pull_req_exec_output.up.sql | 0 .../000020_comment_data_type_change.down.sql | 0 .../000020_comment_data_type_change.up.sql | 0 .../app}/db/migrations/000021_llm_api_keys.down.sql | 0 .../app}/db/migrations/000021_llm_api_keys.up.sql | 0 .../000022_add_supercoder_user_and_org.down.sql | 0 .../000022_add_supercoder_user_and_org.up.sql | 0 .../000023_remove_supercoder_user_and_org.down.sql | 0 .../000023_remove_supercoder_user_and_org.up.sql | 0 .../migrations/000024_alter_stories_table.down.sql | 0 .../db/migrations/000024_alter_stories_table.up.sql | 0 ...000025_create_design_story_review_table.down.sql | 0 .../000025_create_design_story_review_table.up.sql | 0 .../000026_update_api_key_length.down.sql | 0 .../migrations/000026_update_api_key_length.up.sql | 0 ...00027_add_frontend_framework_to_project.down.sql | 0 .../000027_add_frontend_framework_to_project.up.sql | 0 .../db/migrations/000028_add_type_in_story.down.sql | 0 .../db/migrations/000028_add_type_in_story.up.sql | 0 ...00029_add_prtype_in_pull_requests_table.down.sql | 0 .../000029_add_prtype_in_pull_requests_table.up.sql | 0 .../000030_inc_length_story_table.down.sql | 0 .../migrations/000030_inc_length_story_table.up.sql | 0 .../000031_rename_framework_in_projects.down.sql | 0 .../000031_rename_framework_in_projects.up.sql | 0 {app => v1/app}/gateways/websocket.go | 0 {app => v1/app}/gateways/websocket_gateway.go | 0 {app => v1/app}/llms/claude.go | 0 {app => v1/app}/llms/open_ai.go | 0 .../app}/middleware/organisation_authorization.go | 0 {app => v1/app}/middleware/project_authorization.go | 0 .../app}/middleware/pull_request_authorization.go | 0 {app => v1/app}/middleware/story_authorization.go | 0 {app => v1/app}/middleware/user_authorization.go | 0 {app => v1/app}/models/activity_log.go | 0 {app => v1/app}/models/design_story_review.go | 0 .../models/dtos/asynq_task/create_job_payload.go | 0 .../models/dtos/asynq_task/delete_workspace_task.go | 0 {app => v1/app}/models/dtos/gitness/types.go | 0 .../models/dtos/llm_api_key/llm_api_key_return.go | 0 {app => v1/app}/models/execution.go | 0 {app => v1/app}/models/execution_file.go | 0 {app => v1/app}/models/execution_output.go | 0 {app => v1/app}/models/execution_step.go | 0 {app => v1/app}/models/llm_api_key.go | 0 {app => v1/app}/models/organisation.go | 0 {app => v1/app}/models/project.go | 0 {app => v1/app}/models/pull_request.go | 0 {app => v1/app}/models/pull_request_comment.go | 0 {app => v1/app}/models/story.go | 0 {app => v1/app}/models/story_file.go | 0 {app => v1/app}/models/story_instruction.go | 0 {app => v1/app}/models/story_test_case.go | 0 {app => v1/app}/models/types/errors.go | 0 {app => v1/app}/models/types/json_map.go | 0 {app => v1/app}/models/user.go | 0 {app => v1/app}/monitoring/slack_alerts.go | 0 .../app}/prompts/nextjs/ai_frontend_developer.txt | 0 .../nextjs/ai_frontend_developer_edit_code.txt | 0 .../app}/prompts/nextjs/next_js_build_checker.txt | 0 .../app}/prompts/python/ai_developer_django.txt | 0 .../app}/prompts/python/ai_developer_flask.txt | 0 {app => v1/app}/repositories/activity_log.go | 0 {app => v1/app}/repositories/design_story_review.go | 0 {app => v1/app}/repositories/execution.go | 0 {app => v1/app}/repositories/execution_output.go | 0 {app => v1/app}/repositories/execution_step.go | 0 {app => v1/app}/repositories/llm_api_key.go | 0 {app => v1/app}/repositories/options.go | 0 {app => v1/app}/repositories/organisation.go | 0 {app => v1/app}/repositories/project.go | 0 .../repositories/project_connection_repository.go | 0 {app => v1/app}/repositories/pull_request.go | 0 .../app}/repositories/pull_request_comment.go | 0 {app => v1/app}/repositories/repository.go | 0 {app => v1/app}/repositories/story.go | 0 {app => v1/app}/repositories/story_file.go | 0 {app => v1/app}/repositories/story_instruction.go | 0 {app => v1/app}/repositories/story_test_case.go | 0 {app => v1/app}/repositories/user.go | 0 {app => v1/app}/services/activity_log_service.go | 0 {app => v1/app}/services/auth/auth_provider.go | 0 {app => v1/app}/services/auth/authenticator.go | 0 {app => v1/app}/services/auth/email_auth_service.go | 0 .../app}/services/auth/github_auth_service.go | 0 .../services/auth/jwt_authentication_middleware.go | 0 {app => v1/app}/services/code_download_service.go | 0 .../app}/services/design_story_review_service.go | 0 .../app}/services/execution_output_service.go | 0 {app => v1/app}/services/execution_service.go | 0 {app => v1/app}/services/execution_step_service.go | 0 {app => v1/app}/services/filestore/filestore.go | 0 {app => v1/app}/services/filestore/impl/local.go | 0 {app => v1/app}/services/filestore/impl/s3.go | 0 .../app}/services/git_providers/git_provider.go | 0 .../git_providers/gitness_git_provider_service.go | 0 {app => v1/app}/services/llm_api_key.go | 0 {app => v1/app}/services/organisation_service.go | 0 .../app}/services/project_notification_service.go | 0 {app => v1/app}/services/project_service.go | 0 .../app}/services/pull_request_comments_service.go | 0 {app => v1/app}/services/pull_request_service.go | 0 {app => v1/app}/services/story_service.go | 0 {app => v1/app}/services/user_service.go | 0 {app => v1/app}/tasks/check_execution_status.go | 0 {app => v1/app}/tasks/create_execution_job_task.go | 0 .../app}/tasks/delete_workspace_task_handler.go | 0 .../app}/types/request/create_comment_request.go | 0 .../request/create_design_story_comment_request.go | 0 {app => v1/app}/types/request/create_job_request.go | 0 {app => v1/app}/types/request/create_pr_request.go | 0 .../app}/types/request/create_project_request.go | 0 .../app}/types/request/create_story_request.go | 0 .../app}/types/request/create_user_request.go | 0 .../app}/types/request/create_worspace_request.go | 0 {app => v1/app}/types/request/delete_story_by_id.go | 0 .../app}/types/request/llm_api_key_request.go | 0 {app => v1/app}/types/request/merge_pull_request.go | 0 .../app}/types/request/retrieve_code_request.go | 0 .../app}/types/request/update_project_request.go | 0 .../app}/types/request/update_story_request.go | 0 .../types/request/update_story_status_request.go | 0 .../app}/types/request/user_signin_request.go | 0 .../types/response/create_workspace_response.go | 0 .../app}/types/response/get_all_commits_response.go | 0 .../types/response/get_all_projects_response.go | 0 .../app}/types/response/get_all_pull_requests.go | 0 .../types/response/get_all_stories_by_project_id.go | 0 .../types/response/get_code_for_design_story.go | 0 .../response/get_design_stories_by_project_id.go | 0 .../app}/types/response/get_story_by_id_response.go | 0 .../app}/types/response/get_story_response.go | 0 {app => v1/app}/types/response/user_response.go | 0 {app => v1/app}/utils/api_key.go | 0 {app => v1/app}/utils/asynq_helper.go | 0 {app => v1/app}/utils/command_helper.go | 0 {app => v1/app}/utils/date_time_helper.go | 0 {app => v1/app}/utils/file_handler.go | 0 {app => v1/app}/utils/file_service.go | 0 {app => v1/app}/utils/get_directory_structure.go | 0 {app => v1/app}/utils/git_command.go | 0 {app => v1/app}/utils/hash_id_generator.go | 0 {app => v1/app}/utils/image_utils.go | 0 {app => v1/app}/utils/random_string_generator.go | 0 .../design_nextjs_workflow_config.go | 0 .../python_django_workflow_config.go | 0 .../python_flask_workflow_config.go | 0 .../step_executors/code_generation_executor.go | 0 .../step_executors/git_commit_executor.go | 0 .../step_executors/git_make_branch_executor.go | 0 .../git_make_pull_request_executor.go | 0 .../step_executors/git_push_executor.go | 0 .../step_executors/graph/execution_state.go | 0 .../step_executors/graph/step_graph.go | 0 .../step_executors/graph/step_node.go | 0 .../impl/django_reset_db_step_executor.go | 0 .../impl/django_server_step_executor.go | 0 .../impl/flask_reset_db_step_executor.go | 0 .../impl/flask_sever_test_executor.go | 0 .../step_executors/impl/git_commit_executor.go | 0 .../step_executors/impl/git_make_branch_executor.go | 0 .../step_executors/impl/git_push_executor.go | 0 .../impl/gitness_make_pull_request_executor.go | 0 .../impl/next_js_server_test_executor.go | 0 .../impl/open_ai_code_generation_executor.go | 0 .../open_ai_next_js_code_generation_executor.go | 0 .../open_ai_next_js_update_code_file_executor.go | 0 .../impl/open_ai_update_code_file_executor.go | 0 .../impl/python_poetry_package_install_executor.go | 0 .../step_executors/package_install_executor.go | 0 .../step_executors/reset_db_step_executor.go | 0 .../step_executors/server_start_test_executor.go | 0 .../step_executors/step_executor.go | 0 .../step_executors/steps/base_step.go | 0 .../step_executors/steps/code_generate_step.go | 0 .../step_executors/steps/git_commit_step.go | 0 .../step_executors/steps/git_make_branch_step.go | 0 .../step_executors/steps/git_make_pull_request.go | 0 .../step_executors/steps/git_push_step.go | 0 .../step_executors/steps/package_install_step.go | 0 .../step_executors/steps/reset_db_step.go | 0 .../step_executors/steps/server_start_test_step.go | 0 .../step_executors/steps/step_names.go | 0 .../step_executors/steps/step_types.go | 0 .../step_executors/steps/update_code_file_step.go | 0 .../step_executors/steps/workflow_step.go | 0 .../step_executors/update_code_executor.go | 0 .../app}/workflow_executors/workflow_config.go | 0 .../workflow_executors/workflow_execution_args.go | 0 .../app}/workflow_executors/workflow_executor.go | 0 {bin => v1/bin}/migrations.sh | 0 docker-compose.yaml => v1/docker-compose.yaml | 0 {docker => v1/docker}/nginx/default.conf | 0 executor.go => v1/executor.go | 0 go.mod => v1/go.mod | 0 go.sum => v1/go.sum | 0 {gui => v1/gui}/.dockerignore | 0 {gui => v1/gui}/.eslintignore | 0 {gui => v1/gui}/.eslintrc.json | 0 {gui => v1/gui}/.gitignore | 0 {gui => v1/gui}/.prettierrc.js | 0 {gui => v1/gui}/.stylelintignore | 0 {gui => v1/gui}/.stylelintrc.json | 0 {gui => v1/gui}/Dockerfile | 0 {gui => v1/gui}/README.md | 0 {gui => v1/gui}/global.d.ts | 0 {gui => v1/gui}/next.config.mjs | 0 {gui => v1/gui}/package.json | 0 {gui => v1/gui}/postcss.config.mjs | 0 {gui => v1/gui}/prod.Dockerfile | 0 {gui => v1/gui}/public/arrows/back_arrow.svg | 0 {gui => v1/gui}/public/arrows/bottom_arrow_grey.svg | 0 .../gui}/public/arrows/bottom_arrow_thin_grey.svg | 0 .../gui}/public/arrows/bottom_arrow_white.svg | 0 {gui => v1/gui}/public/arrows/left_arrow_grey.svg | 0 .../gui}/public/arrows/right_arrow_thin_grey.svg | 0 .../Mark-Simonson-Proxima-Nova-Black-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Black.otf | 0 .../Mark-Simonson-Proxima-Nova-Bold-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Bold.otf | Bin .../Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Extrabold.otf | Bin .../Mark-Simonson-Proxima-Nova-Light-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Light.otf | Bin .../Mark-Simonson-Proxima-Nova-Regular-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Regular.otf | Bin .../Mark-Simonson-Proxima-Nova-Semibold-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Semibold.otf | Bin .../Mark-Simonson-Proxima-Nova-Thin-Italic.otf | Bin .../Mark-Simonson-Proxima-Nova-Thin.otf | Bin {gui => v1/gui}/public/icons/add_icon.svg | 0 .../public/icons/application_icons/discord_icon.svg | 0 .../public/icons/application_icons/github_icon.svg | 0 {gui => v1/gui}/public/icons/browser_icon.svg | 0 {gui => v1/gui}/public/icons/browser_icon_dark.svg | 0 {gui => v1/gui}/public/icons/chat_bubble_icon.svg | 0 {gui => v1/gui}/public/icons/claude_icon.svg | 0 {gui => v1/gui}/public/icons/clock_icon.svg | 0 {gui => v1/gui}/public/icons/close_icon.svg | 0 {gui => v1/gui}/public/icons/code_add_icon.svg | 0 {gui => v1/gui}/public/icons/code_minus_icon.svg | 0 {gui => v1/gui}/public/icons/copy_icon.svg | 0 {gui => v1/gui}/public/icons/delete_icon.svg | 0 {gui => v1/gui}/public/icons/done_dot.svg | 0 {gui => v1/gui}/public/icons/edit_icon.svg | 0 {gui => v1/gui}/public/icons/empty_files_icon.svg | 0 .../gui}/public/icons/horizontal_three_dots.svg | 0 {gui => v1/gui}/public/icons/in_review_dot.svg | 0 {gui => v1/gui}/public/icons/inprogress_dot.svg | 0 {gui => v1/gui}/public/icons/logout_icon.svg | 0 {gui => v1/gui}/public/icons/move_to_icon.svg | 0 {gui => v1/gui}/public/icons/openai_icon.svg | 0 .../gui}/public/icons/overview_warning_yellow.svg | 0 {gui => v1/gui}/public/icons/password_hidden.svg | 0 {gui => v1/gui}/public/icons/password_unhidden.svg | 0 {gui => v1/gui}/public/icons/play_icon.svg | 0 {gui => v1/gui}/public/icons/project_icon.svg | 0 .../public/icons/pull_requests/pr_closed_icon.svg | 0 .../public/icons/pull_requests/pr_merged_icon.svg | 0 .../icons/pull_requests/pr_open_grey_icon.svg | 0 .../public/icons/pull_requests/pr_open_icon.svg | 0 .../icons/pull_requests/pr_open_white_icon.svg | 0 .../public/icons/pull_requests/pr_ready_icon.svg | 0 .../gui}/public/icons/red_cross_delete_icon.svg | 0 {gui => v1/gui}/public/icons/search_icon.svg | 0 .../public/icons/selected/backend_workbench.svg | 0 {gui => v1/gui}/public/icons/selected/board.svg | 0 {gui => v1/gui}/public/icons/selected/code.svg | 0 {gui => v1/gui}/public/icons/selected/commits.svg | 0 {gui => v1/gui}/public/icons/selected/design.svg | 0 .../gui}/public/icons/selected/files_changed.svg | 0 .../public/icons/selected/frontend_workbench.svg | 0 .../gui}/public/icons/selected/instructions.svg | 0 {gui => v1/gui}/public/icons/selected/models.svg | 0 {gui => v1/gui}/public/icons/selected/overview.svg | 0 .../gui}/public/icons/selected/pull_requests.svg | 0 {gui => v1/gui}/public/icons/selected/reports.svg | 0 .../gui}/public/icons/selected/test_cases.svg | 0 .../gui}/public/icons/selected/visual_diff.svg | 0 {gui => v1/gui}/public/icons/settings_icon.svg | 0 {gui => v1/gui}/public/icons/todo_dot.svg | 0 .../public/icons/unselected/backend_workbench.svg | 0 {gui => v1/gui}/public/icons/unselected/board.svg | 0 {gui => v1/gui}/public/icons/unselected/code.svg | 0 {gui => v1/gui}/public/icons/unselected/commits.svg | 0 {gui => v1/gui}/public/icons/unselected/deploy.svg | 0 {gui => v1/gui}/public/icons/unselected/design.svg | 0 .../gui}/public/icons/unselected/files_changed.svg | 0 .../public/icons/unselected/frontend_workbench.svg | 0 .../gui}/public/icons/unselected/instructions.svg | 0 {gui => v1/gui}/public/icons/unselected/models.svg | 0 .../gui}/public/icons/unselected/overview.svg | 0 .../gui}/public/icons/unselected/pull_requests.svg | 0 {gui => v1/gui}/public/icons/unselected/reports.svg | 0 .../gui}/public/icons/unselected/test_cases.svg | 0 .../gui}/public/icons/unselected/visual_diff.svg | 0 {gui => v1/gui}/public/icons/upload_icon.svg | 0 {gui => v1/gui}/public/icons/vertical_line.svg | 0 {gui => v1/gui}/public/icons/white_dot.svg | 0 {gui => v1/gui}/public/images/django_image.png | Bin {gui => v1/gui}/public/images/fastapi_image.png | Bin {gui => v1/gui}/public/images/flask_image.png | Bin {gui => v1/gui}/public/images/nextjs_image.png | Bin {gui => v1/gui}/public/images/supercoder_image.svg | 0 {gui => v1/gui}/public/logos/github_logo.svg | 0 {gui => v1/gui}/public/logos/superagi_icon_logo.svg | 0 {gui => v1/gui}/public/logos/superagi_logo.svg | 0 .../gui}/public/logos/superagi_logo_round.svg | 0 {gui => v1/gui}/src/api/DashboardService.tsx | 0 {gui => v1/gui}/src/api/apiConfig.tsx | 0 .../src/app/(programmer)/board/board.module.css | 0 .../gui}/src/app/(programmer)/board/layout.tsx | 0 {gui => v1/gui}/src/app/(programmer)/board/page.tsx | 0 {gui => v1/gui}/src/app/(programmer)/code/Code.tsx | 0 {gui => v1/gui}/src/app/(programmer)/code/page.tsx | 0 .../gui}/src/app/(programmer)/design/layout.tsx | 0 .../gui}/src/app/(programmer)/design/page.tsx | 0 .../(programmer)/design/review/[story_id]/page.tsx | 0 .../design/review/[story_id]/review.module.css | 0 .../app/(programmer)/design_workbench/layout.tsx | 0 .../src/app/(programmer)/design_workbench/page.tsx | 0 {gui => v1/gui}/src/app/(programmer)/layout.tsx | 0 .../pull_request/PRList/GithubReviewButton.tsx | 0 .../app/(programmer)/pull_request/PRList/PRList.tsx | 0 .../pull_request/[pr_id]/CommitLogs.tsx | 0 .../pull_request/[pr_id]/FilesChanged.tsx | 0 .../(programmer)/pull_request/[pr_id]/OpenTag.ts | 0 .../app/(programmer)/pull_request/[pr_id]/page.tsx | 0 .../src/app/(programmer)/pull_request/layout.tsx | 0 .../gui}/src/app/(programmer)/pull_request/page.tsx | 0 .../src/app/(programmer)/pull_request/pr.module.css | 0 .../gui}/src/app/(programmer)/workbench/layout.tsx | 0 .../gui}/src/app/(programmer)/workbench/page.tsx | 0 {gui => v1/gui}/src/app/_app.css | 0 .../gui}/src/app/constants/ActivityLogType.ts | 0 {gui => v1/gui}/src/app/constants/BoardConstants.ts | 0 .../gui}/src/app/constants/NavbarConstants.ts | 0 .../gui}/src/app/constants/ProjectConstants.ts | 0 .../gui}/src/app/constants/PullRequestConstants.ts | 0 .../gui}/src/app/constants/SidebarConstants.ts | 0 .../gui}/src/app/constants/SkeletonConstants.ts | 0 {gui => v1/gui}/src/app/constants/UtilsConstants.ts | 0 {gui => v1/gui}/src/app/imagePath.tsx | 0 {gui => v1/gui}/src/app/layout.tsx | 0 {gui => v1/gui}/src/app/logout/page.tsx | 0 {gui => v1/gui}/src/app/page.tsx | 0 {gui => v1/gui}/src/app/projects/layout.tsx | 0 {gui => v1/gui}/src/app/projects/page.tsx | 0 .../gui}/src/app/projects/projects.module.css | 0 {gui => v1/gui}/src/app/providers.tsx | 0 .../src/app/settings/SettingsOptions/Models.tsx | 0 {gui => v1/gui}/src/app/settings/layout.tsx | 0 {gui => v1/gui}/src/app/settings/page.tsx | 0 {gui => v1/gui}/src/app/utils.tsx | 0 .../gui}/src/components/BackButton/BackButton.tsx | 0 .../CustomContainers/CustomContainers.tsx | 0 .../CustomContainers/container.module.css | 0 .../CustomDiffEditor/MonacoDiffEditor.tsx | 0 .../src/components/CustomDiffEditor/diff.module.css | 0 .../src/components/CustomDrawer/CustomDrawer.tsx | 0 .../src/components/CustomDrawer/drawer.module.css | 0 .../components/CustomDropdown/CustomDropdown.tsx | 0 .../components/CustomDropdown/dropdown.module.css | 0 .../gui}/src/components/CustomInput/CustomInput.tsx | 0 .../src/components/CustomInput/input.module.css | 0 .../src/components/CustomLoaders/CustomLoaders.tsx | 0 .../gui}/src/components/CustomLoaders/Loader.tsx | 0 .../src/components/CustomLoaders/SkeletonLoader.tsx | 0 .../src/components/CustomLoaders/loader.module.css | 0 .../gui}/src/components/CustomModal/CustomModal.tsx | 0 .../src/components/CustomModal/modal.module.css | 0 .../src/components/CustomSelect/CustomSelect.tsx | 0 .../src/components/CustomSelect/select.module.css | 0 .../src/components/CustomSidebar/CustomSidebar.tsx | 0 .../src/components/CustomSidebar/sidebar.module.css | 0 .../gui}/src/components/CustomTabs/CustomTabs.tsx | 0 .../gui}/src/components/CustomTabs/tabs.module.css | 0 .../gui}/src/components/CustomTag/CustomTag.tsx | 0 .../gui}/src/components/CustomTag/tag.module.css | 0 .../components/CustomTimeline/CustomTimeline.tsx | 0 .../components/CustomTimeline/timeline.module.css | 0 .../DesignStoryComponents/CreateEditDesignStory.tsx | 0 .../DesignStoryComponents/DesignStoryDetails.tsx | 0 .../DesignStoryComponents/DesignStoryList.tsx | 0 .../DesignStoryComponents/FrontendCodeSection.tsx | 0 .../components/DesignStoryComponents/ReviewList.tsx | 0 .../DesignStoryComponents/desingStory.module.css | 0 .../gui}/src/components/DiffViewer/DiffViewer.tsx | 0 .../gui}/src/components/DiffViewer/diff.module.css | 0 .../HomeComponents/CreateOrEditProjectBody.tsx | 0 .../components/HomeComponents/GithubStarModal.tsx | 0 .../src/components/HomeComponents/LandingPage.tsx | 0 .../src/components/HomeComponents/home.module.css | 0 .../src/components/ImageComponents/CustomImage.tsx | 0 .../ImageComponents/CustomImageSelector.tsx | 0 .../components/ImageComponents/CustomTextImage.tsx | 0 .../src/components/ImageComponents/image.module.css | 0 .../gui}/src/components/LayoutComponents/NavBar.tsx | 0 .../src/components/LayoutComponents/SideBar.tsx | 0 .../gui}/src/components/LayoutComponents/style.css | 0 .../src/components/RebuildModal/RebuildModal.tsx | 0 .../components/StoryComponents/CreateEditStory.tsx | 0 .../components/StoryComponents/InReviewIssue.tsx | 0 .../src/components/StoryComponents/InputSection.tsx | 0 .../src/components/StoryComponents/Instructions.tsx | 0 .../src/components/StoryComponents/Overview.tsx | 0 .../components/StoryComponents/SetupModelModal.tsx | 0 .../src/components/StoryComponents/StoryDetails.tsx | 0 .../src/components/StoryComponents/TestCases.tsx | 0 .../src/components/StoryComponents/story.module.css | 0 .../src/components/SyntaxDisplay/SyntaxDisplay.tsx | 0 .../WorkBenchComponents/ActiveWorkbench.tsx | 0 .../src/components/WorkBenchComponents/Activity.tsx | 0 .../WorkBenchComponents/BackendWorkbench.tsx | 0 .../src/components/WorkBenchComponents/Browser.tsx | 0 .../WorkBenchComponents/DesignWorkbench.tsx | 0 .../WorkBenchComponents/StoryDetailsWorkbench.tsx | 0 .../workbenchComponents.module.css | 0 {gui => v1/gui}/src/context/Boards.tsx | 0 {gui => v1/gui}/src/context/Design.tsx | 0 {gui => v1/gui}/src/context/PullRequests.tsx | 0 {gui => v1/gui}/src/context/SocketContext.tsx | 0 {gui => v1/gui}/src/context/UserContext.tsx | 0 {gui => v1/gui}/src/context/Workbench.tsx | 0 {gui => v1/gui}/src/hooks/useProjectDropdown.tsx | 0 {gui => v1/gui}/src/middleware.ts | 0 {gui => v1/gui}/src/utils/SocketUtils.tsx | 0 {gui => v1/gui}/tailwind.config.ts | 0 {gui => v1/gui}/tsconfig.json | 0 {gui => v1/gui}/types/authTypes.ts | 0 {gui => v1/gui}/types/customComponentTypes.ts | 0 {gui => v1/gui}/types/designStoryTypes.ts | 0 {gui => v1/gui}/types/imageComponentsTypes.ts | 0 {gui => v1/gui}/types/modelsTypes.ts | 0 {gui => v1/gui}/types/navbarTypes.ts | 0 {gui => v1/gui}/types/projectsTypes.ts | 0 {gui => v1/gui}/types/pullRequestsTypes.ts | 0 {gui => v1/gui}/types/settingTypes.ts | 0 {gui => v1/gui}/types/sidebarTypes.ts | 0 {gui => v1/gui}/types/storyTypes.ts | 0 {gui => v1/gui}/types/workbenchTypes.ts | 0 {gui => v1/gui}/yarn.lock | 0 {ide => v1/ide}/node/Dockerfile | 0 {ide => v1/ide}/node/config.yaml | 0 {ide => v1/ide}/node/settings.json | 0 {ide => v1/ide}/python/Dockerfile | 0 {ide => v1/ide}/python/config.yaml | 0 {ide => v1/ide}/python/initialise.sh | 0 {ide => v1/ide}/python/settings.json | 0 server.go => v1/server.go | 0 startup-worker.sh => v1/startup-worker.sh | 0 startup.sh => v1/startup.sh | 0 worker.go => v1/worker.go | 0 .../workspace-service}/.gitignore | 0 .../workspace-service}/Dockerfile | 0 .../workspace-service}/app/clients/docker_client.go | 0 .../workspace-service}/app/clients/k8s_client.go | 0 .../app/clients/k8s_controller_client.go | 0 .../workspace-service}/app/config/config.go | 0 .../workspace-service}/app/config/env.go | 0 .../app/config/frontend_workspace_config.go | 0 .../workspace-service}/app/config/new_relic.go | 0 .../workspace-service}/app/config/workspace_jobs.go | 0 .../app/config/workspace_service.go | 0 .../app/controllers/health_controller.go | 0 .../app/controllers/jobs_controller.go | 0 .../app/controllers/workspace_controller.go | 0 .../workspace-service}/app/models/dto/create_job.go | 0 .../app/models/dto/create_workspace.go | 0 .../app/models/dto/workspace_details.go | 0 .../app/services/impl/docker_job_service.go | 0 .../app/services/impl/docker_workspace_service.go | 0 .../app/services/impl/k8s_job_service.go | 0 .../app/services/impl/k8s_workspace_service.go | 0 .../workspace-service}/app/services/job_service.go | 0 .../app/services/workspace_service.go | 0 .../workspace-service}/app/utils/fs_utils.go | 0 {workspace-service => v1/workspace-service}/go.mod | 0 {workspace-service => v1/workspace-service}/go.sum | 0 .../workspace-service}/server.go | 0 .../workspace-service}/templates/django/.gitignore | 0 .../templates/django/.vscode/tasks.json | 0 .../workspace-service}/templates/django/manage.py | 0 .../templates/django/myapp/__init__.py | 0 .../templates/django/myapp/admin.py | 0 .../templates/django/myapp/app.py | 0 .../templates/django/myapp/migrations/__init__.py | 0 .../templates/django/myapp/models.py | 0 .../templates/django/myapp/tests.py | 0 .../templates/django/myapp/urls.py | 0 .../templates/django/myapp/views.py | 0 .../templates/django/project/__init__.py | 0 .../templates/django/project/asgi.py | 0 .../templates/django/project/settings.py | 0 .../templates/django/project/urls.py | 0 .../templates/django/project/wsgi.py | 0 .../templates/django/pyproject.toml | 0 .../templates/django/server_test.txt | 0 .../templates/django/static/css/styles.css | 0 .../templates/django/static/js/scripts.js | 0 .../templates/django/templates/about.html | 0 .../templates/django/templates/home.html | 0 .../django/templates/registration/login.html | 0 .../templates/django/terminal.txt | 0 .../workspace-service}/templates/flask/.gitignore | 0 .../templates/flask/.vscode/tasks.json | 0 .../workspace-service}/templates/flask/__init__.py | 0 .../workspace-service}/templates/flask/app.py | 0 .../templates/flask/models/__init__.py | 0 .../templates/flask/models/model.py | 0 .../templates/flask/pyproject.toml | 0 .../templates/flask/static/css/style.css | 0 .../templates/flask/static/js/main.js | 0 .../templates/flask/templates/index.html | 0 .../templates/nextjs/.eslintrc.json | 0 .../workspace-service}/templates/nextjs/.gitignore | 0 .../workspace-service}/templates/nextjs/README.md | 0 .../templates/nextjs/app/favicon.ico | Bin .../templates/nextjs/app/globals.css | 0 .../templates/nextjs/app/layout.tsx | 0 .../templates/nextjs/app/page.tsx | 0 .../templates/nextjs/next.config.mjs | 0 .../templates/nextjs/package-lock.json | 0 .../templates/nextjs/package.json | 0 .../templates/nextjs/postcss.config.mjs | 0 .../templates/nextjs/public/next.svg | 0 .../templates/nextjs/public/vercel.svg | 0 .../templates/nextjs/tailwind.config.ts | 0 .../templates/nextjs/tsconfig.json | 0 619 files changed, 0 insertions(+), 0 deletions(-) rename .dockerignore => v1/.dockerignore (100%) rename {.github => v1/.github}/ISSUE_TEMPLATE/1.BUG_REPORT.yml (100%) rename {.github => v1/.github}/PULL_REQUEST_TEMPLATE.md (100%) rename {.github => v1/.github}/workflows/build-ide-image.yaml (100%) rename CODE_OF_CONDUCT.md => v1/CODE_OF_CONDUCT.md (100%) rename Dockerfile => v1/Dockerfile (100%) rename LICENSE => v1/LICENSE (100%) rename README.md => v1/README.md (100%) rename {app => v1/app}/client/git_provider/gitness_git_provider.go (100%) rename {app => v1/app}/client/http_client.go (100%) rename {app => v1/app}/client/workspace/workspace_service.go (100%) rename {app => v1/app}/config/ai_developer_execution.go (100%) rename {app => v1/app}/config/aws_config.go (100%) rename {app => v1/app}/config/config.go (100%) rename {app => v1/app}/config/db.go (100%) rename {app => v1/app}/config/env.go (100%) rename {app => v1/app}/config/filestore.go (100%) rename {app => v1/app}/config/frontend_workspace.go (100%) rename {app => v1/app}/config/github_oauth.go (100%) rename {app => v1/app}/config/gitness.go (100%) rename {app => v1/app}/config/jwt.go (100%) rename {app => v1/app}/config/local_filestore_config.go (100%) rename {app => v1/app}/config/logger.go (100%) rename {app => v1/app}/config/model.go (100%) rename {app => v1/app}/config/monitoring_slack.go (100%) rename {app => v1/app}/config/new_relic.go (100%) rename {app => v1/app}/config/redis.go (100%) rename {app => v1/app}/config/s3_config.go (100%) rename {app => v1/app}/config/s3_filestore_config.go (100%) rename {app => v1/app}/config/workspace_config.go (100%) rename {app => v1/app}/config/workspace_service.go (100%) rename {app => v1/app}/constants/app_env.go (100%) rename {app => v1/app}/constants/asynq_task_names.go (100%) rename {app => v1/app}/constants/filestore_type.go (100%) rename {app => v1/app}/constants/models.go (100%) rename {app => v1/app}/constants/pr_status.go (100%) rename {app => v1/app}/constants/pr_type.go (100%) rename {app => v1/app}/constants/project_workflows.go (100%) rename {app => v1/app}/constants/redis_ttl.go (100%) rename {app => v1/app}/constants/story_status.go (100%) rename {app => v1/app}/constants/story_type.go (100%) rename {app => v1/app}/controllers/activity_log_controller.go (100%) rename {app => v1/app}/controllers/auth_controller.go (100%) rename {app => v1/app}/controllers/design_story_review_controller.go (100%) rename {app => v1/app}/controllers/execution_controller.go (100%) rename {app => v1/app}/controllers/execution_output_controller.go (100%) rename {app => v1/app}/controllers/health.go (100%) rename {app => v1/app}/controllers/llm_api_key.go (100%) rename {app => v1/app}/controllers/project_controller.go (100%) rename {app => v1/app}/controllers/pull_request_comments_controller.go (100%) rename {app => v1/app}/controllers/pull_request_controller.go (100%) rename {app => v1/app}/controllers/story_controller.go (100%) rename {app => v1/app}/controllers/user_controller.go (100%) rename {app => v1/app}/db/migrations/000001_create_users_table.down.sql (100%) rename {app => v1/app}/db/migrations/000001_create_users_table.up.sql (100%) rename {app => v1/app}/db/migrations/000002_create_organisations_table.down.sql (100%) rename {app => v1/app}/db/migrations/000002_create_organisations_table.up.sql (100%) rename {app => v1/app}/db/migrations/000003_create_projects_table.down.sql (100%) rename {app => v1/app}/db/migrations/000003_create_projects_table.up.sql (100%) rename {app => v1/app}/db/migrations/000004_create_stories_table.down.sql (100%) rename {app => v1/app}/db/migrations/000004_create_stories_table.up.sql (100%) rename {app => v1/app}/db/migrations/000005_create_story_files_table.down.sql (100%) rename {app => v1/app}/db/migrations/000005_create_story_files_table.up.sql (100%) rename {app => v1/app}/db/migrations/000006_create_story_instructions_table.down.sql (100%) rename {app => v1/app}/db/migrations/000006_create_story_instructions_table.up.sql (100%) rename {app => v1/app}/db/migrations/000007_create_story_test_cases_table.down.sql (100%) rename {app => v1/app}/db/migrations/000007_create_story_test_cases_table.up.sql (100%) rename {app => v1/app}/db/migrations/000008_create_executions_table.down.sql (100%) rename {app => v1/app}/db/migrations/000008_create_executions_table.up.sql (100%) rename {app => v1/app}/db/migrations/000009_create_execution_steps_table.down.sql (100%) rename {app => v1/app}/db/migrations/000009_create_execution_steps_table.up.sql (100%) rename {app => v1/app}/db/migrations/000010_create_execution_outputs_table.down.sql (100%) rename {app => v1/app}/db/migrations/000010_create_execution_outputs_table.up.sql (100%) rename {app => v1/app}/db/migrations/000011_create_activity_logs_table.down.sql (100%) rename {app => v1/app}/db/migrations/000011_create_activity_logs_table.up.sql (100%) rename {app => v1/app}/db/migrations/000012_create_execution_files_table.down.sql (100%) rename {app => v1/app}/db/migrations/000012_create_execution_files_table.up.sql (100%) rename {app => v1/app}/db/migrations/000013_add_reexecution_to_executions.down.sql (100%) rename {app => v1/app}/db/migrations/000013_add_reexecution_to_executions.up.sql (100%) rename {app => v1/app}/db/migrations/000014_alter_execution_output_table.down.sql (100%) rename {app => v1/app}/db/migrations/000014_alter_execution_output_table.up.sql (100%) rename {app => v1/app}/db/migrations/000015_create_pull_request_table.down.sql (100%) rename {app => v1/app}/db/migrations/000015_create_pull_request_table.up.sql (100%) rename {app => v1/app}/db/migrations/000016_create_pull_request_comments_table.down.sql (100%) rename {app => v1/app}/db/migrations/000016_create_pull_request_comments_table.up.sql (100%) rename {app => v1/app}/db/migrations/000017_remove_pull_req_exec_output.down.sql (100%) rename {app => v1/app}/db/migrations/000017_remove_pull_req_exec_output.up.sql (100%) rename {app => v1/app}/db/migrations/000018_add_is_deleted_column_story.down.sql (100%) rename {app => v1/app}/db/migrations/000018_add_is_deleted_column_story.up.sql (100%) rename {app => v1/app}/db/migrations/000019_remove_pull_req_exec_output.down.sql (100%) rename {app => v1/app}/db/migrations/000019_remove_pull_req_exec_output.up.sql (100%) rename {app => v1/app}/db/migrations/000020_comment_data_type_change.down.sql (100%) rename {app => v1/app}/db/migrations/000020_comment_data_type_change.up.sql (100%) rename {app => v1/app}/db/migrations/000021_llm_api_keys.down.sql (100%) rename {app => v1/app}/db/migrations/000021_llm_api_keys.up.sql (100%) rename {app => v1/app}/db/migrations/000022_add_supercoder_user_and_org.down.sql (100%) rename {app => v1/app}/db/migrations/000022_add_supercoder_user_and_org.up.sql (100%) rename {app => v1/app}/db/migrations/000023_remove_supercoder_user_and_org.down.sql (100%) rename {app => v1/app}/db/migrations/000023_remove_supercoder_user_and_org.up.sql (100%) rename {app => v1/app}/db/migrations/000024_alter_stories_table.down.sql (100%) rename {app => v1/app}/db/migrations/000024_alter_stories_table.up.sql (100%) rename {app => v1/app}/db/migrations/000025_create_design_story_review_table.down.sql (100%) rename {app => v1/app}/db/migrations/000025_create_design_story_review_table.up.sql (100%) rename {app => v1/app}/db/migrations/000026_update_api_key_length.down.sql (100%) rename {app => v1/app}/db/migrations/000026_update_api_key_length.up.sql (100%) rename {app => v1/app}/db/migrations/000027_add_frontend_framework_to_project.down.sql (100%) rename {app => v1/app}/db/migrations/000027_add_frontend_framework_to_project.up.sql (100%) rename {app => v1/app}/db/migrations/000028_add_type_in_story.down.sql (100%) rename {app => v1/app}/db/migrations/000028_add_type_in_story.up.sql (100%) rename {app => v1/app}/db/migrations/000029_add_prtype_in_pull_requests_table.down.sql (100%) rename {app => v1/app}/db/migrations/000029_add_prtype_in_pull_requests_table.up.sql (100%) rename {app => v1/app}/db/migrations/000030_inc_length_story_table.down.sql (100%) rename {app => v1/app}/db/migrations/000030_inc_length_story_table.up.sql (100%) rename {app => v1/app}/db/migrations/000031_rename_framework_in_projects.down.sql (100%) rename {app => v1/app}/db/migrations/000031_rename_framework_in_projects.up.sql (100%) rename {app => v1/app}/gateways/websocket.go (100%) rename {app => v1/app}/gateways/websocket_gateway.go (100%) rename {app => v1/app}/llms/claude.go (100%) rename {app => v1/app}/llms/open_ai.go (100%) rename {app => v1/app}/middleware/organisation_authorization.go (100%) rename {app => v1/app}/middleware/project_authorization.go (100%) rename {app => v1/app}/middleware/pull_request_authorization.go (100%) rename {app => v1/app}/middleware/story_authorization.go (100%) rename {app => v1/app}/middleware/user_authorization.go (100%) rename {app => v1/app}/models/activity_log.go (100%) rename {app => v1/app}/models/design_story_review.go (100%) rename {app => v1/app}/models/dtos/asynq_task/create_job_payload.go (100%) rename {app => v1/app}/models/dtos/asynq_task/delete_workspace_task.go (100%) rename {app => v1/app}/models/dtos/gitness/types.go (100%) rename {app => v1/app}/models/dtos/llm_api_key/llm_api_key_return.go (100%) rename {app => v1/app}/models/execution.go (100%) rename {app => v1/app}/models/execution_file.go (100%) rename {app => v1/app}/models/execution_output.go (100%) rename {app => v1/app}/models/execution_step.go (100%) rename {app => v1/app}/models/llm_api_key.go (100%) rename {app => v1/app}/models/organisation.go (100%) rename {app => v1/app}/models/project.go (100%) rename {app => v1/app}/models/pull_request.go (100%) rename {app => v1/app}/models/pull_request_comment.go (100%) rename {app => v1/app}/models/story.go (100%) rename {app => v1/app}/models/story_file.go (100%) rename {app => v1/app}/models/story_instruction.go (100%) rename {app => v1/app}/models/story_test_case.go (100%) rename {app => v1/app}/models/types/errors.go (100%) rename {app => v1/app}/models/types/json_map.go (100%) rename {app => v1/app}/models/user.go (100%) rename {app => v1/app}/monitoring/slack_alerts.go (100%) rename {app => v1/app}/prompts/nextjs/ai_frontend_developer.txt (100%) rename {app => v1/app}/prompts/nextjs/ai_frontend_developer_edit_code.txt (100%) rename {app => v1/app}/prompts/nextjs/next_js_build_checker.txt (100%) rename {app => v1/app}/prompts/python/ai_developer_django.txt (100%) rename {app => v1/app}/prompts/python/ai_developer_flask.txt (100%) rename {app => v1/app}/repositories/activity_log.go (100%) rename {app => v1/app}/repositories/design_story_review.go (100%) rename {app => v1/app}/repositories/execution.go (100%) rename {app => v1/app}/repositories/execution_output.go (100%) rename {app => v1/app}/repositories/execution_step.go (100%) rename {app => v1/app}/repositories/llm_api_key.go (100%) rename {app => v1/app}/repositories/options.go (100%) rename {app => v1/app}/repositories/organisation.go (100%) rename {app => v1/app}/repositories/project.go (100%) rename {app => v1/app}/repositories/project_connection_repository.go (100%) rename {app => v1/app}/repositories/pull_request.go (100%) rename {app => v1/app}/repositories/pull_request_comment.go (100%) rename {app => v1/app}/repositories/repository.go (100%) rename {app => v1/app}/repositories/story.go (100%) rename {app => v1/app}/repositories/story_file.go (100%) rename {app => v1/app}/repositories/story_instruction.go (100%) rename {app => v1/app}/repositories/story_test_case.go (100%) rename {app => v1/app}/repositories/user.go (100%) rename {app => v1/app}/services/activity_log_service.go (100%) rename {app => v1/app}/services/auth/auth_provider.go (100%) rename {app => v1/app}/services/auth/authenticator.go (100%) rename {app => v1/app}/services/auth/email_auth_service.go (100%) rename {app => v1/app}/services/auth/github_auth_service.go (100%) rename {app => v1/app}/services/auth/jwt_authentication_middleware.go (100%) rename {app => v1/app}/services/code_download_service.go (100%) rename {app => v1/app}/services/design_story_review_service.go (100%) rename {app => v1/app}/services/execution_output_service.go (100%) rename {app => v1/app}/services/execution_service.go (100%) rename {app => v1/app}/services/execution_step_service.go (100%) rename {app => v1/app}/services/filestore/filestore.go (100%) rename {app => v1/app}/services/filestore/impl/local.go (100%) rename {app => v1/app}/services/filestore/impl/s3.go (100%) rename {app => v1/app}/services/git_providers/git_provider.go (100%) rename {app => v1/app}/services/git_providers/gitness_git_provider_service.go (100%) rename {app => v1/app}/services/llm_api_key.go (100%) rename {app => v1/app}/services/organisation_service.go (100%) rename {app => v1/app}/services/project_notification_service.go (100%) rename {app => v1/app}/services/project_service.go (100%) rename {app => v1/app}/services/pull_request_comments_service.go (100%) rename {app => v1/app}/services/pull_request_service.go (100%) rename {app => v1/app}/services/story_service.go (100%) rename {app => v1/app}/services/user_service.go (100%) rename {app => v1/app}/tasks/check_execution_status.go (100%) rename {app => v1/app}/tasks/create_execution_job_task.go (100%) rename {app => v1/app}/tasks/delete_workspace_task_handler.go (100%) rename {app => v1/app}/types/request/create_comment_request.go (100%) rename {app => v1/app}/types/request/create_design_story_comment_request.go (100%) rename {app => v1/app}/types/request/create_job_request.go (100%) rename {app => v1/app}/types/request/create_pr_request.go (100%) rename {app => v1/app}/types/request/create_project_request.go (100%) rename {app => v1/app}/types/request/create_story_request.go (100%) rename {app => v1/app}/types/request/create_user_request.go (100%) rename {app => v1/app}/types/request/create_worspace_request.go (100%) rename {app => v1/app}/types/request/delete_story_by_id.go (100%) rename {app => v1/app}/types/request/llm_api_key_request.go (100%) rename {app => v1/app}/types/request/merge_pull_request.go (100%) rename {app => v1/app}/types/request/retrieve_code_request.go (100%) rename {app => v1/app}/types/request/update_project_request.go (100%) rename {app => v1/app}/types/request/update_story_request.go (100%) rename {app => v1/app}/types/request/update_story_status_request.go (100%) rename {app => v1/app}/types/request/user_signin_request.go (100%) rename {app => v1/app}/types/response/create_workspace_response.go (100%) rename {app => v1/app}/types/response/get_all_commits_response.go (100%) rename {app => v1/app}/types/response/get_all_projects_response.go (100%) rename {app => v1/app}/types/response/get_all_pull_requests.go (100%) rename {app => v1/app}/types/response/get_all_stories_by_project_id.go (100%) rename {app => v1/app}/types/response/get_code_for_design_story.go (100%) rename {app => v1/app}/types/response/get_design_stories_by_project_id.go (100%) rename {app => v1/app}/types/response/get_story_by_id_response.go (100%) rename {app => v1/app}/types/response/get_story_response.go (100%) rename {app => v1/app}/types/response/user_response.go (100%) rename {app => v1/app}/utils/api_key.go (100%) rename {app => v1/app}/utils/asynq_helper.go (100%) rename {app => v1/app}/utils/command_helper.go (100%) rename {app => v1/app}/utils/date_time_helper.go (100%) rename {app => v1/app}/utils/file_handler.go (100%) rename {app => v1/app}/utils/file_service.go (100%) rename {app => v1/app}/utils/get_directory_structure.go (100%) rename {app => v1/app}/utils/git_command.go (100%) rename {app => v1/app}/utils/hash_id_generator.go (100%) rename {app => v1/app}/utils/image_utils.go (100%) rename {app => v1/app}/utils/random_string_generator.go (100%) rename {app => v1/app}/workflow_executors/design_nextjs_workflow_config.go (100%) rename {app => v1/app}/workflow_executors/python_django_workflow_config.go (100%) rename {app => v1/app}/workflow_executors/python_flask_workflow_config.go (100%) rename {app => v1/app}/workflow_executors/step_executors/code_generation_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/git_commit_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/git_make_branch_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/git_make_pull_request_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/git_push_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/graph/execution_state.go (100%) rename {app => v1/app}/workflow_executors/step_executors/graph/step_graph.go (100%) rename {app => v1/app}/workflow_executors/step_executors/graph/step_node.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/django_reset_db_step_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/django_server_step_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/flask_reset_db_step_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/flask_sever_test_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/git_commit_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/git_make_branch_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/git_push_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/gitness_make_pull_request_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/next_js_server_test_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/open_ai_code_generation_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/open_ai_next_js_code_generation_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/open_ai_next_js_update_code_file_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/open_ai_update_code_file_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/impl/python_poetry_package_install_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/package_install_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/reset_db_step_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/server_start_test_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/step_executor.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/base_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/code_generate_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/git_commit_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/git_make_branch_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/git_make_pull_request.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/git_push_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/package_install_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/reset_db_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/server_start_test_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/step_names.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/step_types.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/update_code_file_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/steps/workflow_step.go (100%) rename {app => v1/app}/workflow_executors/step_executors/update_code_executor.go (100%) rename {app => v1/app}/workflow_executors/workflow_config.go (100%) rename {app => v1/app}/workflow_executors/workflow_execution_args.go (100%) rename {app => v1/app}/workflow_executors/workflow_executor.go (100%) rename {bin => v1/bin}/migrations.sh (100%) rename docker-compose.yaml => v1/docker-compose.yaml (100%) rename {docker => v1/docker}/nginx/default.conf (100%) rename executor.go => v1/executor.go (100%) rename go.mod => v1/go.mod (100%) rename go.sum => v1/go.sum (100%) rename {gui => v1/gui}/.dockerignore (100%) rename {gui => v1/gui}/.eslintignore (100%) rename {gui => v1/gui}/.eslintrc.json (100%) rename {gui => v1/gui}/.gitignore (100%) rename {gui => v1/gui}/.prettierrc.js (100%) rename {gui => v1/gui}/.stylelintignore (100%) rename {gui => v1/gui}/.stylelintrc.json (100%) rename {gui => v1/gui}/Dockerfile (100%) rename {gui => v1/gui}/README.md (100%) rename {gui => v1/gui}/global.d.ts (100%) rename {gui => v1/gui}/next.config.mjs (100%) rename {gui => v1/gui}/package.json (100%) rename {gui => v1/gui}/postcss.config.mjs (100%) rename {gui => v1/gui}/prod.Dockerfile (100%) rename {gui => v1/gui}/public/arrows/back_arrow.svg (100%) rename {gui => v1/gui}/public/arrows/bottom_arrow_grey.svg (100%) rename {gui => v1/gui}/public/arrows/bottom_arrow_thin_grey.svg (100%) rename {gui => v1/gui}/public/arrows/bottom_arrow_white.svg (100%) rename {gui => v1/gui}/public/arrows/left_arrow_grey.svg (100%) rename {gui => v1/gui}/public/arrows/right_arrow_thin_grey.svg (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin-Italic.otf (100%) rename {gui => v1/gui}/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin.otf (100%) rename {gui => v1/gui}/public/icons/add_icon.svg (100%) rename {gui => v1/gui}/public/icons/application_icons/discord_icon.svg (100%) rename {gui => v1/gui}/public/icons/application_icons/github_icon.svg (100%) rename {gui => v1/gui}/public/icons/browser_icon.svg (100%) rename {gui => v1/gui}/public/icons/browser_icon_dark.svg (100%) rename {gui => v1/gui}/public/icons/chat_bubble_icon.svg (100%) rename {gui => v1/gui}/public/icons/claude_icon.svg (100%) rename {gui => v1/gui}/public/icons/clock_icon.svg (100%) rename {gui => v1/gui}/public/icons/close_icon.svg (100%) rename {gui => v1/gui}/public/icons/code_add_icon.svg (100%) rename {gui => v1/gui}/public/icons/code_minus_icon.svg (100%) rename {gui => v1/gui}/public/icons/copy_icon.svg (100%) rename {gui => v1/gui}/public/icons/delete_icon.svg (100%) rename {gui => v1/gui}/public/icons/done_dot.svg (100%) rename {gui => v1/gui}/public/icons/edit_icon.svg (100%) rename {gui => v1/gui}/public/icons/empty_files_icon.svg (100%) rename {gui => v1/gui}/public/icons/horizontal_three_dots.svg (100%) rename {gui => v1/gui}/public/icons/in_review_dot.svg (100%) rename {gui => v1/gui}/public/icons/inprogress_dot.svg (100%) rename {gui => v1/gui}/public/icons/logout_icon.svg (100%) rename {gui => v1/gui}/public/icons/move_to_icon.svg (100%) rename {gui => v1/gui}/public/icons/openai_icon.svg (100%) rename {gui => v1/gui}/public/icons/overview_warning_yellow.svg (100%) rename {gui => v1/gui}/public/icons/password_hidden.svg (100%) rename {gui => v1/gui}/public/icons/password_unhidden.svg (100%) rename {gui => v1/gui}/public/icons/play_icon.svg (100%) rename {gui => v1/gui}/public/icons/project_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_closed_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_merged_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_open_grey_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_open_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_open_white_icon.svg (100%) rename {gui => v1/gui}/public/icons/pull_requests/pr_ready_icon.svg (100%) rename {gui => v1/gui}/public/icons/red_cross_delete_icon.svg (100%) rename {gui => v1/gui}/public/icons/search_icon.svg (100%) rename {gui => v1/gui}/public/icons/selected/backend_workbench.svg (100%) rename {gui => v1/gui}/public/icons/selected/board.svg (100%) rename {gui => v1/gui}/public/icons/selected/code.svg (100%) rename {gui => v1/gui}/public/icons/selected/commits.svg (100%) rename {gui => v1/gui}/public/icons/selected/design.svg (100%) rename {gui => v1/gui}/public/icons/selected/files_changed.svg (100%) rename {gui => v1/gui}/public/icons/selected/frontend_workbench.svg (100%) rename {gui => v1/gui}/public/icons/selected/instructions.svg (100%) rename {gui => v1/gui}/public/icons/selected/models.svg (100%) rename {gui => v1/gui}/public/icons/selected/overview.svg (100%) rename {gui => v1/gui}/public/icons/selected/pull_requests.svg (100%) rename {gui => v1/gui}/public/icons/selected/reports.svg (100%) rename {gui => v1/gui}/public/icons/selected/test_cases.svg (100%) rename {gui => v1/gui}/public/icons/selected/visual_diff.svg (100%) rename {gui => v1/gui}/public/icons/settings_icon.svg (100%) rename {gui => v1/gui}/public/icons/todo_dot.svg (100%) rename {gui => v1/gui}/public/icons/unselected/backend_workbench.svg (100%) rename {gui => v1/gui}/public/icons/unselected/board.svg (100%) rename {gui => v1/gui}/public/icons/unselected/code.svg (100%) rename {gui => v1/gui}/public/icons/unselected/commits.svg (100%) rename {gui => v1/gui}/public/icons/unselected/deploy.svg (100%) rename {gui => v1/gui}/public/icons/unselected/design.svg (100%) rename {gui => v1/gui}/public/icons/unselected/files_changed.svg (100%) rename {gui => v1/gui}/public/icons/unselected/frontend_workbench.svg (100%) rename {gui => v1/gui}/public/icons/unselected/instructions.svg (100%) rename {gui => v1/gui}/public/icons/unselected/models.svg (100%) rename {gui => v1/gui}/public/icons/unselected/overview.svg (100%) rename {gui => v1/gui}/public/icons/unselected/pull_requests.svg (100%) rename {gui => v1/gui}/public/icons/unselected/reports.svg (100%) rename {gui => v1/gui}/public/icons/unselected/test_cases.svg (100%) rename {gui => v1/gui}/public/icons/unselected/visual_diff.svg (100%) rename {gui => v1/gui}/public/icons/upload_icon.svg (100%) rename {gui => v1/gui}/public/icons/vertical_line.svg (100%) rename {gui => v1/gui}/public/icons/white_dot.svg (100%) rename {gui => v1/gui}/public/images/django_image.png (100%) rename {gui => v1/gui}/public/images/fastapi_image.png (100%) rename {gui => v1/gui}/public/images/flask_image.png (100%) rename {gui => v1/gui}/public/images/nextjs_image.png (100%) rename {gui => v1/gui}/public/images/supercoder_image.svg (100%) rename {gui => v1/gui}/public/logos/github_logo.svg (100%) rename {gui => v1/gui}/public/logos/superagi_icon_logo.svg (100%) rename {gui => v1/gui}/public/logos/superagi_logo.svg (100%) rename {gui => v1/gui}/public/logos/superagi_logo_round.svg (100%) rename {gui => v1/gui}/src/api/DashboardService.tsx (100%) rename {gui => v1/gui}/src/api/apiConfig.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/board/board.module.css (100%) rename {gui => v1/gui}/src/app/(programmer)/board/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/board/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/code/Code.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/code/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/design/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/design/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/design/review/[story_id]/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/design/review/[story_id]/review.module.css (100%) rename {gui => v1/gui}/src/app/(programmer)/design_workbench/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/design_workbench/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/PRList/GithubReviewButton.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/PRList/PRList.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/[pr_id]/CommitLogs.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/[pr_id]/FilesChanged.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/[pr_id]/OpenTag.ts (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/[pr_id]/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/page.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/pull_request/pr.module.css (100%) rename {gui => v1/gui}/src/app/(programmer)/workbench/layout.tsx (100%) rename {gui => v1/gui}/src/app/(programmer)/workbench/page.tsx (100%) rename {gui => v1/gui}/src/app/_app.css (100%) rename {gui => v1/gui}/src/app/constants/ActivityLogType.ts (100%) rename {gui => v1/gui}/src/app/constants/BoardConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/NavbarConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/ProjectConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/PullRequestConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/SidebarConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/SkeletonConstants.ts (100%) rename {gui => v1/gui}/src/app/constants/UtilsConstants.ts (100%) rename {gui => v1/gui}/src/app/imagePath.tsx (100%) rename {gui => v1/gui}/src/app/layout.tsx (100%) rename {gui => v1/gui}/src/app/logout/page.tsx (100%) rename {gui => v1/gui}/src/app/page.tsx (100%) rename {gui => v1/gui}/src/app/projects/layout.tsx (100%) rename {gui => v1/gui}/src/app/projects/page.tsx (100%) rename {gui => v1/gui}/src/app/projects/projects.module.css (100%) rename {gui => v1/gui}/src/app/providers.tsx (100%) rename {gui => v1/gui}/src/app/settings/SettingsOptions/Models.tsx (100%) rename {gui => v1/gui}/src/app/settings/layout.tsx (100%) rename {gui => v1/gui}/src/app/settings/page.tsx (100%) rename {gui => v1/gui}/src/app/utils.tsx (100%) rename {gui => v1/gui}/src/components/BackButton/BackButton.tsx (100%) rename {gui => v1/gui}/src/components/CustomContainers/CustomContainers.tsx (100%) rename {gui => v1/gui}/src/components/CustomContainers/container.module.css (100%) rename {gui => v1/gui}/src/components/CustomDiffEditor/MonacoDiffEditor.tsx (100%) rename {gui => v1/gui}/src/components/CustomDiffEditor/diff.module.css (100%) rename {gui => v1/gui}/src/components/CustomDrawer/CustomDrawer.tsx (100%) rename {gui => v1/gui}/src/components/CustomDrawer/drawer.module.css (100%) rename {gui => v1/gui}/src/components/CustomDropdown/CustomDropdown.tsx (100%) rename {gui => v1/gui}/src/components/CustomDropdown/dropdown.module.css (100%) rename {gui => v1/gui}/src/components/CustomInput/CustomInput.tsx (100%) rename {gui => v1/gui}/src/components/CustomInput/input.module.css (100%) rename {gui => v1/gui}/src/components/CustomLoaders/CustomLoaders.tsx (100%) rename {gui => v1/gui}/src/components/CustomLoaders/Loader.tsx (100%) rename {gui => v1/gui}/src/components/CustomLoaders/SkeletonLoader.tsx (100%) rename {gui => v1/gui}/src/components/CustomLoaders/loader.module.css (100%) rename {gui => v1/gui}/src/components/CustomModal/CustomModal.tsx (100%) rename {gui => v1/gui}/src/components/CustomModal/modal.module.css (100%) rename {gui => v1/gui}/src/components/CustomSelect/CustomSelect.tsx (100%) rename {gui => v1/gui}/src/components/CustomSelect/select.module.css (100%) rename {gui => v1/gui}/src/components/CustomSidebar/CustomSidebar.tsx (100%) rename {gui => v1/gui}/src/components/CustomSidebar/sidebar.module.css (100%) rename {gui => v1/gui}/src/components/CustomTabs/CustomTabs.tsx (100%) rename {gui => v1/gui}/src/components/CustomTabs/tabs.module.css (100%) rename {gui => v1/gui}/src/components/CustomTag/CustomTag.tsx (100%) rename {gui => v1/gui}/src/components/CustomTag/tag.module.css (100%) rename {gui => v1/gui}/src/components/CustomTimeline/CustomTimeline.tsx (100%) rename {gui => v1/gui}/src/components/CustomTimeline/timeline.module.css (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/CreateEditDesignStory.tsx (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/DesignStoryDetails.tsx (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/DesignStoryList.tsx (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/FrontendCodeSection.tsx (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/ReviewList.tsx (100%) rename {gui => v1/gui}/src/components/DesignStoryComponents/desingStory.module.css (100%) rename {gui => v1/gui}/src/components/DiffViewer/DiffViewer.tsx (100%) rename {gui => v1/gui}/src/components/DiffViewer/diff.module.css (100%) rename {gui => v1/gui}/src/components/HomeComponents/CreateOrEditProjectBody.tsx (100%) rename {gui => v1/gui}/src/components/HomeComponents/GithubStarModal.tsx (100%) rename {gui => v1/gui}/src/components/HomeComponents/LandingPage.tsx (100%) rename {gui => v1/gui}/src/components/HomeComponents/home.module.css (100%) rename {gui => v1/gui}/src/components/ImageComponents/CustomImage.tsx (100%) rename {gui => v1/gui}/src/components/ImageComponents/CustomImageSelector.tsx (100%) rename {gui => v1/gui}/src/components/ImageComponents/CustomTextImage.tsx (100%) rename {gui => v1/gui}/src/components/ImageComponents/image.module.css (100%) rename {gui => v1/gui}/src/components/LayoutComponents/NavBar.tsx (100%) rename {gui => v1/gui}/src/components/LayoutComponents/SideBar.tsx (100%) rename {gui => v1/gui}/src/components/LayoutComponents/style.css (100%) rename {gui => v1/gui}/src/components/RebuildModal/RebuildModal.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/CreateEditStory.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/InReviewIssue.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/InputSection.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/Instructions.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/Overview.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/SetupModelModal.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/StoryDetails.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/TestCases.tsx (100%) rename {gui => v1/gui}/src/components/StoryComponents/story.module.css (100%) rename {gui => v1/gui}/src/components/SyntaxDisplay/SyntaxDisplay.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/ActiveWorkbench.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/Activity.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/BackendWorkbench.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/Browser.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/DesignWorkbench.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/StoryDetailsWorkbench.tsx (100%) rename {gui => v1/gui}/src/components/WorkBenchComponents/workbenchComponents.module.css (100%) rename {gui => v1/gui}/src/context/Boards.tsx (100%) rename {gui => v1/gui}/src/context/Design.tsx (100%) rename {gui => v1/gui}/src/context/PullRequests.tsx (100%) rename {gui => v1/gui}/src/context/SocketContext.tsx (100%) rename {gui => v1/gui}/src/context/UserContext.tsx (100%) rename {gui => v1/gui}/src/context/Workbench.tsx (100%) rename {gui => v1/gui}/src/hooks/useProjectDropdown.tsx (100%) rename {gui => v1/gui}/src/middleware.ts (100%) rename {gui => v1/gui}/src/utils/SocketUtils.tsx (100%) rename {gui => v1/gui}/tailwind.config.ts (100%) rename {gui => v1/gui}/tsconfig.json (100%) rename {gui => v1/gui}/types/authTypes.ts (100%) rename {gui => v1/gui}/types/customComponentTypes.ts (100%) rename {gui => v1/gui}/types/designStoryTypes.ts (100%) rename {gui => v1/gui}/types/imageComponentsTypes.ts (100%) rename {gui => v1/gui}/types/modelsTypes.ts (100%) rename {gui => v1/gui}/types/navbarTypes.ts (100%) rename {gui => v1/gui}/types/projectsTypes.ts (100%) rename {gui => v1/gui}/types/pullRequestsTypes.ts (100%) rename {gui => v1/gui}/types/settingTypes.ts (100%) rename {gui => v1/gui}/types/sidebarTypes.ts (100%) rename {gui => v1/gui}/types/storyTypes.ts (100%) rename {gui => v1/gui}/types/workbenchTypes.ts (100%) rename {gui => v1/gui}/yarn.lock (100%) rename {ide => v1/ide}/node/Dockerfile (100%) rename {ide => v1/ide}/node/config.yaml (100%) rename {ide => v1/ide}/node/settings.json (100%) rename {ide => v1/ide}/python/Dockerfile (100%) rename {ide => v1/ide}/python/config.yaml (100%) rename {ide => v1/ide}/python/initialise.sh (100%) rename {ide => v1/ide}/python/settings.json (100%) rename server.go => v1/server.go (100%) rename startup-worker.sh => v1/startup-worker.sh (100%) rename startup.sh => v1/startup.sh (100%) rename worker.go => v1/worker.go (100%) rename {workspace-service => v1/workspace-service}/.gitignore (100%) rename {workspace-service => v1/workspace-service}/Dockerfile (100%) rename {workspace-service => v1/workspace-service}/app/clients/docker_client.go (100%) rename {workspace-service => v1/workspace-service}/app/clients/k8s_client.go (100%) rename {workspace-service => v1/workspace-service}/app/clients/k8s_controller_client.go (100%) rename {workspace-service => v1/workspace-service}/app/config/config.go (100%) rename {workspace-service => v1/workspace-service}/app/config/env.go (100%) rename {workspace-service => v1/workspace-service}/app/config/frontend_workspace_config.go (100%) rename {workspace-service => v1/workspace-service}/app/config/new_relic.go (100%) rename {workspace-service => v1/workspace-service}/app/config/workspace_jobs.go (100%) rename {workspace-service => v1/workspace-service}/app/config/workspace_service.go (100%) rename {workspace-service => v1/workspace-service}/app/controllers/health_controller.go (100%) rename {workspace-service => v1/workspace-service}/app/controllers/jobs_controller.go (100%) rename {workspace-service => v1/workspace-service}/app/controllers/workspace_controller.go (100%) rename {workspace-service => v1/workspace-service}/app/models/dto/create_job.go (100%) rename {workspace-service => v1/workspace-service}/app/models/dto/create_workspace.go (100%) rename {workspace-service => v1/workspace-service}/app/models/dto/workspace_details.go (100%) rename {workspace-service => v1/workspace-service}/app/services/impl/docker_job_service.go (100%) rename {workspace-service => v1/workspace-service}/app/services/impl/docker_workspace_service.go (100%) rename {workspace-service => v1/workspace-service}/app/services/impl/k8s_job_service.go (100%) rename {workspace-service => v1/workspace-service}/app/services/impl/k8s_workspace_service.go (100%) rename {workspace-service => v1/workspace-service}/app/services/job_service.go (100%) rename {workspace-service => v1/workspace-service}/app/services/workspace_service.go (100%) rename {workspace-service => v1/workspace-service}/app/utils/fs_utils.go (100%) rename {workspace-service => v1/workspace-service}/go.mod (100%) rename {workspace-service => v1/workspace-service}/go.sum (100%) rename {workspace-service => v1/workspace-service}/server.go (100%) rename {workspace-service => v1/workspace-service}/templates/django/.gitignore (100%) rename {workspace-service => v1/workspace-service}/templates/django/.vscode/tasks.json (100%) rename {workspace-service => v1/workspace-service}/templates/django/manage.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/__init__.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/admin.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/app.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/migrations/__init__.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/models.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/tests.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/urls.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/myapp/views.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/project/__init__.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/project/asgi.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/project/settings.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/project/urls.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/project/wsgi.py (100%) rename {workspace-service => v1/workspace-service}/templates/django/pyproject.toml (100%) rename {workspace-service => v1/workspace-service}/templates/django/server_test.txt (100%) rename {workspace-service => v1/workspace-service}/templates/django/static/css/styles.css (100%) rename {workspace-service => v1/workspace-service}/templates/django/static/js/scripts.js (100%) rename {workspace-service => v1/workspace-service}/templates/django/templates/about.html (100%) rename {workspace-service => v1/workspace-service}/templates/django/templates/home.html (100%) rename {workspace-service => v1/workspace-service}/templates/django/templates/registration/login.html (100%) rename {workspace-service => v1/workspace-service}/templates/django/terminal.txt (100%) rename {workspace-service => v1/workspace-service}/templates/flask/.gitignore (100%) rename {workspace-service => v1/workspace-service}/templates/flask/.vscode/tasks.json (100%) rename {workspace-service => v1/workspace-service}/templates/flask/__init__.py (100%) rename {workspace-service => v1/workspace-service}/templates/flask/app.py (100%) rename {workspace-service => v1/workspace-service}/templates/flask/models/__init__.py (100%) rename {workspace-service => v1/workspace-service}/templates/flask/models/model.py (100%) rename {workspace-service => v1/workspace-service}/templates/flask/pyproject.toml (100%) rename {workspace-service => v1/workspace-service}/templates/flask/static/css/style.css (100%) rename {workspace-service => v1/workspace-service}/templates/flask/static/js/main.js (100%) rename {workspace-service => v1/workspace-service}/templates/flask/templates/index.html (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/.eslintrc.json (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/.gitignore (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/README.md (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/app/favicon.ico (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/app/globals.css (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/app/layout.tsx (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/app/page.tsx (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/next.config.mjs (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/package-lock.json (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/package.json (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/postcss.config.mjs (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/public/next.svg (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/public/vercel.svg (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/tailwind.config.ts (100%) rename {workspace-service => v1/workspace-service}/templates/nextjs/tsconfig.json (100%) diff --git a/.dockerignore b/v1/.dockerignore similarity index 100% rename from .dockerignore rename to v1/.dockerignore diff --git a/.github/ISSUE_TEMPLATE/1.BUG_REPORT.yml b/v1/.github/ISSUE_TEMPLATE/1.BUG_REPORT.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/1.BUG_REPORT.yml rename to v1/.github/ISSUE_TEMPLATE/1.BUG_REPORT.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/v1/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE.md rename to v1/.github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/workflows/build-ide-image.yaml b/v1/.github/workflows/build-ide-image.yaml similarity index 100% rename from .github/workflows/build-ide-image.yaml rename to v1/.github/workflows/build-ide-image.yaml diff --git a/CODE_OF_CONDUCT.md b/v1/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to v1/CODE_OF_CONDUCT.md diff --git a/Dockerfile b/v1/Dockerfile similarity index 100% rename from Dockerfile rename to v1/Dockerfile diff --git a/LICENSE b/v1/LICENSE similarity index 100% rename from LICENSE rename to v1/LICENSE diff --git a/README.md b/v1/README.md similarity index 100% rename from README.md rename to v1/README.md diff --git a/app/client/git_provider/gitness_git_provider.go b/v1/app/client/git_provider/gitness_git_provider.go similarity index 100% rename from app/client/git_provider/gitness_git_provider.go rename to v1/app/client/git_provider/gitness_git_provider.go diff --git a/app/client/http_client.go b/v1/app/client/http_client.go similarity index 100% rename from app/client/http_client.go rename to v1/app/client/http_client.go diff --git a/app/client/workspace/workspace_service.go b/v1/app/client/workspace/workspace_service.go similarity index 100% rename from app/client/workspace/workspace_service.go rename to v1/app/client/workspace/workspace_service.go diff --git a/app/config/ai_developer_execution.go b/v1/app/config/ai_developer_execution.go similarity index 100% rename from app/config/ai_developer_execution.go rename to v1/app/config/ai_developer_execution.go diff --git a/app/config/aws_config.go b/v1/app/config/aws_config.go similarity index 100% rename from app/config/aws_config.go rename to v1/app/config/aws_config.go diff --git a/app/config/config.go b/v1/app/config/config.go similarity index 100% rename from app/config/config.go rename to v1/app/config/config.go diff --git a/app/config/db.go b/v1/app/config/db.go similarity index 100% rename from app/config/db.go rename to v1/app/config/db.go diff --git a/app/config/env.go b/v1/app/config/env.go similarity index 100% rename from app/config/env.go rename to v1/app/config/env.go diff --git a/app/config/filestore.go b/v1/app/config/filestore.go similarity index 100% rename from app/config/filestore.go rename to v1/app/config/filestore.go diff --git a/app/config/frontend_workspace.go b/v1/app/config/frontend_workspace.go similarity index 100% rename from app/config/frontend_workspace.go rename to v1/app/config/frontend_workspace.go diff --git a/app/config/github_oauth.go b/v1/app/config/github_oauth.go similarity index 100% rename from app/config/github_oauth.go rename to v1/app/config/github_oauth.go diff --git a/app/config/gitness.go b/v1/app/config/gitness.go similarity index 100% rename from app/config/gitness.go rename to v1/app/config/gitness.go diff --git a/app/config/jwt.go b/v1/app/config/jwt.go similarity index 100% rename from app/config/jwt.go rename to v1/app/config/jwt.go diff --git a/app/config/local_filestore_config.go b/v1/app/config/local_filestore_config.go similarity index 100% rename from app/config/local_filestore_config.go rename to v1/app/config/local_filestore_config.go diff --git a/app/config/logger.go b/v1/app/config/logger.go similarity index 100% rename from app/config/logger.go rename to v1/app/config/logger.go diff --git a/app/config/model.go b/v1/app/config/model.go similarity index 100% rename from app/config/model.go rename to v1/app/config/model.go diff --git a/app/config/monitoring_slack.go b/v1/app/config/monitoring_slack.go similarity index 100% rename from app/config/monitoring_slack.go rename to v1/app/config/monitoring_slack.go diff --git a/app/config/new_relic.go b/v1/app/config/new_relic.go similarity index 100% rename from app/config/new_relic.go rename to v1/app/config/new_relic.go diff --git a/app/config/redis.go b/v1/app/config/redis.go similarity index 100% rename from app/config/redis.go rename to v1/app/config/redis.go diff --git a/app/config/s3_config.go b/v1/app/config/s3_config.go similarity index 100% rename from app/config/s3_config.go rename to v1/app/config/s3_config.go diff --git a/app/config/s3_filestore_config.go b/v1/app/config/s3_filestore_config.go similarity index 100% rename from app/config/s3_filestore_config.go rename to v1/app/config/s3_filestore_config.go diff --git a/app/config/workspace_config.go b/v1/app/config/workspace_config.go similarity index 100% rename from app/config/workspace_config.go rename to v1/app/config/workspace_config.go diff --git a/app/config/workspace_service.go b/v1/app/config/workspace_service.go similarity index 100% rename from app/config/workspace_service.go rename to v1/app/config/workspace_service.go diff --git a/app/constants/app_env.go b/v1/app/constants/app_env.go similarity index 100% rename from app/constants/app_env.go rename to v1/app/constants/app_env.go diff --git a/app/constants/asynq_task_names.go b/v1/app/constants/asynq_task_names.go similarity index 100% rename from app/constants/asynq_task_names.go rename to v1/app/constants/asynq_task_names.go diff --git a/app/constants/filestore_type.go b/v1/app/constants/filestore_type.go similarity index 100% rename from app/constants/filestore_type.go rename to v1/app/constants/filestore_type.go diff --git a/app/constants/models.go b/v1/app/constants/models.go similarity index 100% rename from app/constants/models.go rename to v1/app/constants/models.go diff --git a/app/constants/pr_status.go b/v1/app/constants/pr_status.go similarity index 100% rename from app/constants/pr_status.go rename to v1/app/constants/pr_status.go diff --git a/app/constants/pr_type.go b/v1/app/constants/pr_type.go similarity index 100% rename from app/constants/pr_type.go rename to v1/app/constants/pr_type.go diff --git a/app/constants/project_workflows.go b/v1/app/constants/project_workflows.go similarity index 100% rename from app/constants/project_workflows.go rename to v1/app/constants/project_workflows.go diff --git a/app/constants/redis_ttl.go b/v1/app/constants/redis_ttl.go similarity index 100% rename from app/constants/redis_ttl.go rename to v1/app/constants/redis_ttl.go diff --git a/app/constants/story_status.go b/v1/app/constants/story_status.go similarity index 100% rename from app/constants/story_status.go rename to v1/app/constants/story_status.go diff --git a/app/constants/story_type.go b/v1/app/constants/story_type.go similarity index 100% rename from app/constants/story_type.go rename to v1/app/constants/story_type.go diff --git a/app/controllers/activity_log_controller.go b/v1/app/controllers/activity_log_controller.go similarity index 100% rename from app/controllers/activity_log_controller.go rename to v1/app/controllers/activity_log_controller.go diff --git a/app/controllers/auth_controller.go b/v1/app/controllers/auth_controller.go similarity index 100% rename from app/controllers/auth_controller.go rename to v1/app/controllers/auth_controller.go diff --git a/app/controllers/design_story_review_controller.go b/v1/app/controllers/design_story_review_controller.go similarity index 100% rename from app/controllers/design_story_review_controller.go rename to v1/app/controllers/design_story_review_controller.go diff --git a/app/controllers/execution_controller.go b/v1/app/controllers/execution_controller.go similarity index 100% rename from app/controllers/execution_controller.go rename to v1/app/controllers/execution_controller.go diff --git a/app/controllers/execution_output_controller.go b/v1/app/controllers/execution_output_controller.go similarity index 100% rename from app/controllers/execution_output_controller.go rename to v1/app/controllers/execution_output_controller.go diff --git a/app/controllers/health.go b/v1/app/controllers/health.go similarity index 100% rename from app/controllers/health.go rename to v1/app/controllers/health.go diff --git a/app/controllers/llm_api_key.go b/v1/app/controllers/llm_api_key.go similarity index 100% rename from app/controllers/llm_api_key.go rename to v1/app/controllers/llm_api_key.go diff --git a/app/controllers/project_controller.go b/v1/app/controllers/project_controller.go similarity index 100% rename from app/controllers/project_controller.go rename to v1/app/controllers/project_controller.go diff --git a/app/controllers/pull_request_comments_controller.go b/v1/app/controllers/pull_request_comments_controller.go similarity index 100% rename from app/controllers/pull_request_comments_controller.go rename to v1/app/controllers/pull_request_comments_controller.go diff --git a/app/controllers/pull_request_controller.go b/v1/app/controllers/pull_request_controller.go similarity index 100% rename from app/controllers/pull_request_controller.go rename to v1/app/controllers/pull_request_controller.go diff --git a/app/controllers/story_controller.go b/v1/app/controllers/story_controller.go similarity index 100% rename from app/controllers/story_controller.go rename to v1/app/controllers/story_controller.go diff --git a/app/controllers/user_controller.go b/v1/app/controllers/user_controller.go similarity index 100% rename from app/controllers/user_controller.go rename to v1/app/controllers/user_controller.go diff --git a/app/db/migrations/000001_create_users_table.down.sql b/v1/app/db/migrations/000001_create_users_table.down.sql similarity index 100% rename from app/db/migrations/000001_create_users_table.down.sql rename to v1/app/db/migrations/000001_create_users_table.down.sql diff --git a/app/db/migrations/000001_create_users_table.up.sql b/v1/app/db/migrations/000001_create_users_table.up.sql similarity index 100% rename from app/db/migrations/000001_create_users_table.up.sql rename to v1/app/db/migrations/000001_create_users_table.up.sql diff --git a/app/db/migrations/000002_create_organisations_table.down.sql b/v1/app/db/migrations/000002_create_organisations_table.down.sql similarity index 100% rename from app/db/migrations/000002_create_organisations_table.down.sql rename to v1/app/db/migrations/000002_create_organisations_table.down.sql diff --git a/app/db/migrations/000002_create_organisations_table.up.sql b/v1/app/db/migrations/000002_create_organisations_table.up.sql similarity index 100% rename from app/db/migrations/000002_create_organisations_table.up.sql rename to v1/app/db/migrations/000002_create_organisations_table.up.sql diff --git a/app/db/migrations/000003_create_projects_table.down.sql b/v1/app/db/migrations/000003_create_projects_table.down.sql similarity index 100% rename from app/db/migrations/000003_create_projects_table.down.sql rename to v1/app/db/migrations/000003_create_projects_table.down.sql diff --git a/app/db/migrations/000003_create_projects_table.up.sql b/v1/app/db/migrations/000003_create_projects_table.up.sql similarity index 100% rename from app/db/migrations/000003_create_projects_table.up.sql rename to v1/app/db/migrations/000003_create_projects_table.up.sql diff --git a/app/db/migrations/000004_create_stories_table.down.sql b/v1/app/db/migrations/000004_create_stories_table.down.sql similarity index 100% rename from app/db/migrations/000004_create_stories_table.down.sql rename to v1/app/db/migrations/000004_create_stories_table.down.sql diff --git a/app/db/migrations/000004_create_stories_table.up.sql b/v1/app/db/migrations/000004_create_stories_table.up.sql similarity index 100% rename from app/db/migrations/000004_create_stories_table.up.sql rename to v1/app/db/migrations/000004_create_stories_table.up.sql diff --git a/app/db/migrations/000005_create_story_files_table.down.sql b/v1/app/db/migrations/000005_create_story_files_table.down.sql similarity index 100% rename from app/db/migrations/000005_create_story_files_table.down.sql rename to v1/app/db/migrations/000005_create_story_files_table.down.sql diff --git a/app/db/migrations/000005_create_story_files_table.up.sql b/v1/app/db/migrations/000005_create_story_files_table.up.sql similarity index 100% rename from app/db/migrations/000005_create_story_files_table.up.sql rename to v1/app/db/migrations/000005_create_story_files_table.up.sql diff --git a/app/db/migrations/000006_create_story_instructions_table.down.sql b/v1/app/db/migrations/000006_create_story_instructions_table.down.sql similarity index 100% rename from app/db/migrations/000006_create_story_instructions_table.down.sql rename to v1/app/db/migrations/000006_create_story_instructions_table.down.sql diff --git a/app/db/migrations/000006_create_story_instructions_table.up.sql b/v1/app/db/migrations/000006_create_story_instructions_table.up.sql similarity index 100% rename from app/db/migrations/000006_create_story_instructions_table.up.sql rename to v1/app/db/migrations/000006_create_story_instructions_table.up.sql diff --git a/app/db/migrations/000007_create_story_test_cases_table.down.sql b/v1/app/db/migrations/000007_create_story_test_cases_table.down.sql similarity index 100% rename from app/db/migrations/000007_create_story_test_cases_table.down.sql rename to v1/app/db/migrations/000007_create_story_test_cases_table.down.sql diff --git a/app/db/migrations/000007_create_story_test_cases_table.up.sql b/v1/app/db/migrations/000007_create_story_test_cases_table.up.sql similarity index 100% rename from app/db/migrations/000007_create_story_test_cases_table.up.sql rename to v1/app/db/migrations/000007_create_story_test_cases_table.up.sql diff --git a/app/db/migrations/000008_create_executions_table.down.sql b/v1/app/db/migrations/000008_create_executions_table.down.sql similarity index 100% rename from app/db/migrations/000008_create_executions_table.down.sql rename to v1/app/db/migrations/000008_create_executions_table.down.sql diff --git a/app/db/migrations/000008_create_executions_table.up.sql b/v1/app/db/migrations/000008_create_executions_table.up.sql similarity index 100% rename from app/db/migrations/000008_create_executions_table.up.sql rename to v1/app/db/migrations/000008_create_executions_table.up.sql diff --git a/app/db/migrations/000009_create_execution_steps_table.down.sql b/v1/app/db/migrations/000009_create_execution_steps_table.down.sql similarity index 100% rename from app/db/migrations/000009_create_execution_steps_table.down.sql rename to v1/app/db/migrations/000009_create_execution_steps_table.down.sql diff --git a/app/db/migrations/000009_create_execution_steps_table.up.sql b/v1/app/db/migrations/000009_create_execution_steps_table.up.sql similarity index 100% rename from app/db/migrations/000009_create_execution_steps_table.up.sql rename to v1/app/db/migrations/000009_create_execution_steps_table.up.sql diff --git a/app/db/migrations/000010_create_execution_outputs_table.down.sql b/v1/app/db/migrations/000010_create_execution_outputs_table.down.sql similarity index 100% rename from app/db/migrations/000010_create_execution_outputs_table.down.sql rename to v1/app/db/migrations/000010_create_execution_outputs_table.down.sql diff --git a/app/db/migrations/000010_create_execution_outputs_table.up.sql b/v1/app/db/migrations/000010_create_execution_outputs_table.up.sql similarity index 100% rename from app/db/migrations/000010_create_execution_outputs_table.up.sql rename to v1/app/db/migrations/000010_create_execution_outputs_table.up.sql diff --git a/app/db/migrations/000011_create_activity_logs_table.down.sql b/v1/app/db/migrations/000011_create_activity_logs_table.down.sql similarity index 100% rename from app/db/migrations/000011_create_activity_logs_table.down.sql rename to v1/app/db/migrations/000011_create_activity_logs_table.down.sql diff --git a/app/db/migrations/000011_create_activity_logs_table.up.sql b/v1/app/db/migrations/000011_create_activity_logs_table.up.sql similarity index 100% rename from app/db/migrations/000011_create_activity_logs_table.up.sql rename to v1/app/db/migrations/000011_create_activity_logs_table.up.sql diff --git a/app/db/migrations/000012_create_execution_files_table.down.sql b/v1/app/db/migrations/000012_create_execution_files_table.down.sql similarity index 100% rename from app/db/migrations/000012_create_execution_files_table.down.sql rename to v1/app/db/migrations/000012_create_execution_files_table.down.sql diff --git a/app/db/migrations/000012_create_execution_files_table.up.sql b/v1/app/db/migrations/000012_create_execution_files_table.up.sql similarity index 100% rename from app/db/migrations/000012_create_execution_files_table.up.sql rename to v1/app/db/migrations/000012_create_execution_files_table.up.sql diff --git a/app/db/migrations/000013_add_reexecution_to_executions.down.sql b/v1/app/db/migrations/000013_add_reexecution_to_executions.down.sql similarity index 100% rename from app/db/migrations/000013_add_reexecution_to_executions.down.sql rename to v1/app/db/migrations/000013_add_reexecution_to_executions.down.sql diff --git a/app/db/migrations/000013_add_reexecution_to_executions.up.sql b/v1/app/db/migrations/000013_add_reexecution_to_executions.up.sql similarity index 100% rename from app/db/migrations/000013_add_reexecution_to_executions.up.sql rename to v1/app/db/migrations/000013_add_reexecution_to_executions.up.sql diff --git a/app/db/migrations/000014_alter_execution_output_table.down.sql b/v1/app/db/migrations/000014_alter_execution_output_table.down.sql similarity index 100% rename from app/db/migrations/000014_alter_execution_output_table.down.sql rename to v1/app/db/migrations/000014_alter_execution_output_table.down.sql diff --git a/app/db/migrations/000014_alter_execution_output_table.up.sql b/v1/app/db/migrations/000014_alter_execution_output_table.up.sql similarity index 100% rename from app/db/migrations/000014_alter_execution_output_table.up.sql rename to v1/app/db/migrations/000014_alter_execution_output_table.up.sql diff --git a/app/db/migrations/000015_create_pull_request_table.down.sql b/v1/app/db/migrations/000015_create_pull_request_table.down.sql similarity index 100% rename from app/db/migrations/000015_create_pull_request_table.down.sql rename to v1/app/db/migrations/000015_create_pull_request_table.down.sql diff --git a/app/db/migrations/000015_create_pull_request_table.up.sql b/v1/app/db/migrations/000015_create_pull_request_table.up.sql similarity index 100% rename from app/db/migrations/000015_create_pull_request_table.up.sql rename to v1/app/db/migrations/000015_create_pull_request_table.up.sql diff --git a/app/db/migrations/000016_create_pull_request_comments_table.down.sql b/v1/app/db/migrations/000016_create_pull_request_comments_table.down.sql similarity index 100% rename from app/db/migrations/000016_create_pull_request_comments_table.down.sql rename to v1/app/db/migrations/000016_create_pull_request_comments_table.down.sql diff --git a/app/db/migrations/000016_create_pull_request_comments_table.up.sql b/v1/app/db/migrations/000016_create_pull_request_comments_table.up.sql similarity index 100% rename from app/db/migrations/000016_create_pull_request_comments_table.up.sql rename to v1/app/db/migrations/000016_create_pull_request_comments_table.up.sql diff --git a/app/db/migrations/000017_remove_pull_req_exec_output.down.sql b/v1/app/db/migrations/000017_remove_pull_req_exec_output.down.sql similarity index 100% rename from app/db/migrations/000017_remove_pull_req_exec_output.down.sql rename to v1/app/db/migrations/000017_remove_pull_req_exec_output.down.sql diff --git a/app/db/migrations/000017_remove_pull_req_exec_output.up.sql b/v1/app/db/migrations/000017_remove_pull_req_exec_output.up.sql similarity index 100% rename from app/db/migrations/000017_remove_pull_req_exec_output.up.sql rename to v1/app/db/migrations/000017_remove_pull_req_exec_output.up.sql diff --git a/app/db/migrations/000018_add_is_deleted_column_story.down.sql b/v1/app/db/migrations/000018_add_is_deleted_column_story.down.sql similarity index 100% rename from app/db/migrations/000018_add_is_deleted_column_story.down.sql rename to v1/app/db/migrations/000018_add_is_deleted_column_story.down.sql diff --git a/app/db/migrations/000018_add_is_deleted_column_story.up.sql b/v1/app/db/migrations/000018_add_is_deleted_column_story.up.sql similarity index 100% rename from app/db/migrations/000018_add_is_deleted_column_story.up.sql rename to v1/app/db/migrations/000018_add_is_deleted_column_story.up.sql diff --git a/app/db/migrations/000019_remove_pull_req_exec_output.down.sql b/v1/app/db/migrations/000019_remove_pull_req_exec_output.down.sql similarity index 100% rename from app/db/migrations/000019_remove_pull_req_exec_output.down.sql rename to v1/app/db/migrations/000019_remove_pull_req_exec_output.down.sql diff --git a/app/db/migrations/000019_remove_pull_req_exec_output.up.sql b/v1/app/db/migrations/000019_remove_pull_req_exec_output.up.sql similarity index 100% rename from app/db/migrations/000019_remove_pull_req_exec_output.up.sql rename to v1/app/db/migrations/000019_remove_pull_req_exec_output.up.sql diff --git a/app/db/migrations/000020_comment_data_type_change.down.sql b/v1/app/db/migrations/000020_comment_data_type_change.down.sql similarity index 100% rename from app/db/migrations/000020_comment_data_type_change.down.sql rename to v1/app/db/migrations/000020_comment_data_type_change.down.sql diff --git a/app/db/migrations/000020_comment_data_type_change.up.sql b/v1/app/db/migrations/000020_comment_data_type_change.up.sql similarity index 100% rename from app/db/migrations/000020_comment_data_type_change.up.sql rename to v1/app/db/migrations/000020_comment_data_type_change.up.sql diff --git a/app/db/migrations/000021_llm_api_keys.down.sql b/v1/app/db/migrations/000021_llm_api_keys.down.sql similarity index 100% rename from app/db/migrations/000021_llm_api_keys.down.sql rename to v1/app/db/migrations/000021_llm_api_keys.down.sql diff --git a/app/db/migrations/000021_llm_api_keys.up.sql b/v1/app/db/migrations/000021_llm_api_keys.up.sql similarity index 100% rename from app/db/migrations/000021_llm_api_keys.up.sql rename to v1/app/db/migrations/000021_llm_api_keys.up.sql diff --git a/app/db/migrations/000022_add_supercoder_user_and_org.down.sql b/v1/app/db/migrations/000022_add_supercoder_user_and_org.down.sql similarity index 100% rename from app/db/migrations/000022_add_supercoder_user_and_org.down.sql rename to v1/app/db/migrations/000022_add_supercoder_user_and_org.down.sql diff --git a/app/db/migrations/000022_add_supercoder_user_and_org.up.sql b/v1/app/db/migrations/000022_add_supercoder_user_and_org.up.sql similarity index 100% rename from app/db/migrations/000022_add_supercoder_user_and_org.up.sql rename to v1/app/db/migrations/000022_add_supercoder_user_and_org.up.sql diff --git a/app/db/migrations/000023_remove_supercoder_user_and_org.down.sql b/v1/app/db/migrations/000023_remove_supercoder_user_and_org.down.sql similarity index 100% rename from app/db/migrations/000023_remove_supercoder_user_and_org.down.sql rename to v1/app/db/migrations/000023_remove_supercoder_user_and_org.down.sql diff --git a/app/db/migrations/000023_remove_supercoder_user_and_org.up.sql b/v1/app/db/migrations/000023_remove_supercoder_user_and_org.up.sql similarity index 100% rename from app/db/migrations/000023_remove_supercoder_user_and_org.up.sql rename to v1/app/db/migrations/000023_remove_supercoder_user_and_org.up.sql diff --git a/app/db/migrations/000024_alter_stories_table.down.sql b/v1/app/db/migrations/000024_alter_stories_table.down.sql similarity index 100% rename from app/db/migrations/000024_alter_stories_table.down.sql rename to v1/app/db/migrations/000024_alter_stories_table.down.sql diff --git a/app/db/migrations/000024_alter_stories_table.up.sql b/v1/app/db/migrations/000024_alter_stories_table.up.sql similarity index 100% rename from app/db/migrations/000024_alter_stories_table.up.sql rename to v1/app/db/migrations/000024_alter_stories_table.up.sql diff --git a/app/db/migrations/000025_create_design_story_review_table.down.sql b/v1/app/db/migrations/000025_create_design_story_review_table.down.sql similarity index 100% rename from app/db/migrations/000025_create_design_story_review_table.down.sql rename to v1/app/db/migrations/000025_create_design_story_review_table.down.sql diff --git a/app/db/migrations/000025_create_design_story_review_table.up.sql b/v1/app/db/migrations/000025_create_design_story_review_table.up.sql similarity index 100% rename from app/db/migrations/000025_create_design_story_review_table.up.sql rename to v1/app/db/migrations/000025_create_design_story_review_table.up.sql diff --git a/app/db/migrations/000026_update_api_key_length.down.sql b/v1/app/db/migrations/000026_update_api_key_length.down.sql similarity index 100% rename from app/db/migrations/000026_update_api_key_length.down.sql rename to v1/app/db/migrations/000026_update_api_key_length.down.sql diff --git a/app/db/migrations/000026_update_api_key_length.up.sql b/v1/app/db/migrations/000026_update_api_key_length.up.sql similarity index 100% rename from app/db/migrations/000026_update_api_key_length.up.sql rename to v1/app/db/migrations/000026_update_api_key_length.up.sql diff --git a/app/db/migrations/000027_add_frontend_framework_to_project.down.sql b/v1/app/db/migrations/000027_add_frontend_framework_to_project.down.sql similarity index 100% rename from app/db/migrations/000027_add_frontend_framework_to_project.down.sql rename to v1/app/db/migrations/000027_add_frontend_framework_to_project.down.sql diff --git a/app/db/migrations/000027_add_frontend_framework_to_project.up.sql b/v1/app/db/migrations/000027_add_frontend_framework_to_project.up.sql similarity index 100% rename from app/db/migrations/000027_add_frontend_framework_to_project.up.sql rename to v1/app/db/migrations/000027_add_frontend_framework_to_project.up.sql diff --git a/app/db/migrations/000028_add_type_in_story.down.sql b/v1/app/db/migrations/000028_add_type_in_story.down.sql similarity index 100% rename from app/db/migrations/000028_add_type_in_story.down.sql rename to v1/app/db/migrations/000028_add_type_in_story.down.sql diff --git a/app/db/migrations/000028_add_type_in_story.up.sql b/v1/app/db/migrations/000028_add_type_in_story.up.sql similarity index 100% rename from app/db/migrations/000028_add_type_in_story.up.sql rename to v1/app/db/migrations/000028_add_type_in_story.up.sql diff --git a/app/db/migrations/000029_add_prtype_in_pull_requests_table.down.sql b/v1/app/db/migrations/000029_add_prtype_in_pull_requests_table.down.sql similarity index 100% rename from app/db/migrations/000029_add_prtype_in_pull_requests_table.down.sql rename to v1/app/db/migrations/000029_add_prtype_in_pull_requests_table.down.sql diff --git a/app/db/migrations/000029_add_prtype_in_pull_requests_table.up.sql b/v1/app/db/migrations/000029_add_prtype_in_pull_requests_table.up.sql similarity index 100% rename from app/db/migrations/000029_add_prtype_in_pull_requests_table.up.sql rename to v1/app/db/migrations/000029_add_prtype_in_pull_requests_table.up.sql diff --git a/app/db/migrations/000030_inc_length_story_table.down.sql b/v1/app/db/migrations/000030_inc_length_story_table.down.sql similarity index 100% rename from app/db/migrations/000030_inc_length_story_table.down.sql rename to v1/app/db/migrations/000030_inc_length_story_table.down.sql diff --git a/app/db/migrations/000030_inc_length_story_table.up.sql b/v1/app/db/migrations/000030_inc_length_story_table.up.sql similarity index 100% rename from app/db/migrations/000030_inc_length_story_table.up.sql rename to v1/app/db/migrations/000030_inc_length_story_table.up.sql diff --git a/app/db/migrations/000031_rename_framework_in_projects.down.sql b/v1/app/db/migrations/000031_rename_framework_in_projects.down.sql similarity index 100% rename from app/db/migrations/000031_rename_framework_in_projects.down.sql rename to v1/app/db/migrations/000031_rename_framework_in_projects.down.sql diff --git a/app/db/migrations/000031_rename_framework_in_projects.up.sql b/v1/app/db/migrations/000031_rename_framework_in_projects.up.sql similarity index 100% rename from app/db/migrations/000031_rename_framework_in_projects.up.sql rename to v1/app/db/migrations/000031_rename_framework_in_projects.up.sql diff --git a/app/gateways/websocket.go b/v1/app/gateways/websocket.go similarity index 100% rename from app/gateways/websocket.go rename to v1/app/gateways/websocket.go diff --git a/app/gateways/websocket_gateway.go b/v1/app/gateways/websocket_gateway.go similarity index 100% rename from app/gateways/websocket_gateway.go rename to v1/app/gateways/websocket_gateway.go diff --git a/app/llms/claude.go b/v1/app/llms/claude.go similarity index 100% rename from app/llms/claude.go rename to v1/app/llms/claude.go diff --git a/app/llms/open_ai.go b/v1/app/llms/open_ai.go similarity index 100% rename from app/llms/open_ai.go rename to v1/app/llms/open_ai.go diff --git a/app/middleware/organisation_authorization.go b/v1/app/middleware/organisation_authorization.go similarity index 100% rename from app/middleware/organisation_authorization.go rename to v1/app/middleware/organisation_authorization.go diff --git a/app/middleware/project_authorization.go b/v1/app/middleware/project_authorization.go similarity index 100% rename from app/middleware/project_authorization.go rename to v1/app/middleware/project_authorization.go diff --git a/app/middleware/pull_request_authorization.go b/v1/app/middleware/pull_request_authorization.go similarity index 100% rename from app/middleware/pull_request_authorization.go rename to v1/app/middleware/pull_request_authorization.go diff --git a/app/middleware/story_authorization.go b/v1/app/middleware/story_authorization.go similarity index 100% rename from app/middleware/story_authorization.go rename to v1/app/middleware/story_authorization.go diff --git a/app/middleware/user_authorization.go b/v1/app/middleware/user_authorization.go similarity index 100% rename from app/middleware/user_authorization.go rename to v1/app/middleware/user_authorization.go diff --git a/app/models/activity_log.go b/v1/app/models/activity_log.go similarity index 100% rename from app/models/activity_log.go rename to v1/app/models/activity_log.go diff --git a/app/models/design_story_review.go b/v1/app/models/design_story_review.go similarity index 100% rename from app/models/design_story_review.go rename to v1/app/models/design_story_review.go diff --git a/app/models/dtos/asynq_task/create_job_payload.go b/v1/app/models/dtos/asynq_task/create_job_payload.go similarity index 100% rename from app/models/dtos/asynq_task/create_job_payload.go rename to v1/app/models/dtos/asynq_task/create_job_payload.go diff --git a/app/models/dtos/asynq_task/delete_workspace_task.go b/v1/app/models/dtos/asynq_task/delete_workspace_task.go similarity index 100% rename from app/models/dtos/asynq_task/delete_workspace_task.go rename to v1/app/models/dtos/asynq_task/delete_workspace_task.go diff --git a/app/models/dtos/gitness/types.go b/v1/app/models/dtos/gitness/types.go similarity index 100% rename from app/models/dtos/gitness/types.go rename to v1/app/models/dtos/gitness/types.go diff --git a/app/models/dtos/llm_api_key/llm_api_key_return.go b/v1/app/models/dtos/llm_api_key/llm_api_key_return.go similarity index 100% rename from app/models/dtos/llm_api_key/llm_api_key_return.go rename to v1/app/models/dtos/llm_api_key/llm_api_key_return.go diff --git a/app/models/execution.go b/v1/app/models/execution.go similarity index 100% rename from app/models/execution.go rename to v1/app/models/execution.go diff --git a/app/models/execution_file.go b/v1/app/models/execution_file.go similarity index 100% rename from app/models/execution_file.go rename to v1/app/models/execution_file.go diff --git a/app/models/execution_output.go b/v1/app/models/execution_output.go similarity index 100% rename from app/models/execution_output.go rename to v1/app/models/execution_output.go diff --git a/app/models/execution_step.go b/v1/app/models/execution_step.go similarity index 100% rename from app/models/execution_step.go rename to v1/app/models/execution_step.go diff --git a/app/models/llm_api_key.go b/v1/app/models/llm_api_key.go similarity index 100% rename from app/models/llm_api_key.go rename to v1/app/models/llm_api_key.go diff --git a/app/models/organisation.go b/v1/app/models/organisation.go similarity index 100% rename from app/models/organisation.go rename to v1/app/models/organisation.go diff --git a/app/models/project.go b/v1/app/models/project.go similarity index 100% rename from app/models/project.go rename to v1/app/models/project.go diff --git a/app/models/pull_request.go b/v1/app/models/pull_request.go similarity index 100% rename from app/models/pull_request.go rename to v1/app/models/pull_request.go diff --git a/app/models/pull_request_comment.go b/v1/app/models/pull_request_comment.go similarity index 100% rename from app/models/pull_request_comment.go rename to v1/app/models/pull_request_comment.go diff --git a/app/models/story.go b/v1/app/models/story.go similarity index 100% rename from app/models/story.go rename to v1/app/models/story.go diff --git a/app/models/story_file.go b/v1/app/models/story_file.go similarity index 100% rename from app/models/story_file.go rename to v1/app/models/story_file.go diff --git a/app/models/story_instruction.go b/v1/app/models/story_instruction.go similarity index 100% rename from app/models/story_instruction.go rename to v1/app/models/story_instruction.go diff --git a/app/models/story_test_case.go b/v1/app/models/story_test_case.go similarity index 100% rename from app/models/story_test_case.go rename to v1/app/models/story_test_case.go diff --git a/app/models/types/errors.go b/v1/app/models/types/errors.go similarity index 100% rename from app/models/types/errors.go rename to v1/app/models/types/errors.go diff --git a/app/models/types/json_map.go b/v1/app/models/types/json_map.go similarity index 100% rename from app/models/types/json_map.go rename to v1/app/models/types/json_map.go diff --git a/app/models/user.go b/v1/app/models/user.go similarity index 100% rename from app/models/user.go rename to v1/app/models/user.go diff --git a/app/monitoring/slack_alerts.go b/v1/app/monitoring/slack_alerts.go similarity index 100% rename from app/monitoring/slack_alerts.go rename to v1/app/monitoring/slack_alerts.go diff --git a/app/prompts/nextjs/ai_frontend_developer.txt b/v1/app/prompts/nextjs/ai_frontend_developer.txt similarity index 100% rename from app/prompts/nextjs/ai_frontend_developer.txt rename to v1/app/prompts/nextjs/ai_frontend_developer.txt diff --git a/app/prompts/nextjs/ai_frontend_developer_edit_code.txt b/v1/app/prompts/nextjs/ai_frontend_developer_edit_code.txt similarity index 100% rename from app/prompts/nextjs/ai_frontend_developer_edit_code.txt rename to v1/app/prompts/nextjs/ai_frontend_developer_edit_code.txt diff --git a/app/prompts/nextjs/next_js_build_checker.txt b/v1/app/prompts/nextjs/next_js_build_checker.txt similarity index 100% rename from app/prompts/nextjs/next_js_build_checker.txt rename to v1/app/prompts/nextjs/next_js_build_checker.txt diff --git a/app/prompts/python/ai_developer_django.txt b/v1/app/prompts/python/ai_developer_django.txt similarity index 100% rename from app/prompts/python/ai_developer_django.txt rename to v1/app/prompts/python/ai_developer_django.txt diff --git a/app/prompts/python/ai_developer_flask.txt b/v1/app/prompts/python/ai_developer_flask.txt similarity index 100% rename from app/prompts/python/ai_developer_flask.txt rename to v1/app/prompts/python/ai_developer_flask.txt diff --git a/app/repositories/activity_log.go b/v1/app/repositories/activity_log.go similarity index 100% rename from app/repositories/activity_log.go rename to v1/app/repositories/activity_log.go diff --git a/app/repositories/design_story_review.go b/v1/app/repositories/design_story_review.go similarity index 100% rename from app/repositories/design_story_review.go rename to v1/app/repositories/design_story_review.go diff --git a/app/repositories/execution.go b/v1/app/repositories/execution.go similarity index 100% rename from app/repositories/execution.go rename to v1/app/repositories/execution.go diff --git a/app/repositories/execution_output.go b/v1/app/repositories/execution_output.go similarity index 100% rename from app/repositories/execution_output.go rename to v1/app/repositories/execution_output.go diff --git a/app/repositories/execution_step.go b/v1/app/repositories/execution_step.go similarity index 100% rename from app/repositories/execution_step.go rename to v1/app/repositories/execution_step.go diff --git a/app/repositories/llm_api_key.go b/v1/app/repositories/llm_api_key.go similarity index 100% rename from app/repositories/llm_api_key.go rename to v1/app/repositories/llm_api_key.go diff --git a/app/repositories/options.go b/v1/app/repositories/options.go similarity index 100% rename from app/repositories/options.go rename to v1/app/repositories/options.go diff --git a/app/repositories/organisation.go b/v1/app/repositories/organisation.go similarity index 100% rename from app/repositories/organisation.go rename to v1/app/repositories/organisation.go diff --git a/app/repositories/project.go b/v1/app/repositories/project.go similarity index 100% rename from app/repositories/project.go rename to v1/app/repositories/project.go diff --git a/app/repositories/project_connection_repository.go b/v1/app/repositories/project_connection_repository.go similarity index 100% rename from app/repositories/project_connection_repository.go rename to v1/app/repositories/project_connection_repository.go diff --git a/app/repositories/pull_request.go b/v1/app/repositories/pull_request.go similarity index 100% rename from app/repositories/pull_request.go rename to v1/app/repositories/pull_request.go diff --git a/app/repositories/pull_request_comment.go b/v1/app/repositories/pull_request_comment.go similarity index 100% rename from app/repositories/pull_request_comment.go rename to v1/app/repositories/pull_request_comment.go diff --git a/app/repositories/repository.go b/v1/app/repositories/repository.go similarity index 100% rename from app/repositories/repository.go rename to v1/app/repositories/repository.go diff --git a/app/repositories/story.go b/v1/app/repositories/story.go similarity index 100% rename from app/repositories/story.go rename to v1/app/repositories/story.go diff --git a/app/repositories/story_file.go b/v1/app/repositories/story_file.go similarity index 100% rename from app/repositories/story_file.go rename to v1/app/repositories/story_file.go diff --git a/app/repositories/story_instruction.go b/v1/app/repositories/story_instruction.go similarity index 100% rename from app/repositories/story_instruction.go rename to v1/app/repositories/story_instruction.go diff --git a/app/repositories/story_test_case.go b/v1/app/repositories/story_test_case.go similarity index 100% rename from app/repositories/story_test_case.go rename to v1/app/repositories/story_test_case.go diff --git a/app/repositories/user.go b/v1/app/repositories/user.go similarity index 100% rename from app/repositories/user.go rename to v1/app/repositories/user.go diff --git a/app/services/activity_log_service.go b/v1/app/services/activity_log_service.go similarity index 100% rename from app/services/activity_log_service.go rename to v1/app/services/activity_log_service.go diff --git a/app/services/auth/auth_provider.go b/v1/app/services/auth/auth_provider.go similarity index 100% rename from app/services/auth/auth_provider.go rename to v1/app/services/auth/auth_provider.go diff --git a/app/services/auth/authenticator.go b/v1/app/services/auth/authenticator.go similarity index 100% rename from app/services/auth/authenticator.go rename to v1/app/services/auth/authenticator.go diff --git a/app/services/auth/email_auth_service.go b/v1/app/services/auth/email_auth_service.go similarity index 100% rename from app/services/auth/email_auth_service.go rename to v1/app/services/auth/email_auth_service.go diff --git a/app/services/auth/github_auth_service.go b/v1/app/services/auth/github_auth_service.go similarity index 100% rename from app/services/auth/github_auth_service.go rename to v1/app/services/auth/github_auth_service.go diff --git a/app/services/auth/jwt_authentication_middleware.go b/v1/app/services/auth/jwt_authentication_middleware.go similarity index 100% rename from app/services/auth/jwt_authentication_middleware.go rename to v1/app/services/auth/jwt_authentication_middleware.go diff --git a/app/services/code_download_service.go b/v1/app/services/code_download_service.go similarity index 100% rename from app/services/code_download_service.go rename to v1/app/services/code_download_service.go diff --git a/app/services/design_story_review_service.go b/v1/app/services/design_story_review_service.go similarity index 100% rename from app/services/design_story_review_service.go rename to v1/app/services/design_story_review_service.go diff --git a/app/services/execution_output_service.go b/v1/app/services/execution_output_service.go similarity index 100% rename from app/services/execution_output_service.go rename to v1/app/services/execution_output_service.go diff --git a/app/services/execution_service.go b/v1/app/services/execution_service.go similarity index 100% rename from app/services/execution_service.go rename to v1/app/services/execution_service.go diff --git a/app/services/execution_step_service.go b/v1/app/services/execution_step_service.go similarity index 100% rename from app/services/execution_step_service.go rename to v1/app/services/execution_step_service.go diff --git a/app/services/filestore/filestore.go b/v1/app/services/filestore/filestore.go similarity index 100% rename from app/services/filestore/filestore.go rename to v1/app/services/filestore/filestore.go diff --git a/app/services/filestore/impl/local.go b/v1/app/services/filestore/impl/local.go similarity index 100% rename from app/services/filestore/impl/local.go rename to v1/app/services/filestore/impl/local.go diff --git a/app/services/filestore/impl/s3.go b/v1/app/services/filestore/impl/s3.go similarity index 100% rename from app/services/filestore/impl/s3.go rename to v1/app/services/filestore/impl/s3.go diff --git a/app/services/git_providers/git_provider.go b/v1/app/services/git_providers/git_provider.go similarity index 100% rename from app/services/git_providers/git_provider.go rename to v1/app/services/git_providers/git_provider.go diff --git a/app/services/git_providers/gitness_git_provider_service.go b/v1/app/services/git_providers/gitness_git_provider_service.go similarity index 100% rename from app/services/git_providers/gitness_git_provider_service.go rename to v1/app/services/git_providers/gitness_git_provider_service.go diff --git a/app/services/llm_api_key.go b/v1/app/services/llm_api_key.go similarity index 100% rename from app/services/llm_api_key.go rename to v1/app/services/llm_api_key.go diff --git a/app/services/organisation_service.go b/v1/app/services/organisation_service.go similarity index 100% rename from app/services/organisation_service.go rename to v1/app/services/organisation_service.go diff --git a/app/services/project_notification_service.go b/v1/app/services/project_notification_service.go similarity index 100% rename from app/services/project_notification_service.go rename to v1/app/services/project_notification_service.go diff --git a/app/services/project_service.go b/v1/app/services/project_service.go similarity index 100% rename from app/services/project_service.go rename to v1/app/services/project_service.go diff --git a/app/services/pull_request_comments_service.go b/v1/app/services/pull_request_comments_service.go similarity index 100% rename from app/services/pull_request_comments_service.go rename to v1/app/services/pull_request_comments_service.go diff --git a/app/services/pull_request_service.go b/v1/app/services/pull_request_service.go similarity index 100% rename from app/services/pull_request_service.go rename to v1/app/services/pull_request_service.go diff --git a/app/services/story_service.go b/v1/app/services/story_service.go similarity index 100% rename from app/services/story_service.go rename to v1/app/services/story_service.go diff --git a/app/services/user_service.go b/v1/app/services/user_service.go similarity index 100% rename from app/services/user_service.go rename to v1/app/services/user_service.go diff --git a/app/tasks/check_execution_status.go b/v1/app/tasks/check_execution_status.go similarity index 100% rename from app/tasks/check_execution_status.go rename to v1/app/tasks/check_execution_status.go diff --git a/app/tasks/create_execution_job_task.go b/v1/app/tasks/create_execution_job_task.go similarity index 100% rename from app/tasks/create_execution_job_task.go rename to v1/app/tasks/create_execution_job_task.go diff --git a/app/tasks/delete_workspace_task_handler.go b/v1/app/tasks/delete_workspace_task_handler.go similarity index 100% rename from app/tasks/delete_workspace_task_handler.go rename to v1/app/tasks/delete_workspace_task_handler.go diff --git a/app/types/request/create_comment_request.go b/v1/app/types/request/create_comment_request.go similarity index 100% rename from app/types/request/create_comment_request.go rename to v1/app/types/request/create_comment_request.go diff --git a/app/types/request/create_design_story_comment_request.go b/v1/app/types/request/create_design_story_comment_request.go similarity index 100% rename from app/types/request/create_design_story_comment_request.go rename to v1/app/types/request/create_design_story_comment_request.go diff --git a/app/types/request/create_job_request.go b/v1/app/types/request/create_job_request.go similarity index 100% rename from app/types/request/create_job_request.go rename to v1/app/types/request/create_job_request.go diff --git a/app/types/request/create_pr_request.go b/v1/app/types/request/create_pr_request.go similarity index 100% rename from app/types/request/create_pr_request.go rename to v1/app/types/request/create_pr_request.go diff --git a/app/types/request/create_project_request.go b/v1/app/types/request/create_project_request.go similarity index 100% rename from app/types/request/create_project_request.go rename to v1/app/types/request/create_project_request.go diff --git a/app/types/request/create_story_request.go b/v1/app/types/request/create_story_request.go similarity index 100% rename from app/types/request/create_story_request.go rename to v1/app/types/request/create_story_request.go diff --git a/app/types/request/create_user_request.go b/v1/app/types/request/create_user_request.go similarity index 100% rename from app/types/request/create_user_request.go rename to v1/app/types/request/create_user_request.go diff --git a/app/types/request/create_worspace_request.go b/v1/app/types/request/create_worspace_request.go similarity index 100% rename from app/types/request/create_worspace_request.go rename to v1/app/types/request/create_worspace_request.go diff --git a/app/types/request/delete_story_by_id.go b/v1/app/types/request/delete_story_by_id.go similarity index 100% rename from app/types/request/delete_story_by_id.go rename to v1/app/types/request/delete_story_by_id.go diff --git a/app/types/request/llm_api_key_request.go b/v1/app/types/request/llm_api_key_request.go similarity index 100% rename from app/types/request/llm_api_key_request.go rename to v1/app/types/request/llm_api_key_request.go diff --git a/app/types/request/merge_pull_request.go b/v1/app/types/request/merge_pull_request.go similarity index 100% rename from app/types/request/merge_pull_request.go rename to v1/app/types/request/merge_pull_request.go diff --git a/app/types/request/retrieve_code_request.go b/v1/app/types/request/retrieve_code_request.go similarity index 100% rename from app/types/request/retrieve_code_request.go rename to v1/app/types/request/retrieve_code_request.go diff --git a/app/types/request/update_project_request.go b/v1/app/types/request/update_project_request.go similarity index 100% rename from app/types/request/update_project_request.go rename to v1/app/types/request/update_project_request.go diff --git a/app/types/request/update_story_request.go b/v1/app/types/request/update_story_request.go similarity index 100% rename from app/types/request/update_story_request.go rename to v1/app/types/request/update_story_request.go diff --git a/app/types/request/update_story_status_request.go b/v1/app/types/request/update_story_status_request.go similarity index 100% rename from app/types/request/update_story_status_request.go rename to v1/app/types/request/update_story_status_request.go diff --git a/app/types/request/user_signin_request.go b/v1/app/types/request/user_signin_request.go similarity index 100% rename from app/types/request/user_signin_request.go rename to v1/app/types/request/user_signin_request.go diff --git a/app/types/response/create_workspace_response.go b/v1/app/types/response/create_workspace_response.go similarity index 100% rename from app/types/response/create_workspace_response.go rename to v1/app/types/response/create_workspace_response.go diff --git a/app/types/response/get_all_commits_response.go b/v1/app/types/response/get_all_commits_response.go similarity index 100% rename from app/types/response/get_all_commits_response.go rename to v1/app/types/response/get_all_commits_response.go diff --git a/app/types/response/get_all_projects_response.go b/v1/app/types/response/get_all_projects_response.go similarity index 100% rename from app/types/response/get_all_projects_response.go rename to v1/app/types/response/get_all_projects_response.go diff --git a/app/types/response/get_all_pull_requests.go b/v1/app/types/response/get_all_pull_requests.go similarity index 100% rename from app/types/response/get_all_pull_requests.go rename to v1/app/types/response/get_all_pull_requests.go diff --git a/app/types/response/get_all_stories_by_project_id.go b/v1/app/types/response/get_all_stories_by_project_id.go similarity index 100% rename from app/types/response/get_all_stories_by_project_id.go rename to v1/app/types/response/get_all_stories_by_project_id.go diff --git a/app/types/response/get_code_for_design_story.go b/v1/app/types/response/get_code_for_design_story.go similarity index 100% rename from app/types/response/get_code_for_design_story.go rename to v1/app/types/response/get_code_for_design_story.go diff --git a/app/types/response/get_design_stories_by_project_id.go b/v1/app/types/response/get_design_stories_by_project_id.go similarity index 100% rename from app/types/response/get_design_stories_by_project_id.go rename to v1/app/types/response/get_design_stories_by_project_id.go diff --git a/app/types/response/get_story_by_id_response.go b/v1/app/types/response/get_story_by_id_response.go similarity index 100% rename from app/types/response/get_story_by_id_response.go rename to v1/app/types/response/get_story_by_id_response.go diff --git a/app/types/response/get_story_response.go b/v1/app/types/response/get_story_response.go similarity index 100% rename from app/types/response/get_story_response.go rename to v1/app/types/response/get_story_response.go diff --git a/app/types/response/user_response.go b/v1/app/types/response/user_response.go similarity index 100% rename from app/types/response/user_response.go rename to v1/app/types/response/user_response.go diff --git a/app/utils/api_key.go b/v1/app/utils/api_key.go similarity index 100% rename from app/utils/api_key.go rename to v1/app/utils/api_key.go diff --git a/app/utils/asynq_helper.go b/v1/app/utils/asynq_helper.go similarity index 100% rename from app/utils/asynq_helper.go rename to v1/app/utils/asynq_helper.go diff --git a/app/utils/command_helper.go b/v1/app/utils/command_helper.go similarity index 100% rename from app/utils/command_helper.go rename to v1/app/utils/command_helper.go diff --git a/app/utils/date_time_helper.go b/v1/app/utils/date_time_helper.go similarity index 100% rename from app/utils/date_time_helper.go rename to v1/app/utils/date_time_helper.go diff --git a/app/utils/file_handler.go b/v1/app/utils/file_handler.go similarity index 100% rename from app/utils/file_handler.go rename to v1/app/utils/file_handler.go diff --git a/app/utils/file_service.go b/v1/app/utils/file_service.go similarity index 100% rename from app/utils/file_service.go rename to v1/app/utils/file_service.go diff --git a/app/utils/get_directory_structure.go b/v1/app/utils/get_directory_structure.go similarity index 100% rename from app/utils/get_directory_structure.go rename to v1/app/utils/get_directory_structure.go diff --git a/app/utils/git_command.go b/v1/app/utils/git_command.go similarity index 100% rename from app/utils/git_command.go rename to v1/app/utils/git_command.go diff --git a/app/utils/hash_id_generator.go b/v1/app/utils/hash_id_generator.go similarity index 100% rename from app/utils/hash_id_generator.go rename to v1/app/utils/hash_id_generator.go diff --git a/app/utils/image_utils.go b/v1/app/utils/image_utils.go similarity index 100% rename from app/utils/image_utils.go rename to v1/app/utils/image_utils.go diff --git a/app/utils/random_string_generator.go b/v1/app/utils/random_string_generator.go similarity index 100% rename from app/utils/random_string_generator.go rename to v1/app/utils/random_string_generator.go diff --git a/app/workflow_executors/design_nextjs_workflow_config.go b/v1/app/workflow_executors/design_nextjs_workflow_config.go similarity index 100% rename from app/workflow_executors/design_nextjs_workflow_config.go rename to v1/app/workflow_executors/design_nextjs_workflow_config.go diff --git a/app/workflow_executors/python_django_workflow_config.go b/v1/app/workflow_executors/python_django_workflow_config.go similarity index 100% rename from app/workflow_executors/python_django_workflow_config.go rename to v1/app/workflow_executors/python_django_workflow_config.go diff --git a/app/workflow_executors/python_flask_workflow_config.go b/v1/app/workflow_executors/python_flask_workflow_config.go similarity index 100% rename from app/workflow_executors/python_flask_workflow_config.go rename to v1/app/workflow_executors/python_flask_workflow_config.go diff --git a/app/workflow_executors/step_executors/code_generation_executor.go b/v1/app/workflow_executors/step_executors/code_generation_executor.go similarity index 100% rename from app/workflow_executors/step_executors/code_generation_executor.go rename to v1/app/workflow_executors/step_executors/code_generation_executor.go diff --git a/app/workflow_executors/step_executors/git_commit_executor.go b/v1/app/workflow_executors/step_executors/git_commit_executor.go similarity index 100% rename from app/workflow_executors/step_executors/git_commit_executor.go rename to v1/app/workflow_executors/step_executors/git_commit_executor.go diff --git a/app/workflow_executors/step_executors/git_make_branch_executor.go b/v1/app/workflow_executors/step_executors/git_make_branch_executor.go similarity index 100% rename from app/workflow_executors/step_executors/git_make_branch_executor.go rename to v1/app/workflow_executors/step_executors/git_make_branch_executor.go diff --git a/app/workflow_executors/step_executors/git_make_pull_request_executor.go b/v1/app/workflow_executors/step_executors/git_make_pull_request_executor.go similarity index 100% rename from app/workflow_executors/step_executors/git_make_pull_request_executor.go rename to v1/app/workflow_executors/step_executors/git_make_pull_request_executor.go diff --git a/app/workflow_executors/step_executors/git_push_executor.go b/v1/app/workflow_executors/step_executors/git_push_executor.go similarity index 100% rename from app/workflow_executors/step_executors/git_push_executor.go rename to v1/app/workflow_executors/step_executors/git_push_executor.go diff --git a/app/workflow_executors/step_executors/graph/execution_state.go b/v1/app/workflow_executors/step_executors/graph/execution_state.go similarity index 100% rename from app/workflow_executors/step_executors/graph/execution_state.go rename to v1/app/workflow_executors/step_executors/graph/execution_state.go diff --git a/app/workflow_executors/step_executors/graph/step_graph.go b/v1/app/workflow_executors/step_executors/graph/step_graph.go similarity index 100% rename from app/workflow_executors/step_executors/graph/step_graph.go rename to v1/app/workflow_executors/step_executors/graph/step_graph.go diff --git a/app/workflow_executors/step_executors/graph/step_node.go b/v1/app/workflow_executors/step_executors/graph/step_node.go similarity index 100% rename from app/workflow_executors/step_executors/graph/step_node.go rename to v1/app/workflow_executors/step_executors/graph/step_node.go diff --git a/app/workflow_executors/step_executors/impl/django_reset_db_step_executor.go b/v1/app/workflow_executors/step_executors/impl/django_reset_db_step_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/django_reset_db_step_executor.go rename to v1/app/workflow_executors/step_executors/impl/django_reset_db_step_executor.go diff --git a/app/workflow_executors/step_executors/impl/django_server_step_executor.go b/v1/app/workflow_executors/step_executors/impl/django_server_step_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/django_server_step_executor.go rename to v1/app/workflow_executors/step_executors/impl/django_server_step_executor.go diff --git a/app/workflow_executors/step_executors/impl/flask_reset_db_step_executor.go b/v1/app/workflow_executors/step_executors/impl/flask_reset_db_step_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/flask_reset_db_step_executor.go rename to v1/app/workflow_executors/step_executors/impl/flask_reset_db_step_executor.go diff --git a/app/workflow_executors/step_executors/impl/flask_sever_test_executor.go b/v1/app/workflow_executors/step_executors/impl/flask_sever_test_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/flask_sever_test_executor.go rename to v1/app/workflow_executors/step_executors/impl/flask_sever_test_executor.go diff --git a/app/workflow_executors/step_executors/impl/git_commit_executor.go b/v1/app/workflow_executors/step_executors/impl/git_commit_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/git_commit_executor.go rename to v1/app/workflow_executors/step_executors/impl/git_commit_executor.go diff --git a/app/workflow_executors/step_executors/impl/git_make_branch_executor.go b/v1/app/workflow_executors/step_executors/impl/git_make_branch_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/git_make_branch_executor.go rename to v1/app/workflow_executors/step_executors/impl/git_make_branch_executor.go diff --git a/app/workflow_executors/step_executors/impl/git_push_executor.go b/v1/app/workflow_executors/step_executors/impl/git_push_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/git_push_executor.go rename to v1/app/workflow_executors/step_executors/impl/git_push_executor.go diff --git a/app/workflow_executors/step_executors/impl/gitness_make_pull_request_executor.go b/v1/app/workflow_executors/step_executors/impl/gitness_make_pull_request_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/gitness_make_pull_request_executor.go rename to v1/app/workflow_executors/step_executors/impl/gitness_make_pull_request_executor.go diff --git a/app/workflow_executors/step_executors/impl/next_js_server_test_executor.go b/v1/app/workflow_executors/step_executors/impl/next_js_server_test_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/next_js_server_test_executor.go rename to v1/app/workflow_executors/step_executors/impl/next_js_server_test_executor.go diff --git a/app/workflow_executors/step_executors/impl/open_ai_code_generation_executor.go b/v1/app/workflow_executors/step_executors/impl/open_ai_code_generation_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/open_ai_code_generation_executor.go rename to v1/app/workflow_executors/step_executors/impl/open_ai_code_generation_executor.go diff --git a/app/workflow_executors/step_executors/impl/open_ai_next_js_code_generation_executor.go b/v1/app/workflow_executors/step_executors/impl/open_ai_next_js_code_generation_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/open_ai_next_js_code_generation_executor.go rename to v1/app/workflow_executors/step_executors/impl/open_ai_next_js_code_generation_executor.go diff --git a/app/workflow_executors/step_executors/impl/open_ai_next_js_update_code_file_executor.go b/v1/app/workflow_executors/step_executors/impl/open_ai_next_js_update_code_file_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/open_ai_next_js_update_code_file_executor.go rename to v1/app/workflow_executors/step_executors/impl/open_ai_next_js_update_code_file_executor.go diff --git a/app/workflow_executors/step_executors/impl/open_ai_update_code_file_executor.go b/v1/app/workflow_executors/step_executors/impl/open_ai_update_code_file_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/open_ai_update_code_file_executor.go rename to v1/app/workflow_executors/step_executors/impl/open_ai_update_code_file_executor.go diff --git a/app/workflow_executors/step_executors/impl/python_poetry_package_install_executor.go b/v1/app/workflow_executors/step_executors/impl/python_poetry_package_install_executor.go similarity index 100% rename from app/workflow_executors/step_executors/impl/python_poetry_package_install_executor.go rename to v1/app/workflow_executors/step_executors/impl/python_poetry_package_install_executor.go diff --git a/app/workflow_executors/step_executors/package_install_executor.go b/v1/app/workflow_executors/step_executors/package_install_executor.go similarity index 100% rename from app/workflow_executors/step_executors/package_install_executor.go rename to v1/app/workflow_executors/step_executors/package_install_executor.go diff --git a/app/workflow_executors/step_executors/reset_db_step_executor.go b/v1/app/workflow_executors/step_executors/reset_db_step_executor.go similarity index 100% rename from app/workflow_executors/step_executors/reset_db_step_executor.go rename to v1/app/workflow_executors/step_executors/reset_db_step_executor.go diff --git a/app/workflow_executors/step_executors/server_start_test_executor.go b/v1/app/workflow_executors/step_executors/server_start_test_executor.go similarity index 100% rename from app/workflow_executors/step_executors/server_start_test_executor.go rename to v1/app/workflow_executors/step_executors/server_start_test_executor.go diff --git a/app/workflow_executors/step_executors/step_executor.go b/v1/app/workflow_executors/step_executors/step_executor.go similarity index 100% rename from app/workflow_executors/step_executors/step_executor.go rename to v1/app/workflow_executors/step_executors/step_executor.go diff --git a/app/workflow_executors/step_executors/steps/base_step.go b/v1/app/workflow_executors/step_executors/steps/base_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/base_step.go rename to v1/app/workflow_executors/step_executors/steps/base_step.go diff --git a/app/workflow_executors/step_executors/steps/code_generate_step.go b/v1/app/workflow_executors/step_executors/steps/code_generate_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/code_generate_step.go rename to v1/app/workflow_executors/step_executors/steps/code_generate_step.go diff --git a/app/workflow_executors/step_executors/steps/git_commit_step.go b/v1/app/workflow_executors/step_executors/steps/git_commit_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/git_commit_step.go rename to v1/app/workflow_executors/step_executors/steps/git_commit_step.go diff --git a/app/workflow_executors/step_executors/steps/git_make_branch_step.go b/v1/app/workflow_executors/step_executors/steps/git_make_branch_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/git_make_branch_step.go rename to v1/app/workflow_executors/step_executors/steps/git_make_branch_step.go diff --git a/app/workflow_executors/step_executors/steps/git_make_pull_request.go b/v1/app/workflow_executors/step_executors/steps/git_make_pull_request.go similarity index 100% rename from app/workflow_executors/step_executors/steps/git_make_pull_request.go rename to v1/app/workflow_executors/step_executors/steps/git_make_pull_request.go diff --git a/app/workflow_executors/step_executors/steps/git_push_step.go b/v1/app/workflow_executors/step_executors/steps/git_push_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/git_push_step.go rename to v1/app/workflow_executors/step_executors/steps/git_push_step.go diff --git a/app/workflow_executors/step_executors/steps/package_install_step.go b/v1/app/workflow_executors/step_executors/steps/package_install_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/package_install_step.go rename to v1/app/workflow_executors/step_executors/steps/package_install_step.go diff --git a/app/workflow_executors/step_executors/steps/reset_db_step.go b/v1/app/workflow_executors/step_executors/steps/reset_db_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/reset_db_step.go rename to v1/app/workflow_executors/step_executors/steps/reset_db_step.go diff --git a/app/workflow_executors/step_executors/steps/server_start_test_step.go b/v1/app/workflow_executors/step_executors/steps/server_start_test_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/server_start_test_step.go rename to v1/app/workflow_executors/step_executors/steps/server_start_test_step.go diff --git a/app/workflow_executors/step_executors/steps/step_names.go b/v1/app/workflow_executors/step_executors/steps/step_names.go similarity index 100% rename from app/workflow_executors/step_executors/steps/step_names.go rename to v1/app/workflow_executors/step_executors/steps/step_names.go diff --git a/app/workflow_executors/step_executors/steps/step_types.go b/v1/app/workflow_executors/step_executors/steps/step_types.go similarity index 100% rename from app/workflow_executors/step_executors/steps/step_types.go rename to v1/app/workflow_executors/step_executors/steps/step_types.go diff --git a/app/workflow_executors/step_executors/steps/update_code_file_step.go b/v1/app/workflow_executors/step_executors/steps/update_code_file_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/update_code_file_step.go rename to v1/app/workflow_executors/step_executors/steps/update_code_file_step.go diff --git a/app/workflow_executors/step_executors/steps/workflow_step.go b/v1/app/workflow_executors/step_executors/steps/workflow_step.go similarity index 100% rename from app/workflow_executors/step_executors/steps/workflow_step.go rename to v1/app/workflow_executors/step_executors/steps/workflow_step.go diff --git a/app/workflow_executors/step_executors/update_code_executor.go b/v1/app/workflow_executors/step_executors/update_code_executor.go similarity index 100% rename from app/workflow_executors/step_executors/update_code_executor.go rename to v1/app/workflow_executors/step_executors/update_code_executor.go diff --git a/app/workflow_executors/workflow_config.go b/v1/app/workflow_executors/workflow_config.go similarity index 100% rename from app/workflow_executors/workflow_config.go rename to v1/app/workflow_executors/workflow_config.go diff --git a/app/workflow_executors/workflow_execution_args.go b/v1/app/workflow_executors/workflow_execution_args.go similarity index 100% rename from app/workflow_executors/workflow_execution_args.go rename to v1/app/workflow_executors/workflow_execution_args.go diff --git a/app/workflow_executors/workflow_executor.go b/v1/app/workflow_executors/workflow_executor.go similarity index 100% rename from app/workflow_executors/workflow_executor.go rename to v1/app/workflow_executors/workflow_executor.go diff --git a/bin/migrations.sh b/v1/bin/migrations.sh similarity index 100% rename from bin/migrations.sh rename to v1/bin/migrations.sh diff --git a/docker-compose.yaml b/v1/docker-compose.yaml similarity index 100% rename from docker-compose.yaml rename to v1/docker-compose.yaml diff --git a/docker/nginx/default.conf b/v1/docker/nginx/default.conf similarity index 100% rename from docker/nginx/default.conf rename to v1/docker/nginx/default.conf diff --git a/executor.go b/v1/executor.go similarity index 100% rename from executor.go rename to v1/executor.go diff --git a/go.mod b/v1/go.mod similarity index 100% rename from go.mod rename to v1/go.mod diff --git a/go.sum b/v1/go.sum similarity index 100% rename from go.sum rename to v1/go.sum diff --git a/gui/.dockerignore b/v1/gui/.dockerignore similarity index 100% rename from gui/.dockerignore rename to v1/gui/.dockerignore diff --git a/gui/.eslintignore b/v1/gui/.eslintignore similarity index 100% rename from gui/.eslintignore rename to v1/gui/.eslintignore diff --git a/gui/.eslintrc.json b/v1/gui/.eslintrc.json similarity index 100% rename from gui/.eslintrc.json rename to v1/gui/.eslintrc.json diff --git a/gui/.gitignore b/v1/gui/.gitignore similarity index 100% rename from gui/.gitignore rename to v1/gui/.gitignore diff --git a/gui/.prettierrc.js b/v1/gui/.prettierrc.js similarity index 100% rename from gui/.prettierrc.js rename to v1/gui/.prettierrc.js diff --git a/gui/.stylelintignore b/v1/gui/.stylelintignore similarity index 100% rename from gui/.stylelintignore rename to v1/gui/.stylelintignore diff --git a/gui/.stylelintrc.json b/v1/gui/.stylelintrc.json similarity index 100% rename from gui/.stylelintrc.json rename to v1/gui/.stylelintrc.json diff --git a/gui/Dockerfile b/v1/gui/Dockerfile similarity index 100% rename from gui/Dockerfile rename to v1/gui/Dockerfile diff --git a/gui/README.md b/v1/gui/README.md similarity index 100% rename from gui/README.md rename to v1/gui/README.md diff --git a/gui/global.d.ts b/v1/gui/global.d.ts similarity index 100% rename from gui/global.d.ts rename to v1/gui/global.d.ts diff --git a/gui/next.config.mjs b/v1/gui/next.config.mjs similarity index 100% rename from gui/next.config.mjs rename to v1/gui/next.config.mjs diff --git a/gui/package.json b/v1/gui/package.json similarity index 100% rename from gui/package.json rename to v1/gui/package.json diff --git a/gui/postcss.config.mjs b/v1/gui/postcss.config.mjs similarity index 100% rename from gui/postcss.config.mjs rename to v1/gui/postcss.config.mjs diff --git a/gui/prod.Dockerfile b/v1/gui/prod.Dockerfile similarity index 100% rename from gui/prod.Dockerfile rename to v1/gui/prod.Dockerfile diff --git a/gui/public/arrows/back_arrow.svg b/v1/gui/public/arrows/back_arrow.svg similarity index 100% rename from gui/public/arrows/back_arrow.svg rename to v1/gui/public/arrows/back_arrow.svg diff --git a/gui/public/arrows/bottom_arrow_grey.svg b/v1/gui/public/arrows/bottom_arrow_grey.svg similarity index 100% rename from gui/public/arrows/bottom_arrow_grey.svg rename to v1/gui/public/arrows/bottom_arrow_grey.svg diff --git a/gui/public/arrows/bottom_arrow_thin_grey.svg b/v1/gui/public/arrows/bottom_arrow_thin_grey.svg similarity index 100% rename from gui/public/arrows/bottom_arrow_thin_grey.svg rename to v1/gui/public/arrows/bottom_arrow_thin_grey.svg diff --git a/gui/public/arrows/bottom_arrow_white.svg b/v1/gui/public/arrows/bottom_arrow_white.svg similarity index 100% rename from gui/public/arrows/bottom_arrow_white.svg rename to v1/gui/public/arrows/bottom_arrow_white.svg diff --git a/gui/public/arrows/left_arrow_grey.svg b/v1/gui/public/arrows/left_arrow_grey.svg similarity index 100% rename from gui/public/arrows/left_arrow_grey.svg rename to v1/gui/public/arrows/left_arrow_grey.svg diff --git a/gui/public/arrows/right_arrow_thin_grey.svg b/v1/gui/public/arrows/right_arrow_thin_grey.svg similarity index 100% rename from gui/public/arrows/right_arrow_thin_grey.svg rename to v1/gui/public/arrows/right_arrow_thin_grey.svg diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Black.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Bold.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Extrabold.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Light.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Regular.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Semibold.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin-Italic.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin-Italic.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin-Italic.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin-Italic.otf diff --git a/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin.otf b/v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin.otf similarity index 100% rename from gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin.otf rename to v1/gui/public/fonts/proxima-nova-2/Mark-Simonson-Proxima-Nova-Thin.otf diff --git a/gui/public/icons/add_icon.svg b/v1/gui/public/icons/add_icon.svg similarity index 100% rename from gui/public/icons/add_icon.svg rename to v1/gui/public/icons/add_icon.svg diff --git a/gui/public/icons/application_icons/discord_icon.svg b/v1/gui/public/icons/application_icons/discord_icon.svg similarity index 100% rename from gui/public/icons/application_icons/discord_icon.svg rename to v1/gui/public/icons/application_icons/discord_icon.svg diff --git a/gui/public/icons/application_icons/github_icon.svg b/v1/gui/public/icons/application_icons/github_icon.svg similarity index 100% rename from gui/public/icons/application_icons/github_icon.svg rename to v1/gui/public/icons/application_icons/github_icon.svg diff --git a/gui/public/icons/browser_icon.svg b/v1/gui/public/icons/browser_icon.svg similarity index 100% rename from gui/public/icons/browser_icon.svg rename to v1/gui/public/icons/browser_icon.svg diff --git a/gui/public/icons/browser_icon_dark.svg b/v1/gui/public/icons/browser_icon_dark.svg similarity index 100% rename from gui/public/icons/browser_icon_dark.svg rename to v1/gui/public/icons/browser_icon_dark.svg diff --git a/gui/public/icons/chat_bubble_icon.svg b/v1/gui/public/icons/chat_bubble_icon.svg similarity index 100% rename from gui/public/icons/chat_bubble_icon.svg rename to v1/gui/public/icons/chat_bubble_icon.svg diff --git a/gui/public/icons/claude_icon.svg b/v1/gui/public/icons/claude_icon.svg similarity index 100% rename from gui/public/icons/claude_icon.svg rename to v1/gui/public/icons/claude_icon.svg diff --git a/gui/public/icons/clock_icon.svg b/v1/gui/public/icons/clock_icon.svg similarity index 100% rename from gui/public/icons/clock_icon.svg rename to v1/gui/public/icons/clock_icon.svg diff --git a/gui/public/icons/close_icon.svg b/v1/gui/public/icons/close_icon.svg similarity index 100% rename from gui/public/icons/close_icon.svg rename to v1/gui/public/icons/close_icon.svg diff --git a/gui/public/icons/code_add_icon.svg b/v1/gui/public/icons/code_add_icon.svg similarity index 100% rename from gui/public/icons/code_add_icon.svg rename to v1/gui/public/icons/code_add_icon.svg diff --git a/gui/public/icons/code_minus_icon.svg b/v1/gui/public/icons/code_minus_icon.svg similarity index 100% rename from gui/public/icons/code_minus_icon.svg rename to v1/gui/public/icons/code_minus_icon.svg diff --git a/gui/public/icons/copy_icon.svg b/v1/gui/public/icons/copy_icon.svg similarity index 100% rename from gui/public/icons/copy_icon.svg rename to v1/gui/public/icons/copy_icon.svg diff --git a/gui/public/icons/delete_icon.svg b/v1/gui/public/icons/delete_icon.svg similarity index 100% rename from gui/public/icons/delete_icon.svg rename to v1/gui/public/icons/delete_icon.svg diff --git a/gui/public/icons/done_dot.svg b/v1/gui/public/icons/done_dot.svg similarity index 100% rename from gui/public/icons/done_dot.svg rename to v1/gui/public/icons/done_dot.svg diff --git a/gui/public/icons/edit_icon.svg b/v1/gui/public/icons/edit_icon.svg similarity index 100% rename from gui/public/icons/edit_icon.svg rename to v1/gui/public/icons/edit_icon.svg diff --git a/gui/public/icons/empty_files_icon.svg b/v1/gui/public/icons/empty_files_icon.svg similarity index 100% rename from gui/public/icons/empty_files_icon.svg rename to v1/gui/public/icons/empty_files_icon.svg diff --git a/gui/public/icons/horizontal_three_dots.svg b/v1/gui/public/icons/horizontal_three_dots.svg similarity index 100% rename from gui/public/icons/horizontal_three_dots.svg rename to v1/gui/public/icons/horizontal_three_dots.svg diff --git a/gui/public/icons/in_review_dot.svg b/v1/gui/public/icons/in_review_dot.svg similarity index 100% rename from gui/public/icons/in_review_dot.svg rename to v1/gui/public/icons/in_review_dot.svg diff --git a/gui/public/icons/inprogress_dot.svg b/v1/gui/public/icons/inprogress_dot.svg similarity index 100% rename from gui/public/icons/inprogress_dot.svg rename to v1/gui/public/icons/inprogress_dot.svg diff --git a/gui/public/icons/logout_icon.svg b/v1/gui/public/icons/logout_icon.svg similarity index 100% rename from gui/public/icons/logout_icon.svg rename to v1/gui/public/icons/logout_icon.svg diff --git a/gui/public/icons/move_to_icon.svg b/v1/gui/public/icons/move_to_icon.svg similarity index 100% rename from gui/public/icons/move_to_icon.svg rename to v1/gui/public/icons/move_to_icon.svg diff --git a/gui/public/icons/openai_icon.svg b/v1/gui/public/icons/openai_icon.svg similarity index 100% rename from gui/public/icons/openai_icon.svg rename to v1/gui/public/icons/openai_icon.svg diff --git a/gui/public/icons/overview_warning_yellow.svg b/v1/gui/public/icons/overview_warning_yellow.svg similarity index 100% rename from gui/public/icons/overview_warning_yellow.svg rename to v1/gui/public/icons/overview_warning_yellow.svg diff --git a/gui/public/icons/password_hidden.svg b/v1/gui/public/icons/password_hidden.svg similarity index 100% rename from gui/public/icons/password_hidden.svg rename to v1/gui/public/icons/password_hidden.svg diff --git a/gui/public/icons/password_unhidden.svg b/v1/gui/public/icons/password_unhidden.svg similarity index 100% rename from gui/public/icons/password_unhidden.svg rename to v1/gui/public/icons/password_unhidden.svg diff --git a/gui/public/icons/play_icon.svg b/v1/gui/public/icons/play_icon.svg similarity index 100% rename from gui/public/icons/play_icon.svg rename to v1/gui/public/icons/play_icon.svg diff --git a/gui/public/icons/project_icon.svg b/v1/gui/public/icons/project_icon.svg similarity index 100% rename from gui/public/icons/project_icon.svg rename to v1/gui/public/icons/project_icon.svg diff --git a/gui/public/icons/pull_requests/pr_closed_icon.svg b/v1/gui/public/icons/pull_requests/pr_closed_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_closed_icon.svg rename to v1/gui/public/icons/pull_requests/pr_closed_icon.svg diff --git a/gui/public/icons/pull_requests/pr_merged_icon.svg b/v1/gui/public/icons/pull_requests/pr_merged_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_merged_icon.svg rename to v1/gui/public/icons/pull_requests/pr_merged_icon.svg diff --git a/gui/public/icons/pull_requests/pr_open_grey_icon.svg b/v1/gui/public/icons/pull_requests/pr_open_grey_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_open_grey_icon.svg rename to v1/gui/public/icons/pull_requests/pr_open_grey_icon.svg diff --git a/gui/public/icons/pull_requests/pr_open_icon.svg b/v1/gui/public/icons/pull_requests/pr_open_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_open_icon.svg rename to v1/gui/public/icons/pull_requests/pr_open_icon.svg diff --git a/gui/public/icons/pull_requests/pr_open_white_icon.svg b/v1/gui/public/icons/pull_requests/pr_open_white_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_open_white_icon.svg rename to v1/gui/public/icons/pull_requests/pr_open_white_icon.svg diff --git a/gui/public/icons/pull_requests/pr_ready_icon.svg b/v1/gui/public/icons/pull_requests/pr_ready_icon.svg similarity index 100% rename from gui/public/icons/pull_requests/pr_ready_icon.svg rename to v1/gui/public/icons/pull_requests/pr_ready_icon.svg diff --git a/gui/public/icons/red_cross_delete_icon.svg b/v1/gui/public/icons/red_cross_delete_icon.svg similarity index 100% rename from gui/public/icons/red_cross_delete_icon.svg rename to v1/gui/public/icons/red_cross_delete_icon.svg diff --git a/gui/public/icons/search_icon.svg b/v1/gui/public/icons/search_icon.svg similarity index 100% rename from gui/public/icons/search_icon.svg rename to v1/gui/public/icons/search_icon.svg diff --git a/gui/public/icons/selected/backend_workbench.svg b/v1/gui/public/icons/selected/backend_workbench.svg similarity index 100% rename from gui/public/icons/selected/backend_workbench.svg rename to v1/gui/public/icons/selected/backend_workbench.svg diff --git a/gui/public/icons/selected/board.svg b/v1/gui/public/icons/selected/board.svg similarity index 100% rename from gui/public/icons/selected/board.svg rename to v1/gui/public/icons/selected/board.svg diff --git a/gui/public/icons/selected/code.svg b/v1/gui/public/icons/selected/code.svg similarity index 100% rename from gui/public/icons/selected/code.svg rename to v1/gui/public/icons/selected/code.svg diff --git a/gui/public/icons/selected/commits.svg b/v1/gui/public/icons/selected/commits.svg similarity index 100% rename from gui/public/icons/selected/commits.svg rename to v1/gui/public/icons/selected/commits.svg diff --git a/gui/public/icons/selected/design.svg b/v1/gui/public/icons/selected/design.svg similarity index 100% rename from gui/public/icons/selected/design.svg rename to v1/gui/public/icons/selected/design.svg diff --git a/gui/public/icons/selected/files_changed.svg b/v1/gui/public/icons/selected/files_changed.svg similarity index 100% rename from gui/public/icons/selected/files_changed.svg rename to v1/gui/public/icons/selected/files_changed.svg diff --git a/gui/public/icons/selected/frontend_workbench.svg b/v1/gui/public/icons/selected/frontend_workbench.svg similarity index 100% rename from gui/public/icons/selected/frontend_workbench.svg rename to v1/gui/public/icons/selected/frontend_workbench.svg diff --git a/gui/public/icons/selected/instructions.svg b/v1/gui/public/icons/selected/instructions.svg similarity index 100% rename from gui/public/icons/selected/instructions.svg rename to v1/gui/public/icons/selected/instructions.svg diff --git a/gui/public/icons/selected/models.svg b/v1/gui/public/icons/selected/models.svg similarity index 100% rename from gui/public/icons/selected/models.svg rename to v1/gui/public/icons/selected/models.svg diff --git a/gui/public/icons/selected/overview.svg b/v1/gui/public/icons/selected/overview.svg similarity index 100% rename from gui/public/icons/selected/overview.svg rename to v1/gui/public/icons/selected/overview.svg diff --git a/gui/public/icons/selected/pull_requests.svg b/v1/gui/public/icons/selected/pull_requests.svg similarity index 100% rename from gui/public/icons/selected/pull_requests.svg rename to v1/gui/public/icons/selected/pull_requests.svg diff --git a/gui/public/icons/selected/reports.svg b/v1/gui/public/icons/selected/reports.svg similarity index 100% rename from gui/public/icons/selected/reports.svg rename to v1/gui/public/icons/selected/reports.svg diff --git a/gui/public/icons/selected/test_cases.svg b/v1/gui/public/icons/selected/test_cases.svg similarity index 100% rename from gui/public/icons/selected/test_cases.svg rename to v1/gui/public/icons/selected/test_cases.svg diff --git a/gui/public/icons/selected/visual_diff.svg b/v1/gui/public/icons/selected/visual_diff.svg similarity index 100% rename from gui/public/icons/selected/visual_diff.svg rename to v1/gui/public/icons/selected/visual_diff.svg diff --git a/gui/public/icons/settings_icon.svg b/v1/gui/public/icons/settings_icon.svg similarity index 100% rename from gui/public/icons/settings_icon.svg rename to v1/gui/public/icons/settings_icon.svg diff --git a/gui/public/icons/todo_dot.svg b/v1/gui/public/icons/todo_dot.svg similarity index 100% rename from gui/public/icons/todo_dot.svg rename to v1/gui/public/icons/todo_dot.svg diff --git a/gui/public/icons/unselected/backend_workbench.svg b/v1/gui/public/icons/unselected/backend_workbench.svg similarity index 100% rename from gui/public/icons/unselected/backend_workbench.svg rename to v1/gui/public/icons/unselected/backend_workbench.svg diff --git a/gui/public/icons/unselected/board.svg b/v1/gui/public/icons/unselected/board.svg similarity index 100% rename from gui/public/icons/unselected/board.svg rename to v1/gui/public/icons/unselected/board.svg diff --git a/gui/public/icons/unselected/code.svg b/v1/gui/public/icons/unselected/code.svg similarity index 100% rename from gui/public/icons/unselected/code.svg rename to v1/gui/public/icons/unselected/code.svg diff --git a/gui/public/icons/unselected/commits.svg b/v1/gui/public/icons/unselected/commits.svg similarity index 100% rename from gui/public/icons/unselected/commits.svg rename to v1/gui/public/icons/unselected/commits.svg diff --git a/gui/public/icons/unselected/deploy.svg b/v1/gui/public/icons/unselected/deploy.svg similarity index 100% rename from gui/public/icons/unselected/deploy.svg rename to v1/gui/public/icons/unselected/deploy.svg diff --git a/gui/public/icons/unselected/design.svg b/v1/gui/public/icons/unselected/design.svg similarity index 100% rename from gui/public/icons/unselected/design.svg rename to v1/gui/public/icons/unselected/design.svg diff --git a/gui/public/icons/unselected/files_changed.svg b/v1/gui/public/icons/unselected/files_changed.svg similarity index 100% rename from gui/public/icons/unselected/files_changed.svg rename to v1/gui/public/icons/unselected/files_changed.svg diff --git a/gui/public/icons/unselected/frontend_workbench.svg b/v1/gui/public/icons/unselected/frontend_workbench.svg similarity index 100% rename from gui/public/icons/unselected/frontend_workbench.svg rename to v1/gui/public/icons/unselected/frontend_workbench.svg diff --git a/gui/public/icons/unselected/instructions.svg b/v1/gui/public/icons/unselected/instructions.svg similarity index 100% rename from gui/public/icons/unselected/instructions.svg rename to v1/gui/public/icons/unselected/instructions.svg diff --git a/gui/public/icons/unselected/models.svg b/v1/gui/public/icons/unselected/models.svg similarity index 100% rename from gui/public/icons/unselected/models.svg rename to v1/gui/public/icons/unselected/models.svg diff --git a/gui/public/icons/unselected/overview.svg b/v1/gui/public/icons/unselected/overview.svg similarity index 100% rename from gui/public/icons/unselected/overview.svg rename to v1/gui/public/icons/unselected/overview.svg diff --git a/gui/public/icons/unselected/pull_requests.svg b/v1/gui/public/icons/unselected/pull_requests.svg similarity index 100% rename from gui/public/icons/unselected/pull_requests.svg rename to v1/gui/public/icons/unselected/pull_requests.svg diff --git a/gui/public/icons/unselected/reports.svg b/v1/gui/public/icons/unselected/reports.svg similarity index 100% rename from gui/public/icons/unselected/reports.svg rename to v1/gui/public/icons/unselected/reports.svg diff --git a/gui/public/icons/unselected/test_cases.svg b/v1/gui/public/icons/unselected/test_cases.svg similarity index 100% rename from gui/public/icons/unselected/test_cases.svg rename to v1/gui/public/icons/unselected/test_cases.svg diff --git a/gui/public/icons/unselected/visual_diff.svg b/v1/gui/public/icons/unselected/visual_diff.svg similarity index 100% rename from gui/public/icons/unselected/visual_diff.svg rename to v1/gui/public/icons/unselected/visual_diff.svg diff --git a/gui/public/icons/upload_icon.svg b/v1/gui/public/icons/upload_icon.svg similarity index 100% rename from gui/public/icons/upload_icon.svg rename to v1/gui/public/icons/upload_icon.svg diff --git a/gui/public/icons/vertical_line.svg b/v1/gui/public/icons/vertical_line.svg similarity index 100% rename from gui/public/icons/vertical_line.svg rename to v1/gui/public/icons/vertical_line.svg diff --git a/gui/public/icons/white_dot.svg b/v1/gui/public/icons/white_dot.svg similarity index 100% rename from gui/public/icons/white_dot.svg rename to v1/gui/public/icons/white_dot.svg diff --git a/gui/public/images/django_image.png b/v1/gui/public/images/django_image.png similarity index 100% rename from gui/public/images/django_image.png rename to v1/gui/public/images/django_image.png diff --git a/gui/public/images/fastapi_image.png b/v1/gui/public/images/fastapi_image.png similarity index 100% rename from gui/public/images/fastapi_image.png rename to v1/gui/public/images/fastapi_image.png diff --git a/gui/public/images/flask_image.png b/v1/gui/public/images/flask_image.png similarity index 100% rename from gui/public/images/flask_image.png rename to v1/gui/public/images/flask_image.png diff --git a/gui/public/images/nextjs_image.png b/v1/gui/public/images/nextjs_image.png similarity index 100% rename from gui/public/images/nextjs_image.png rename to v1/gui/public/images/nextjs_image.png diff --git a/gui/public/images/supercoder_image.svg b/v1/gui/public/images/supercoder_image.svg similarity index 100% rename from gui/public/images/supercoder_image.svg rename to v1/gui/public/images/supercoder_image.svg diff --git a/gui/public/logos/github_logo.svg b/v1/gui/public/logos/github_logo.svg similarity index 100% rename from gui/public/logos/github_logo.svg rename to v1/gui/public/logos/github_logo.svg diff --git a/gui/public/logos/superagi_icon_logo.svg b/v1/gui/public/logos/superagi_icon_logo.svg similarity index 100% rename from gui/public/logos/superagi_icon_logo.svg rename to v1/gui/public/logos/superagi_icon_logo.svg diff --git a/gui/public/logos/superagi_logo.svg b/v1/gui/public/logos/superagi_logo.svg similarity index 100% rename from gui/public/logos/superagi_logo.svg rename to v1/gui/public/logos/superagi_logo.svg diff --git a/gui/public/logos/superagi_logo_round.svg b/v1/gui/public/logos/superagi_logo_round.svg similarity index 100% rename from gui/public/logos/superagi_logo_round.svg rename to v1/gui/public/logos/superagi_logo_round.svg diff --git a/gui/src/api/DashboardService.tsx b/v1/gui/src/api/DashboardService.tsx similarity index 100% rename from gui/src/api/DashboardService.tsx rename to v1/gui/src/api/DashboardService.tsx diff --git a/gui/src/api/apiConfig.tsx b/v1/gui/src/api/apiConfig.tsx similarity index 100% rename from gui/src/api/apiConfig.tsx rename to v1/gui/src/api/apiConfig.tsx diff --git a/gui/src/app/(programmer)/board/board.module.css b/v1/gui/src/app/(programmer)/board/board.module.css similarity index 100% rename from gui/src/app/(programmer)/board/board.module.css rename to v1/gui/src/app/(programmer)/board/board.module.css diff --git a/gui/src/app/(programmer)/board/layout.tsx b/v1/gui/src/app/(programmer)/board/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/board/layout.tsx rename to v1/gui/src/app/(programmer)/board/layout.tsx diff --git a/gui/src/app/(programmer)/board/page.tsx b/v1/gui/src/app/(programmer)/board/page.tsx similarity index 100% rename from gui/src/app/(programmer)/board/page.tsx rename to v1/gui/src/app/(programmer)/board/page.tsx diff --git a/gui/src/app/(programmer)/code/Code.tsx b/v1/gui/src/app/(programmer)/code/Code.tsx similarity index 100% rename from gui/src/app/(programmer)/code/Code.tsx rename to v1/gui/src/app/(programmer)/code/Code.tsx diff --git a/gui/src/app/(programmer)/code/page.tsx b/v1/gui/src/app/(programmer)/code/page.tsx similarity index 100% rename from gui/src/app/(programmer)/code/page.tsx rename to v1/gui/src/app/(programmer)/code/page.tsx diff --git a/gui/src/app/(programmer)/design/layout.tsx b/v1/gui/src/app/(programmer)/design/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/design/layout.tsx rename to v1/gui/src/app/(programmer)/design/layout.tsx diff --git a/gui/src/app/(programmer)/design/page.tsx b/v1/gui/src/app/(programmer)/design/page.tsx similarity index 100% rename from gui/src/app/(programmer)/design/page.tsx rename to v1/gui/src/app/(programmer)/design/page.tsx diff --git a/gui/src/app/(programmer)/design/review/[story_id]/page.tsx b/v1/gui/src/app/(programmer)/design/review/[story_id]/page.tsx similarity index 100% rename from gui/src/app/(programmer)/design/review/[story_id]/page.tsx rename to v1/gui/src/app/(programmer)/design/review/[story_id]/page.tsx diff --git a/gui/src/app/(programmer)/design/review/[story_id]/review.module.css b/v1/gui/src/app/(programmer)/design/review/[story_id]/review.module.css similarity index 100% rename from gui/src/app/(programmer)/design/review/[story_id]/review.module.css rename to v1/gui/src/app/(programmer)/design/review/[story_id]/review.module.css diff --git a/gui/src/app/(programmer)/design_workbench/layout.tsx b/v1/gui/src/app/(programmer)/design_workbench/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/design_workbench/layout.tsx rename to v1/gui/src/app/(programmer)/design_workbench/layout.tsx diff --git a/gui/src/app/(programmer)/design_workbench/page.tsx b/v1/gui/src/app/(programmer)/design_workbench/page.tsx similarity index 100% rename from gui/src/app/(programmer)/design_workbench/page.tsx rename to v1/gui/src/app/(programmer)/design_workbench/page.tsx diff --git a/gui/src/app/(programmer)/layout.tsx b/v1/gui/src/app/(programmer)/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/layout.tsx rename to v1/gui/src/app/(programmer)/layout.tsx diff --git a/gui/src/app/(programmer)/pull_request/PRList/GithubReviewButton.tsx b/v1/gui/src/app/(programmer)/pull_request/PRList/GithubReviewButton.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/PRList/GithubReviewButton.tsx rename to v1/gui/src/app/(programmer)/pull_request/PRList/GithubReviewButton.tsx diff --git a/gui/src/app/(programmer)/pull_request/PRList/PRList.tsx b/v1/gui/src/app/(programmer)/pull_request/PRList/PRList.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/PRList/PRList.tsx rename to v1/gui/src/app/(programmer)/pull_request/PRList/PRList.tsx diff --git a/gui/src/app/(programmer)/pull_request/[pr_id]/CommitLogs.tsx b/v1/gui/src/app/(programmer)/pull_request/[pr_id]/CommitLogs.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/[pr_id]/CommitLogs.tsx rename to v1/gui/src/app/(programmer)/pull_request/[pr_id]/CommitLogs.tsx diff --git a/gui/src/app/(programmer)/pull_request/[pr_id]/FilesChanged.tsx b/v1/gui/src/app/(programmer)/pull_request/[pr_id]/FilesChanged.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/[pr_id]/FilesChanged.tsx rename to v1/gui/src/app/(programmer)/pull_request/[pr_id]/FilesChanged.tsx diff --git a/gui/src/app/(programmer)/pull_request/[pr_id]/OpenTag.ts b/v1/gui/src/app/(programmer)/pull_request/[pr_id]/OpenTag.ts similarity index 100% rename from gui/src/app/(programmer)/pull_request/[pr_id]/OpenTag.ts rename to v1/gui/src/app/(programmer)/pull_request/[pr_id]/OpenTag.ts diff --git a/gui/src/app/(programmer)/pull_request/[pr_id]/page.tsx b/v1/gui/src/app/(programmer)/pull_request/[pr_id]/page.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/[pr_id]/page.tsx rename to v1/gui/src/app/(programmer)/pull_request/[pr_id]/page.tsx diff --git a/gui/src/app/(programmer)/pull_request/layout.tsx b/v1/gui/src/app/(programmer)/pull_request/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/layout.tsx rename to v1/gui/src/app/(programmer)/pull_request/layout.tsx diff --git a/gui/src/app/(programmer)/pull_request/page.tsx b/v1/gui/src/app/(programmer)/pull_request/page.tsx similarity index 100% rename from gui/src/app/(programmer)/pull_request/page.tsx rename to v1/gui/src/app/(programmer)/pull_request/page.tsx diff --git a/gui/src/app/(programmer)/pull_request/pr.module.css b/v1/gui/src/app/(programmer)/pull_request/pr.module.css similarity index 100% rename from gui/src/app/(programmer)/pull_request/pr.module.css rename to v1/gui/src/app/(programmer)/pull_request/pr.module.css diff --git a/gui/src/app/(programmer)/workbench/layout.tsx b/v1/gui/src/app/(programmer)/workbench/layout.tsx similarity index 100% rename from gui/src/app/(programmer)/workbench/layout.tsx rename to v1/gui/src/app/(programmer)/workbench/layout.tsx diff --git a/gui/src/app/(programmer)/workbench/page.tsx b/v1/gui/src/app/(programmer)/workbench/page.tsx similarity index 100% rename from gui/src/app/(programmer)/workbench/page.tsx rename to v1/gui/src/app/(programmer)/workbench/page.tsx diff --git a/gui/src/app/_app.css b/v1/gui/src/app/_app.css similarity index 100% rename from gui/src/app/_app.css rename to v1/gui/src/app/_app.css diff --git a/gui/src/app/constants/ActivityLogType.ts b/v1/gui/src/app/constants/ActivityLogType.ts similarity index 100% rename from gui/src/app/constants/ActivityLogType.ts rename to v1/gui/src/app/constants/ActivityLogType.ts diff --git a/gui/src/app/constants/BoardConstants.ts b/v1/gui/src/app/constants/BoardConstants.ts similarity index 100% rename from gui/src/app/constants/BoardConstants.ts rename to v1/gui/src/app/constants/BoardConstants.ts diff --git a/gui/src/app/constants/NavbarConstants.ts b/v1/gui/src/app/constants/NavbarConstants.ts similarity index 100% rename from gui/src/app/constants/NavbarConstants.ts rename to v1/gui/src/app/constants/NavbarConstants.ts diff --git a/gui/src/app/constants/ProjectConstants.ts b/v1/gui/src/app/constants/ProjectConstants.ts similarity index 100% rename from gui/src/app/constants/ProjectConstants.ts rename to v1/gui/src/app/constants/ProjectConstants.ts diff --git a/gui/src/app/constants/PullRequestConstants.ts b/v1/gui/src/app/constants/PullRequestConstants.ts similarity index 100% rename from gui/src/app/constants/PullRequestConstants.ts rename to v1/gui/src/app/constants/PullRequestConstants.ts diff --git a/gui/src/app/constants/SidebarConstants.ts b/v1/gui/src/app/constants/SidebarConstants.ts similarity index 100% rename from gui/src/app/constants/SidebarConstants.ts rename to v1/gui/src/app/constants/SidebarConstants.ts diff --git a/gui/src/app/constants/SkeletonConstants.ts b/v1/gui/src/app/constants/SkeletonConstants.ts similarity index 100% rename from gui/src/app/constants/SkeletonConstants.ts rename to v1/gui/src/app/constants/SkeletonConstants.ts diff --git a/gui/src/app/constants/UtilsConstants.ts b/v1/gui/src/app/constants/UtilsConstants.ts similarity index 100% rename from gui/src/app/constants/UtilsConstants.ts rename to v1/gui/src/app/constants/UtilsConstants.ts diff --git a/gui/src/app/imagePath.tsx b/v1/gui/src/app/imagePath.tsx similarity index 100% rename from gui/src/app/imagePath.tsx rename to v1/gui/src/app/imagePath.tsx diff --git a/gui/src/app/layout.tsx b/v1/gui/src/app/layout.tsx similarity index 100% rename from gui/src/app/layout.tsx rename to v1/gui/src/app/layout.tsx diff --git a/gui/src/app/logout/page.tsx b/v1/gui/src/app/logout/page.tsx similarity index 100% rename from gui/src/app/logout/page.tsx rename to v1/gui/src/app/logout/page.tsx diff --git a/gui/src/app/page.tsx b/v1/gui/src/app/page.tsx similarity index 100% rename from gui/src/app/page.tsx rename to v1/gui/src/app/page.tsx diff --git a/gui/src/app/projects/layout.tsx b/v1/gui/src/app/projects/layout.tsx similarity index 100% rename from gui/src/app/projects/layout.tsx rename to v1/gui/src/app/projects/layout.tsx diff --git a/gui/src/app/projects/page.tsx b/v1/gui/src/app/projects/page.tsx similarity index 100% rename from gui/src/app/projects/page.tsx rename to v1/gui/src/app/projects/page.tsx diff --git a/gui/src/app/projects/projects.module.css b/v1/gui/src/app/projects/projects.module.css similarity index 100% rename from gui/src/app/projects/projects.module.css rename to v1/gui/src/app/projects/projects.module.css diff --git a/gui/src/app/providers.tsx b/v1/gui/src/app/providers.tsx similarity index 100% rename from gui/src/app/providers.tsx rename to v1/gui/src/app/providers.tsx diff --git a/gui/src/app/settings/SettingsOptions/Models.tsx b/v1/gui/src/app/settings/SettingsOptions/Models.tsx similarity index 100% rename from gui/src/app/settings/SettingsOptions/Models.tsx rename to v1/gui/src/app/settings/SettingsOptions/Models.tsx diff --git a/gui/src/app/settings/layout.tsx b/v1/gui/src/app/settings/layout.tsx similarity index 100% rename from gui/src/app/settings/layout.tsx rename to v1/gui/src/app/settings/layout.tsx diff --git a/gui/src/app/settings/page.tsx b/v1/gui/src/app/settings/page.tsx similarity index 100% rename from gui/src/app/settings/page.tsx rename to v1/gui/src/app/settings/page.tsx diff --git a/gui/src/app/utils.tsx b/v1/gui/src/app/utils.tsx similarity index 100% rename from gui/src/app/utils.tsx rename to v1/gui/src/app/utils.tsx diff --git a/gui/src/components/BackButton/BackButton.tsx b/v1/gui/src/components/BackButton/BackButton.tsx similarity index 100% rename from gui/src/components/BackButton/BackButton.tsx rename to v1/gui/src/components/BackButton/BackButton.tsx diff --git a/gui/src/components/CustomContainers/CustomContainers.tsx b/v1/gui/src/components/CustomContainers/CustomContainers.tsx similarity index 100% rename from gui/src/components/CustomContainers/CustomContainers.tsx rename to v1/gui/src/components/CustomContainers/CustomContainers.tsx diff --git a/gui/src/components/CustomContainers/container.module.css b/v1/gui/src/components/CustomContainers/container.module.css similarity index 100% rename from gui/src/components/CustomContainers/container.module.css rename to v1/gui/src/components/CustomContainers/container.module.css diff --git a/gui/src/components/CustomDiffEditor/MonacoDiffEditor.tsx b/v1/gui/src/components/CustomDiffEditor/MonacoDiffEditor.tsx similarity index 100% rename from gui/src/components/CustomDiffEditor/MonacoDiffEditor.tsx rename to v1/gui/src/components/CustomDiffEditor/MonacoDiffEditor.tsx diff --git a/gui/src/components/CustomDiffEditor/diff.module.css b/v1/gui/src/components/CustomDiffEditor/diff.module.css similarity index 100% rename from gui/src/components/CustomDiffEditor/diff.module.css rename to v1/gui/src/components/CustomDiffEditor/diff.module.css diff --git a/gui/src/components/CustomDrawer/CustomDrawer.tsx b/v1/gui/src/components/CustomDrawer/CustomDrawer.tsx similarity index 100% rename from gui/src/components/CustomDrawer/CustomDrawer.tsx rename to v1/gui/src/components/CustomDrawer/CustomDrawer.tsx diff --git a/gui/src/components/CustomDrawer/drawer.module.css b/v1/gui/src/components/CustomDrawer/drawer.module.css similarity index 100% rename from gui/src/components/CustomDrawer/drawer.module.css rename to v1/gui/src/components/CustomDrawer/drawer.module.css diff --git a/gui/src/components/CustomDropdown/CustomDropdown.tsx b/v1/gui/src/components/CustomDropdown/CustomDropdown.tsx similarity index 100% rename from gui/src/components/CustomDropdown/CustomDropdown.tsx rename to v1/gui/src/components/CustomDropdown/CustomDropdown.tsx diff --git a/gui/src/components/CustomDropdown/dropdown.module.css b/v1/gui/src/components/CustomDropdown/dropdown.module.css similarity index 100% rename from gui/src/components/CustomDropdown/dropdown.module.css rename to v1/gui/src/components/CustomDropdown/dropdown.module.css diff --git a/gui/src/components/CustomInput/CustomInput.tsx b/v1/gui/src/components/CustomInput/CustomInput.tsx similarity index 100% rename from gui/src/components/CustomInput/CustomInput.tsx rename to v1/gui/src/components/CustomInput/CustomInput.tsx diff --git a/gui/src/components/CustomInput/input.module.css b/v1/gui/src/components/CustomInput/input.module.css similarity index 100% rename from gui/src/components/CustomInput/input.module.css rename to v1/gui/src/components/CustomInput/input.module.css diff --git a/gui/src/components/CustomLoaders/CustomLoaders.tsx b/v1/gui/src/components/CustomLoaders/CustomLoaders.tsx similarity index 100% rename from gui/src/components/CustomLoaders/CustomLoaders.tsx rename to v1/gui/src/components/CustomLoaders/CustomLoaders.tsx diff --git a/gui/src/components/CustomLoaders/Loader.tsx b/v1/gui/src/components/CustomLoaders/Loader.tsx similarity index 100% rename from gui/src/components/CustomLoaders/Loader.tsx rename to v1/gui/src/components/CustomLoaders/Loader.tsx diff --git a/gui/src/components/CustomLoaders/SkeletonLoader.tsx b/v1/gui/src/components/CustomLoaders/SkeletonLoader.tsx similarity index 100% rename from gui/src/components/CustomLoaders/SkeletonLoader.tsx rename to v1/gui/src/components/CustomLoaders/SkeletonLoader.tsx diff --git a/gui/src/components/CustomLoaders/loader.module.css b/v1/gui/src/components/CustomLoaders/loader.module.css similarity index 100% rename from gui/src/components/CustomLoaders/loader.module.css rename to v1/gui/src/components/CustomLoaders/loader.module.css diff --git a/gui/src/components/CustomModal/CustomModal.tsx b/v1/gui/src/components/CustomModal/CustomModal.tsx similarity index 100% rename from gui/src/components/CustomModal/CustomModal.tsx rename to v1/gui/src/components/CustomModal/CustomModal.tsx diff --git a/gui/src/components/CustomModal/modal.module.css b/v1/gui/src/components/CustomModal/modal.module.css similarity index 100% rename from gui/src/components/CustomModal/modal.module.css rename to v1/gui/src/components/CustomModal/modal.module.css diff --git a/gui/src/components/CustomSelect/CustomSelect.tsx b/v1/gui/src/components/CustomSelect/CustomSelect.tsx similarity index 100% rename from gui/src/components/CustomSelect/CustomSelect.tsx rename to v1/gui/src/components/CustomSelect/CustomSelect.tsx diff --git a/gui/src/components/CustomSelect/select.module.css b/v1/gui/src/components/CustomSelect/select.module.css similarity index 100% rename from gui/src/components/CustomSelect/select.module.css rename to v1/gui/src/components/CustomSelect/select.module.css diff --git a/gui/src/components/CustomSidebar/CustomSidebar.tsx b/v1/gui/src/components/CustomSidebar/CustomSidebar.tsx similarity index 100% rename from gui/src/components/CustomSidebar/CustomSidebar.tsx rename to v1/gui/src/components/CustomSidebar/CustomSidebar.tsx diff --git a/gui/src/components/CustomSidebar/sidebar.module.css b/v1/gui/src/components/CustomSidebar/sidebar.module.css similarity index 100% rename from gui/src/components/CustomSidebar/sidebar.module.css rename to v1/gui/src/components/CustomSidebar/sidebar.module.css diff --git a/gui/src/components/CustomTabs/CustomTabs.tsx b/v1/gui/src/components/CustomTabs/CustomTabs.tsx similarity index 100% rename from gui/src/components/CustomTabs/CustomTabs.tsx rename to v1/gui/src/components/CustomTabs/CustomTabs.tsx diff --git a/gui/src/components/CustomTabs/tabs.module.css b/v1/gui/src/components/CustomTabs/tabs.module.css similarity index 100% rename from gui/src/components/CustomTabs/tabs.module.css rename to v1/gui/src/components/CustomTabs/tabs.module.css diff --git a/gui/src/components/CustomTag/CustomTag.tsx b/v1/gui/src/components/CustomTag/CustomTag.tsx similarity index 100% rename from gui/src/components/CustomTag/CustomTag.tsx rename to v1/gui/src/components/CustomTag/CustomTag.tsx diff --git a/gui/src/components/CustomTag/tag.module.css b/v1/gui/src/components/CustomTag/tag.module.css similarity index 100% rename from gui/src/components/CustomTag/tag.module.css rename to v1/gui/src/components/CustomTag/tag.module.css diff --git a/gui/src/components/CustomTimeline/CustomTimeline.tsx b/v1/gui/src/components/CustomTimeline/CustomTimeline.tsx similarity index 100% rename from gui/src/components/CustomTimeline/CustomTimeline.tsx rename to v1/gui/src/components/CustomTimeline/CustomTimeline.tsx diff --git a/gui/src/components/CustomTimeline/timeline.module.css b/v1/gui/src/components/CustomTimeline/timeline.module.css similarity index 100% rename from gui/src/components/CustomTimeline/timeline.module.css rename to v1/gui/src/components/CustomTimeline/timeline.module.css diff --git a/gui/src/components/DesignStoryComponents/CreateEditDesignStory.tsx b/v1/gui/src/components/DesignStoryComponents/CreateEditDesignStory.tsx similarity index 100% rename from gui/src/components/DesignStoryComponents/CreateEditDesignStory.tsx rename to v1/gui/src/components/DesignStoryComponents/CreateEditDesignStory.tsx diff --git a/gui/src/components/DesignStoryComponents/DesignStoryDetails.tsx b/v1/gui/src/components/DesignStoryComponents/DesignStoryDetails.tsx similarity index 100% rename from gui/src/components/DesignStoryComponents/DesignStoryDetails.tsx rename to v1/gui/src/components/DesignStoryComponents/DesignStoryDetails.tsx diff --git a/gui/src/components/DesignStoryComponents/DesignStoryList.tsx b/v1/gui/src/components/DesignStoryComponents/DesignStoryList.tsx similarity index 100% rename from gui/src/components/DesignStoryComponents/DesignStoryList.tsx rename to v1/gui/src/components/DesignStoryComponents/DesignStoryList.tsx diff --git a/gui/src/components/DesignStoryComponents/FrontendCodeSection.tsx b/v1/gui/src/components/DesignStoryComponents/FrontendCodeSection.tsx similarity index 100% rename from gui/src/components/DesignStoryComponents/FrontendCodeSection.tsx rename to v1/gui/src/components/DesignStoryComponents/FrontendCodeSection.tsx diff --git a/gui/src/components/DesignStoryComponents/ReviewList.tsx b/v1/gui/src/components/DesignStoryComponents/ReviewList.tsx similarity index 100% rename from gui/src/components/DesignStoryComponents/ReviewList.tsx rename to v1/gui/src/components/DesignStoryComponents/ReviewList.tsx diff --git a/gui/src/components/DesignStoryComponents/desingStory.module.css b/v1/gui/src/components/DesignStoryComponents/desingStory.module.css similarity index 100% rename from gui/src/components/DesignStoryComponents/desingStory.module.css rename to v1/gui/src/components/DesignStoryComponents/desingStory.module.css diff --git a/gui/src/components/DiffViewer/DiffViewer.tsx b/v1/gui/src/components/DiffViewer/DiffViewer.tsx similarity index 100% rename from gui/src/components/DiffViewer/DiffViewer.tsx rename to v1/gui/src/components/DiffViewer/DiffViewer.tsx diff --git a/gui/src/components/DiffViewer/diff.module.css b/v1/gui/src/components/DiffViewer/diff.module.css similarity index 100% rename from gui/src/components/DiffViewer/diff.module.css rename to v1/gui/src/components/DiffViewer/diff.module.css diff --git a/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx b/v1/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx similarity index 100% rename from gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx rename to v1/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx diff --git a/gui/src/components/HomeComponents/GithubStarModal.tsx b/v1/gui/src/components/HomeComponents/GithubStarModal.tsx similarity index 100% rename from gui/src/components/HomeComponents/GithubStarModal.tsx rename to v1/gui/src/components/HomeComponents/GithubStarModal.tsx diff --git a/gui/src/components/HomeComponents/LandingPage.tsx b/v1/gui/src/components/HomeComponents/LandingPage.tsx similarity index 100% rename from gui/src/components/HomeComponents/LandingPage.tsx rename to v1/gui/src/components/HomeComponents/LandingPage.tsx diff --git a/gui/src/components/HomeComponents/home.module.css b/v1/gui/src/components/HomeComponents/home.module.css similarity index 100% rename from gui/src/components/HomeComponents/home.module.css rename to v1/gui/src/components/HomeComponents/home.module.css diff --git a/gui/src/components/ImageComponents/CustomImage.tsx b/v1/gui/src/components/ImageComponents/CustomImage.tsx similarity index 100% rename from gui/src/components/ImageComponents/CustomImage.tsx rename to v1/gui/src/components/ImageComponents/CustomImage.tsx diff --git a/gui/src/components/ImageComponents/CustomImageSelector.tsx b/v1/gui/src/components/ImageComponents/CustomImageSelector.tsx similarity index 100% rename from gui/src/components/ImageComponents/CustomImageSelector.tsx rename to v1/gui/src/components/ImageComponents/CustomImageSelector.tsx diff --git a/gui/src/components/ImageComponents/CustomTextImage.tsx b/v1/gui/src/components/ImageComponents/CustomTextImage.tsx similarity index 100% rename from gui/src/components/ImageComponents/CustomTextImage.tsx rename to v1/gui/src/components/ImageComponents/CustomTextImage.tsx diff --git a/gui/src/components/ImageComponents/image.module.css b/v1/gui/src/components/ImageComponents/image.module.css similarity index 100% rename from gui/src/components/ImageComponents/image.module.css rename to v1/gui/src/components/ImageComponents/image.module.css diff --git a/gui/src/components/LayoutComponents/NavBar.tsx b/v1/gui/src/components/LayoutComponents/NavBar.tsx similarity index 100% rename from gui/src/components/LayoutComponents/NavBar.tsx rename to v1/gui/src/components/LayoutComponents/NavBar.tsx diff --git a/gui/src/components/LayoutComponents/SideBar.tsx b/v1/gui/src/components/LayoutComponents/SideBar.tsx similarity index 100% rename from gui/src/components/LayoutComponents/SideBar.tsx rename to v1/gui/src/components/LayoutComponents/SideBar.tsx diff --git a/gui/src/components/LayoutComponents/style.css b/v1/gui/src/components/LayoutComponents/style.css similarity index 100% rename from gui/src/components/LayoutComponents/style.css rename to v1/gui/src/components/LayoutComponents/style.css diff --git a/gui/src/components/RebuildModal/RebuildModal.tsx b/v1/gui/src/components/RebuildModal/RebuildModal.tsx similarity index 100% rename from gui/src/components/RebuildModal/RebuildModal.tsx rename to v1/gui/src/components/RebuildModal/RebuildModal.tsx diff --git a/gui/src/components/StoryComponents/CreateEditStory.tsx b/v1/gui/src/components/StoryComponents/CreateEditStory.tsx similarity index 100% rename from gui/src/components/StoryComponents/CreateEditStory.tsx rename to v1/gui/src/components/StoryComponents/CreateEditStory.tsx diff --git a/gui/src/components/StoryComponents/InReviewIssue.tsx b/v1/gui/src/components/StoryComponents/InReviewIssue.tsx similarity index 100% rename from gui/src/components/StoryComponents/InReviewIssue.tsx rename to v1/gui/src/components/StoryComponents/InReviewIssue.tsx diff --git a/gui/src/components/StoryComponents/InputSection.tsx b/v1/gui/src/components/StoryComponents/InputSection.tsx similarity index 100% rename from gui/src/components/StoryComponents/InputSection.tsx rename to v1/gui/src/components/StoryComponents/InputSection.tsx diff --git a/gui/src/components/StoryComponents/Instructions.tsx b/v1/gui/src/components/StoryComponents/Instructions.tsx similarity index 100% rename from gui/src/components/StoryComponents/Instructions.tsx rename to v1/gui/src/components/StoryComponents/Instructions.tsx diff --git a/gui/src/components/StoryComponents/Overview.tsx b/v1/gui/src/components/StoryComponents/Overview.tsx similarity index 100% rename from gui/src/components/StoryComponents/Overview.tsx rename to v1/gui/src/components/StoryComponents/Overview.tsx diff --git a/gui/src/components/StoryComponents/SetupModelModal.tsx b/v1/gui/src/components/StoryComponents/SetupModelModal.tsx similarity index 100% rename from gui/src/components/StoryComponents/SetupModelModal.tsx rename to v1/gui/src/components/StoryComponents/SetupModelModal.tsx diff --git a/gui/src/components/StoryComponents/StoryDetails.tsx b/v1/gui/src/components/StoryComponents/StoryDetails.tsx similarity index 100% rename from gui/src/components/StoryComponents/StoryDetails.tsx rename to v1/gui/src/components/StoryComponents/StoryDetails.tsx diff --git a/gui/src/components/StoryComponents/TestCases.tsx b/v1/gui/src/components/StoryComponents/TestCases.tsx similarity index 100% rename from gui/src/components/StoryComponents/TestCases.tsx rename to v1/gui/src/components/StoryComponents/TestCases.tsx diff --git a/gui/src/components/StoryComponents/story.module.css b/v1/gui/src/components/StoryComponents/story.module.css similarity index 100% rename from gui/src/components/StoryComponents/story.module.css rename to v1/gui/src/components/StoryComponents/story.module.css diff --git a/gui/src/components/SyntaxDisplay/SyntaxDisplay.tsx b/v1/gui/src/components/SyntaxDisplay/SyntaxDisplay.tsx similarity index 100% rename from gui/src/components/SyntaxDisplay/SyntaxDisplay.tsx rename to v1/gui/src/components/SyntaxDisplay/SyntaxDisplay.tsx diff --git a/gui/src/components/WorkBenchComponents/ActiveWorkbench.tsx b/v1/gui/src/components/WorkBenchComponents/ActiveWorkbench.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/ActiveWorkbench.tsx rename to v1/gui/src/components/WorkBenchComponents/ActiveWorkbench.tsx diff --git a/gui/src/components/WorkBenchComponents/Activity.tsx b/v1/gui/src/components/WorkBenchComponents/Activity.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/Activity.tsx rename to v1/gui/src/components/WorkBenchComponents/Activity.tsx diff --git a/gui/src/components/WorkBenchComponents/BackendWorkbench.tsx b/v1/gui/src/components/WorkBenchComponents/BackendWorkbench.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/BackendWorkbench.tsx rename to v1/gui/src/components/WorkBenchComponents/BackendWorkbench.tsx diff --git a/gui/src/components/WorkBenchComponents/Browser.tsx b/v1/gui/src/components/WorkBenchComponents/Browser.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/Browser.tsx rename to v1/gui/src/components/WorkBenchComponents/Browser.tsx diff --git a/gui/src/components/WorkBenchComponents/DesignWorkbench.tsx b/v1/gui/src/components/WorkBenchComponents/DesignWorkbench.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/DesignWorkbench.tsx rename to v1/gui/src/components/WorkBenchComponents/DesignWorkbench.tsx diff --git a/gui/src/components/WorkBenchComponents/StoryDetailsWorkbench.tsx b/v1/gui/src/components/WorkBenchComponents/StoryDetailsWorkbench.tsx similarity index 100% rename from gui/src/components/WorkBenchComponents/StoryDetailsWorkbench.tsx rename to v1/gui/src/components/WorkBenchComponents/StoryDetailsWorkbench.tsx diff --git a/gui/src/components/WorkBenchComponents/workbenchComponents.module.css b/v1/gui/src/components/WorkBenchComponents/workbenchComponents.module.css similarity index 100% rename from gui/src/components/WorkBenchComponents/workbenchComponents.module.css rename to v1/gui/src/components/WorkBenchComponents/workbenchComponents.module.css diff --git a/gui/src/context/Boards.tsx b/v1/gui/src/context/Boards.tsx similarity index 100% rename from gui/src/context/Boards.tsx rename to v1/gui/src/context/Boards.tsx diff --git a/gui/src/context/Design.tsx b/v1/gui/src/context/Design.tsx similarity index 100% rename from gui/src/context/Design.tsx rename to v1/gui/src/context/Design.tsx diff --git a/gui/src/context/PullRequests.tsx b/v1/gui/src/context/PullRequests.tsx similarity index 100% rename from gui/src/context/PullRequests.tsx rename to v1/gui/src/context/PullRequests.tsx diff --git a/gui/src/context/SocketContext.tsx b/v1/gui/src/context/SocketContext.tsx similarity index 100% rename from gui/src/context/SocketContext.tsx rename to v1/gui/src/context/SocketContext.tsx diff --git a/gui/src/context/UserContext.tsx b/v1/gui/src/context/UserContext.tsx similarity index 100% rename from gui/src/context/UserContext.tsx rename to v1/gui/src/context/UserContext.tsx diff --git a/gui/src/context/Workbench.tsx b/v1/gui/src/context/Workbench.tsx similarity index 100% rename from gui/src/context/Workbench.tsx rename to v1/gui/src/context/Workbench.tsx diff --git a/gui/src/hooks/useProjectDropdown.tsx b/v1/gui/src/hooks/useProjectDropdown.tsx similarity index 100% rename from gui/src/hooks/useProjectDropdown.tsx rename to v1/gui/src/hooks/useProjectDropdown.tsx diff --git a/gui/src/middleware.ts b/v1/gui/src/middleware.ts similarity index 100% rename from gui/src/middleware.ts rename to v1/gui/src/middleware.ts diff --git a/gui/src/utils/SocketUtils.tsx b/v1/gui/src/utils/SocketUtils.tsx similarity index 100% rename from gui/src/utils/SocketUtils.tsx rename to v1/gui/src/utils/SocketUtils.tsx diff --git a/gui/tailwind.config.ts b/v1/gui/tailwind.config.ts similarity index 100% rename from gui/tailwind.config.ts rename to v1/gui/tailwind.config.ts diff --git a/gui/tsconfig.json b/v1/gui/tsconfig.json similarity index 100% rename from gui/tsconfig.json rename to v1/gui/tsconfig.json diff --git a/gui/types/authTypes.ts b/v1/gui/types/authTypes.ts similarity index 100% rename from gui/types/authTypes.ts rename to v1/gui/types/authTypes.ts diff --git a/gui/types/customComponentTypes.ts b/v1/gui/types/customComponentTypes.ts similarity index 100% rename from gui/types/customComponentTypes.ts rename to v1/gui/types/customComponentTypes.ts diff --git a/gui/types/designStoryTypes.ts b/v1/gui/types/designStoryTypes.ts similarity index 100% rename from gui/types/designStoryTypes.ts rename to v1/gui/types/designStoryTypes.ts diff --git a/gui/types/imageComponentsTypes.ts b/v1/gui/types/imageComponentsTypes.ts similarity index 100% rename from gui/types/imageComponentsTypes.ts rename to v1/gui/types/imageComponentsTypes.ts diff --git a/gui/types/modelsTypes.ts b/v1/gui/types/modelsTypes.ts similarity index 100% rename from gui/types/modelsTypes.ts rename to v1/gui/types/modelsTypes.ts diff --git a/gui/types/navbarTypes.ts b/v1/gui/types/navbarTypes.ts similarity index 100% rename from gui/types/navbarTypes.ts rename to v1/gui/types/navbarTypes.ts diff --git a/gui/types/projectsTypes.ts b/v1/gui/types/projectsTypes.ts similarity index 100% rename from gui/types/projectsTypes.ts rename to v1/gui/types/projectsTypes.ts diff --git a/gui/types/pullRequestsTypes.ts b/v1/gui/types/pullRequestsTypes.ts similarity index 100% rename from gui/types/pullRequestsTypes.ts rename to v1/gui/types/pullRequestsTypes.ts diff --git a/gui/types/settingTypes.ts b/v1/gui/types/settingTypes.ts similarity index 100% rename from gui/types/settingTypes.ts rename to v1/gui/types/settingTypes.ts diff --git a/gui/types/sidebarTypes.ts b/v1/gui/types/sidebarTypes.ts similarity index 100% rename from gui/types/sidebarTypes.ts rename to v1/gui/types/sidebarTypes.ts diff --git a/gui/types/storyTypes.ts b/v1/gui/types/storyTypes.ts similarity index 100% rename from gui/types/storyTypes.ts rename to v1/gui/types/storyTypes.ts diff --git a/gui/types/workbenchTypes.ts b/v1/gui/types/workbenchTypes.ts similarity index 100% rename from gui/types/workbenchTypes.ts rename to v1/gui/types/workbenchTypes.ts diff --git a/gui/yarn.lock b/v1/gui/yarn.lock similarity index 100% rename from gui/yarn.lock rename to v1/gui/yarn.lock diff --git a/ide/node/Dockerfile b/v1/ide/node/Dockerfile similarity index 100% rename from ide/node/Dockerfile rename to v1/ide/node/Dockerfile diff --git a/ide/node/config.yaml b/v1/ide/node/config.yaml similarity index 100% rename from ide/node/config.yaml rename to v1/ide/node/config.yaml diff --git a/ide/node/settings.json b/v1/ide/node/settings.json similarity index 100% rename from ide/node/settings.json rename to v1/ide/node/settings.json diff --git a/ide/python/Dockerfile b/v1/ide/python/Dockerfile similarity index 100% rename from ide/python/Dockerfile rename to v1/ide/python/Dockerfile diff --git a/ide/python/config.yaml b/v1/ide/python/config.yaml similarity index 100% rename from ide/python/config.yaml rename to v1/ide/python/config.yaml diff --git a/ide/python/initialise.sh b/v1/ide/python/initialise.sh similarity index 100% rename from ide/python/initialise.sh rename to v1/ide/python/initialise.sh diff --git a/ide/python/settings.json b/v1/ide/python/settings.json similarity index 100% rename from ide/python/settings.json rename to v1/ide/python/settings.json diff --git a/server.go b/v1/server.go similarity index 100% rename from server.go rename to v1/server.go diff --git a/startup-worker.sh b/v1/startup-worker.sh similarity index 100% rename from startup-worker.sh rename to v1/startup-worker.sh diff --git a/startup.sh b/v1/startup.sh similarity index 100% rename from startup.sh rename to v1/startup.sh diff --git a/worker.go b/v1/worker.go similarity index 100% rename from worker.go rename to v1/worker.go diff --git a/workspace-service/.gitignore b/v1/workspace-service/.gitignore similarity index 100% rename from workspace-service/.gitignore rename to v1/workspace-service/.gitignore diff --git a/workspace-service/Dockerfile b/v1/workspace-service/Dockerfile similarity index 100% rename from workspace-service/Dockerfile rename to v1/workspace-service/Dockerfile diff --git a/workspace-service/app/clients/docker_client.go b/v1/workspace-service/app/clients/docker_client.go similarity index 100% rename from workspace-service/app/clients/docker_client.go rename to v1/workspace-service/app/clients/docker_client.go diff --git a/workspace-service/app/clients/k8s_client.go b/v1/workspace-service/app/clients/k8s_client.go similarity index 100% rename from workspace-service/app/clients/k8s_client.go rename to v1/workspace-service/app/clients/k8s_client.go diff --git a/workspace-service/app/clients/k8s_controller_client.go b/v1/workspace-service/app/clients/k8s_controller_client.go similarity index 100% rename from workspace-service/app/clients/k8s_controller_client.go rename to v1/workspace-service/app/clients/k8s_controller_client.go diff --git a/workspace-service/app/config/config.go b/v1/workspace-service/app/config/config.go similarity index 100% rename from workspace-service/app/config/config.go rename to v1/workspace-service/app/config/config.go diff --git a/workspace-service/app/config/env.go b/v1/workspace-service/app/config/env.go similarity index 100% rename from workspace-service/app/config/env.go rename to v1/workspace-service/app/config/env.go diff --git a/workspace-service/app/config/frontend_workspace_config.go b/v1/workspace-service/app/config/frontend_workspace_config.go similarity index 100% rename from workspace-service/app/config/frontend_workspace_config.go rename to v1/workspace-service/app/config/frontend_workspace_config.go diff --git a/workspace-service/app/config/new_relic.go b/v1/workspace-service/app/config/new_relic.go similarity index 100% rename from workspace-service/app/config/new_relic.go rename to v1/workspace-service/app/config/new_relic.go diff --git a/workspace-service/app/config/workspace_jobs.go b/v1/workspace-service/app/config/workspace_jobs.go similarity index 100% rename from workspace-service/app/config/workspace_jobs.go rename to v1/workspace-service/app/config/workspace_jobs.go diff --git a/workspace-service/app/config/workspace_service.go b/v1/workspace-service/app/config/workspace_service.go similarity index 100% rename from workspace-service/app/config/workspace_service.go rename to v1/workspace-service/app/config/workspace_service.go diff --git a/workspace-service/app/controllers/health_controller.go b/v1/workspace-service/app/controllers/health_controller.go similarity index 100% rename from workspace-service/app/controllers/health_controller.go rename to v1/workspace-service/app/controllers/health_controller.go diff --git a/workspace-service/app/controllers/jobs_controller.go b/v1/workspace-service/app/controllers/jobs_controller.go similarity index 100% rename from workspace-service/app/controllers/jobs_controller.go rename to v1/workspace-service/app/controllers/jobs_controller.go diff --git a/workspace-service/app/controllers/workspace_controller.go b/v1/workspace-service/app/controllers/workspace_controller.go similarity index 100% rename from workspace-service/app/controllers/workspace_controller.go rename to v1/workspace-service/app/controllers/workspace_controller.go diff --git a/workspace-service/app/models/dto/create_job.go b/v1/workspace-service/app/models/dto/create_job.go similarity index 100% rename from workspace-service/app/models/dto/create_job.go rename to v1/workspace-service/app/models/dto/create_job.go diff --git a/workspace-service/app/models/dto/create_workspace.go b/v1/workspace-service/app/models/dto/create_workspace.go similarity index 100% rename from workspace-service/app/models/dto/create_workspace.go rename to v1/workspace-service/app/models/dto/create_workspace.go diff --git a/workspace-service/app/models/dto/workspace_details.go b/v1/workspace-service/app/models/dto/workspace_details.go similarity index 100% rename from workspace-service/app/models/dto/workspace_details.go rename to v1/workspace-service/app/models/dto/workspace_details.go diff --git a/workspace-service/app/services/impl/docker_job_service.go b/v1/workspace-service/app/services/impl/docker_job_service.go similarity index 100% rename from workspace-service/app/services/impl/docker_job_service.go rename to v1/workspace-service/app/services/impl/docker_job_service.go diff --git a/workspace-service/app/services/impl/docker_workspace_service.go b/v1/workspace-service/app/services/impl/docker_workspace_service.go similarity index 100% rename from workspace-service/app/services/impl/docker_workspace_service.go rename to v1/workspace-service/app/services/impl/docker_workspace_service.go diff --git a/workspace-service/app/services/impl/k8s_job_service.go b/v1/workspace-service/app/services/impl/k8s_job_service.go similarity index 100% rename from workspace-service/app/services/impl/k8s_job_service.go rename to v1/workspace-service/app/services/impl/k8s_job_service.go diff --git a/workspace-service/app/services/impl/k8s_workspace_service.go b/v1/workspace-service/app/services/impl/k8s_workspace_service.go similarity index 100% rename from workspace-service/app/services/impl/k8s_workspace_service.go rename to v1/workspace-service/app/services/impl/k8s_workspace_service.go diff --git a/workspace-service/app/services/job_service.go b/v1/workspace-service/app/services/job_service.go similarity index 100% rename from workspace-service/app/services/job_service.go rename to v1/workspace-service/app/services/job_service.go diff --git a/workspace-service/app/services/workspace_service.go b/v1/workspace-service/app/services/workspace_service.go similarity index 100% rename from workspace-service/app/services/workspace_service.go rename to v1/workspace-service/app/services/workspace_service.go diff --git a/workspace-service/app/utils/fs_utils.go b/v1/workspace-service/app/utils/fs_utils.go similarity index 100% rename from workspace-service/app/utils/fs_utils.go rename to v1/workspace-service/app/utils/fs_utils.go diff --git a/workspace-service/go.mod b/v1/workspace-service/go.mod similarity index 100% rename from workspace-service/go.mod rename to v1/workspace-service/go.mod diff --git a/workspace-service/go.sum b/v1/workspace-service/go.sum similarity index 100% rename from workspace-service/go.sum rename to v1/workspace-service/go.sum diff --git a/workspace-service/server.go b/v1/workspace-service/server.go similarity index 100% rename from workspace-service/server.go rename to v1/workspace-service/server.go diff --git a/workspace-service/templates/django/.gitignore b/v1/workspace-service/templates/django/.gitignore similarity index 100% rename from workspace-service/templates/django/.gitignore rename to v1/workspace-service/templates/django/.gitignore diff --git a/workspace-service/templates/django/.vscode/tasks.json b/v1/workspace-service/templates/django/.vscode/tasks.json similarity index 100% rename from workspace-service/templates/django/.vscode/tasks.json rename to v1/workspace-service/templates/django/.vscode/tasks.json diff --git a/workspace-service/templates/django/manage.py b/v1/workspace-service/templates/django/manage.py similarity index 100% rename from workspace-service/templates/django/manage.py rename to v1/workspace-service/templates/django/manage.py diff --git a/workspace-service/templates/django/myapp/__init__.py b/v1/workspace-service/templates/django/myapp/__init__.py similarity index 100% rename from workspace-service/templates/django/myapp/__init__.py rename to v1/workspace-service/templates/django/myapp/__init__.py diff --git a/workspace-service/templates/django/myapp/admin.py b/v1/workspace-service/templates/django/myapp/admin.py similarity index 100% rename from workspace-service/templates/django/myapp/admin.py rename to v1/workspace-service/templates/django/myapp/admin.py diff --git a/workspace-service/templates/django/myapp/app.py b/v1/workspace-service/templates/django/myapp/app.py similarity index 100% rename from workspace-service/templates/django/myapp/app.py rename to v1/workspace-service/templates/django/myapp/app.py diff --git a/workspace-service/templates/django/myapp/migrations/__init__.py b/v1/workspace-service/templates/django/myapp/migrations/__init__.py similarity index 100% rename from workspace-service/templates/django/myapp/migrations/__init__.py rename to v1/workspace-service/templates/django/myapp/migrations/__init__.py diff --git a/workspace-service/templates/django/myapp/models.py b/v1/workspace-service/templates/django/myapp/models.py similarity index 100% rename from workspace-service/templates/django/myapp/models.py rename to v1/workspace-service/templates/django/myapp/models.py diff --git a/workspace-service/templates/django/myapp/tests.py b/v1/workspace-service/templates/django/myapp/tests.py similarity index 100% rename from workspace-service/templates/django/myapp/tests.py rename to v1/workspace-service/templates/django/myapp/tests.py diff --git a/workspace-service/templates/django/myapp/urls.py b/v1/workspace-service/templates/django/myapp/urls.py similarity index 100% rename from workspace-service/templates/django/myapp/urls.py rename to v1/workspace-service/templates/django/myapp/urls.py diff --git a/workspace-service/templates/django/myapp/views.py b/v1/workspace-service/templates/django/myapp/views.py similarity index 100% rename from workspace-service/templates/django/myapp/views.py rename to v1/workspace-service/templates/django/myapp/views.py diff --git a/workspace-service/templates/django/project/__init__.py b/v1/workspace-service/templates/django/project/__init__.py similarity index 100% rename from workspace-service/templates/django/project/__init__.py rename to v1/workspace-service/templates/django/project/__init__.py diff --git a/workspace-service/templates/django/project/asgi.py b/v1/workspace-service/templates/django/project/asgi.py similarity index 100% rename from workspace-service/templates/django/project/asgi.py rename to v1/workspace-service/templates/django/project/asgi.py diff --git a/workspace-service/templates/django/project/settings.py b/v1/workspace-service/templates/django/project/settings.py similarity index 100% rename from workspace-service/templates/django/project/settings.py rename to v1/workspace-service/templates/django/project/settings.py diff --git a/workspace-service/templates/django/project/urls.py b/v1/workspace-service/templates/django/project/urls.py similarity index 100% rename from workspace-service/templates/django/project/urls.py rename to v1/workspace-service/templates/django/project/urls.py diff --git a/workspace-service/templates/django/project/wsgi.py b/v1/workspace-service/templates/django/project/wsgi.py similarity index 100% rename from workspace-service/templates/django/project/wsgi.py rename to v1/workspace-service/templates/django/project/wsgi.py diff --git a/workspace-service/templates/django/pyproject.toml b/v1/workspace-service/templates/django/pyproject.toml similarity index 100% rename from workspace-service/templates/django/pyproject.toml rename to v1/workspace-service/templates/django/pyproject.toml diff --git a/workspace-service/templates/django/server_test.txt b/v1/workspace-service/templates/django/server_test.txt similarity index 100% rename from workspace-service/templates/django/server_test.txt rename to v1/workspace-service/templates/django/server_test.txt diff --git a/workspace-service/templates/django/static/css/styles.css b/v1/workspace-service/templates/django/static/css/styles.css similarity index 100% rename from workspace-service/templates/django/static/css/styles.css rename to v1/workspace-service/templates/django/static/css/styles.css diff --git a/workspace-service/templates/django/static/js/scripts.js b/v1/workspace-service/templates/django/static/js/scripts.js similarity index 100% rename from workspace-service/templates/django/static/js/scripts.js rename to v1/workspace-service/templates/django/static/js/scripts.js diff --git a/workspace-service/templates/django/templates/about.html b/v1/workspace-service/templates/django/templates/about.html similarity index 100% rename from workspace-service/templates/django/templates/about.html rename to v1/workspace-service/templates/django/templates/about.html diff --git a/workspace-service/templates/django/templates/home.html b/v1/workspace-service/templates/django/templates/home.html similarity index 100% rename from workspace-service/templates/django/templates/home.html rename to v1/workspace-service/templates/django/templates/home.html diff --git a/workspace-service/templates/django/templates/registration/login.html b/v1/workspace-service/templates/django/templates/registration/login.html similarity index 100% rename from workspace-service/templates/django/templates/registration/login.html rename to v1/workspace-service/templates/django/templates/registration/login.html diff --git a/workspace-service/templates/django/terminal.txt b/v1/workspace-service/templates/django/terminal.txt similarity index 100% rename from workspace-service/templates/django/terminal.txt rename to v1/workspace-service/templates/django/terminal.txt diff --git a/workspace-service/templates/flask/.gitignore b/v1/workspace-service/templates/flask/.gitignore similarity index 100% rename from workspace-service/templates/flask/.gitignore rename to v1/workspace-service/templates/flask/.gitignore diff --git a/workspace-service/templates/flask/.vscode/tasks.json b/v1/workspace-service/templates/flask/.vscode/tasks.json similarity index 100% rename from workspace-service/templates/flask/.vscode/tasks.json rename to v1/workspace-service/templates/flask/.vscode/tasks.json diff --git a/workspace-service/templates/flask/__init__.py b/v1/workspace-service/templates/flask/__init__.py similarity index 100% rename from workspace-service/templates/flask/__init__.py rename to v1/workspace-service/templates/flask/__init__.py diff --git a/workspace-service/templates/flask/app.py b/v1/workspace-service/templates/flask/app.py similarity index 100% rename from workspace-service/templates/flask/app.py rename to v1/workspace-service/templates/flask/app.py diff --git a/workspace-service/templates/flask/models/__init__.py b/v1/workspace-service/templates/flask/models/__init__.py similarity index 100% rename from workspace-service/templates/flask/models/__init__.py rename to v1/workspace-service/templates/flask/models/__init__.py diff --git a/workspace-service/templates/flask/models/model.py b/v1/workspace-service/templates/flask/models/model.py similarity index 100% rename from workspace-service/templates/flask/models/model.py rename to v1/workspace-service/templates/flask/models/model.py diff --git a/workspace-service/templates/flask/pyproject.toml b/v1/workspace-service/templates/flask/pyproject.toml similarity index 100% rename from workspace-service/templates/flask/pyproject.toml rename to v1/workspace-service/templates/flask/pyproject.toml diff --git a/workspace-service/templates/flask/static/css/style.css b/v1/workspace-service/templates/flask/static/css/style.css similarity index 100% rename from workspace-service/templates/flask/static/css/style.css rename to v1/workspace-service/templates/flask/static/css/style.css diff --git a/workspace-service/templates/flask/static/js/main.js b/v1/workspace-service/templates/flask/static/js/main.js similarity index 100% rename from workspace-service/templates/flask/static/js/main.js rename to v1/workspace-service/templates/flask/static/js/main.js diff --git a/workspace-service/templates/flask/templates/index.html b/v1/workspace-service/templates/flask/templates/index.html similarity index 100% rename from workspace-service/templates/flask/templates/index.html rename to v1/workspace-service/templates/flask/templates/index.html diff --git a/workspace-service/templates/nextjs/.eslintrc.json b/v1/workspace-service/templates/nextjs/.eslintrc.json similarity index 100% rename from workspace-service/templates/nextjs/.eslintrc.json rename to v1/workspace-service/templates/nextjs/.eslintrc.json diff --git a/workspace-service/templates/nextjs/.gitignore b/v1/workspace-service/templates/nextjs/.gitignore similarity index 100% rename from workspace-service/templates/nextjs/.gitignore rename to v1/workspace-service/templates/nextjs/.gitignore diff --git a/workspace-service/templates/nextjs/README.md b/v1/workspace-service/templates/nextjs/README.md similarity index 100% rename from workspace-service/templates/nextjs/README.md rename to v1/workspace-service/templates/nextjs/README.md diff --git a/workspace-service/templates/nextjs/app/favicon.ico b/v1/workspace-service/templates/nextjs/app/favicon.ico similarity index 100% rename from workspace-service/templates/nextjs/app/favicon.ico rename to v1/workspace-service/templates/nextjs/app/favicon.ico diff --git a/workspace-service/templates/nextjs/app/globals.css b/v1/workspace-service/templates/nextjs/app/globals.css similarity index 100% rename from workspace-service/templates/nextjs/app/globals.css rename to v1/workspace-service/templates/nextjs/app/globals.css diff --git a/workspace-service/templates/nextjs/app/layout.tsx b/v1/workspace-service/templates/nextjs/app/layout.tsx similarity index 100% rename from workspace-service/templates/nextjs/app/layout.tsx rename to v1/workspace-service/templates/nextjs/app/layout.tsx diff --git a/workspace-service/templates/nextjs/app/page.tsx b/v1/workspace-service/templates/nextjs/app/page.tsx similarity index 100% rename from workspace-service/templates/nextjs/app/page.tsx rename to v1/workspace-service/templates/nextjs/app/page.tsx diff --git a/workspace-service/templates/nextjs/next.config.mjs b/v1/workspace-service/templates/nextjs/next.config.mjs similarity index 100% rename from workspace-service/templates/nextjs/next.config.mjs rename to v1/workspace-service/templates/nextjs/next.config.mjs diff --git a/workspace-service/templates/nextjs/package-lock.json b/v1/workspace-service/templates/nextjs/package-lock.json similarity index 100% rename from workspace-service/templates/nextjs/package-lock.json rename to v1/workspace-service/templates/nextjs/package-lock.json diff --git a/workspace-service/templates/nextjs/package.json b/v1/workspace-service/templates/nextjs/package.json similarity index 100% rename from workspace-service/templates/nextjs/package.json rename to v1/workspace-service/templates/nextjs/package.json diff --git a/workspace-service/templates/nextjs/postcss.config.mjs b/v1/workspace-service/templates/nextjs/postcss.config.mjs similarity index 100% rename from workspace-service/templates/nextjs/postcss.config.mjs rename to v1/workspace-service/templates/nextjs/postcss.config.mjs diff --git a/workspace-service/templates/nextjs/public/next.svg b/v1/workspace-service/templates/nextjs/public/next.svg similarity index 100% rename from workspace-service/templates/nextjs/public/next.svg rename to v1/workspace-service/templates/nextjs/public/next.svg diff --git a/workspace-service/templates/nextjs/public/vercel.svg b/v1/workspace-service/templates/nextjs/public/vercel.svg similarity index 100% rename from workspace-service/templates/nextjs/public/vercel.svg rename to v1/workspace-service/templates/nextjs/public/vercel.svg diff --git a/workspace-service/templates/nextjs/tailwind.config.ts b/v1/workspace-service/templates/nextjs/tailwind.config.ts similarity index 100% rename from workspace-service/templates/nextjs/tailwind.config.ts rename to v1/workspace-service/templates/nextjs/tailwind.config.ts diff --git a/workspace-service/templates/nextjs/tsconfig.json b/v1/workspace-service/templates/nextjs/tsconfig.json similarity index 100% rename from workspace-service/templates/nextjs/tsconfig.json rename to v1/workspace-service/templates/nextjs/tsconfig.json From 77f460d5e983824c5adeeba83b941f7eee88d5ae Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Mon, 1 Jun 2026 18:41:56 +0530 Subject: [PATCH 02/26] chore: scaffold monorepo root Add root .gitignore (Rust/Node/Go/Bazel), placeholder README, LICENSE, and an empty apps/ directory. --- .gitignore | 29 +++++++++++++++-------------- LICENSE | 21 +++++++++++++++++++++ README.md | 16 ++++++++++++++++ apps/.gitkeep | 0 4 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/.gitkeep diff --git a/.gitignore b/.gitignore index f09ff79b..1875c4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,36 @@ -# Binaries for programs and plugins +# Rust +target/ + +# Node +node_modules/ +dist/ +*.tsbuildinfo + +# Go *.exe *.exe~ *.dll *.so *.dylib *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below if you use vendoring) vendor/ - -# Go workspace file go.work -# IDE and editor directories and files +# Bazel +bazel-* + +# IDE / editor .idea/ *.swp *.swo *.swn *~ -# Mac OS specific +# OS .DS_Store - -# Windows specific Thumbs.db -#workspace virtual environment -workspace/venv/ +# Env .env .envrc -gui/.husky/pre-commit \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1ec41bdb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 TransformerOptimus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..bbbcfdf7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# SuperCoder + +A local-first coding agent. OSS rewrite in progress. + +The previous (2024) implementation is preserved under [`v1/`](./v1) and is frozen — not maintained or built. + +## Layout + +``` +crates/ Rust agent core (agent, git-ops) +services/ Go services (gateway) +apps/ Desktop app (coming soon) +v1/ Legacy codegen pipeline (frozen) +``` + +Licensed under the MIT License — see [LICENSE](./LICENSE). diff --git a/apps/.gitkeep b/apps/.gitkeep new file mode 100644 index 00000000..e69de29b From 6ca6eaf5729a61e1c78caced56cebc65aaa324af Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Mon, 1 Jun 2026 18:43:36 +0530 Subject: [PATCH 03/26] Add agent and git-ops Rust crates Lift the agent harness and git-ops crate into crates/, with a Cargo workspace at the root (shared release profile: LTO, panic=abort, opt-level=s). --- Cargo.lock | 2263 +++++++++ Cargo.toml | 15 + crates/agent/Cargo.toml | 31 + crates/agent/default-skills/.gitkeep | 0 crates/agent/default-subagents/.gitkeep | 0 .../code-architect/code-architect.md | 37 + .../code-explorer/code-explorer.md | 53 + .../code-reviewer/code-reviewer.md | 49 + .../code-simplifier/code-simplifier.md | 52 + crates/agent/src/agent/ask_prompt.txt | 85 + crates/agent/src/agent/coding_prompt.txt | 138 + crates/agent/src/agent/compaction.rs | 433 ++ crates/agent/src/agent/config.rs | 126 + crates/agent/src/agent/loop_.rs | 4092 +++++++++++++++++ crates/agent/src/agent/mod.rs | 95 + crates/agent/src/agent/model_profile.rs | 239 + crates/agent/src/agent/plan_prompt.txt | 101 + crates/agent/src/agent/prompt.rs | 271 ++ crates/agent/src/agent/worktree.rs | 492 ++ crates/agent/src/approval.rs | 145 + crates/agent/src/context_engine.rs | 741 +++ crates/agent/src/error.rs | 28 + crates/agent/src/frontmatter.rs | 99 + crates/agent/src/lib.rs | 16 + crates/agent/src/llm/client.rs | 276 ++ crates/agent/src/llm/mod.rs | 6 + crates/agent/src/llm/sse.rs | 860 ++++ crates/agent/src/llm/types.rs | 397 ++ crates/agent/src/persistence.rs | 322 ++ crates/agent/src/session.rs | 719 +++ crates/agent/src/skills/mod.rs | 13 + crates/agent/src/skills/parse.rs | 152 + crates/agent/src/skills/registry.rs | 329 ++ crates/agent/src/skills/tool.rs | 218 + crates/agent/src/subagents/mod.rs | 14 + crates/agent/src/subagents/parse.rs | 173 + crates/agent/src/subagents/registry.rs | 307 ++ crates/agent/src/subagents/tool.rs | 432 ++ crates/agent/src/subagents/write_lock.rs | 100 + crates/agent/src/test_util.rs | 67 + crates/agent/src/tool/apply_patch.rs | 545 +++ crates/agent/src/tool/ask_user.rs | 145 + crates/agent/src/tool/bash.rs | 315 ++ crates/agent/src/tool/codebase_graph.rs | 423 ++ crates/agent/src/tool/codebase_search.rs | 617 +++ crates/agent/src/tool/edit.rs | 765 +++ crates/agent/src/tool/edit_plan.rs | 279 ++ crates/agent/src/tool/git_tool.rs | 574 +++ crates/agent/src/tool/glob.rs | 273 ++ crates/agent/src/tool/grep.rs | 320 ++ crates/agent/src/tool/mod.rs | 510 ++ crates/agent/src/tool/pr_tool.rs | 430 ++ crates/agent/src/tool/read.rs | 363 ++ crates/agent/src/tool/save_plan.rs | 144 + crates/agent/src/tool/schema.rs | 126 + crates/agent/src/tool/start_session.rs | 215 + crates/agent/src/tool/todo_write.rs | 264 ++ crates/agent/src/tool/write.rs | 136 + crates/agent/src/types.rs | 161 + crates/agent/src/util.rs | 353 ++ crates/agent/tests/integration.rs | 1108 +++++ crates/git-ops/Cargo.toml | 16 + crates/git-ops/src/checkpoint.rs | 494 ++ crates/git-ops/src/core.rs | 592 +++ crates/git-ops/src/error.rs | 33 + crates/git-ops/src/exec.rs | 104 + crates/git-ops/src/ide.rs | 89 + crates/git-ops/src/lib.rs | 17 + crates/git-ops/src/no_window.rs | 33 + crates/git-ops/src/pr.rs | 339 ++ crates/git-ops/src/test_util.rs | 41 + crates/git-ops/src/types.rs | 79 + crates/git-ops/src/worktree.rs | 208 + 73 files changed, 24097 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/agent/Cargo.toml create mode 100644 crates/agent/default-skills/.gitkeep create mode 100644 crates/agent/default-subagents/.gitkeep create mode 100644 crates/agent/default-subagents/code-architect/code-architect.md create mode 100644 crates/agent/default-subagents/code-explorer/code-explorer.md create mode 100644 crates/agent/default-subagents/code-reviewer/code-reviewer.md create mode 100644 crates/agent/default-subagents/code-simplifier/code-simplifier.md create mode 100644 crates/agent/src/agent/ask_prompt.txt create mode 100644 crates/agent/src/agent/coding_prompt.txt create mode 100644 crates/agent/src/agent/compaction.rs create mode 100644 crates/agent/src/agent/config.rs create mode 100644 crates/agent/src/agent/loop_.rs create mode 100644 crates/agent/src/agent/mod.rs create mode 100644 crates/agent/src/agent/model_profile.rs create mode 100644 crates/agent/src/agent/plan_prompt.txt create mode 100644 crates/agent/src/agent/prompt.rs create mode 100644 crates/agent/src/agent/worktree.rs create mode 100644 crates/agent/src/approval.rs create mode 100644 crates/agent/src/context_engine.rs create mode 100644 crates/agent/src/error.rs create mode 100644 crates/agent/src/frontmatter.rs create mode 100644 crates/agent/src/lib.rs create mode 100644 crates/agent/src/llm/client.rs create mode 100644 crates/agent/src/llm/mod.rs create mode 100644 crates/agent/src/llm/sse.rs create mode 100644 crates/agent/src/llm/types.rs create mode 100644 crates/agent/src/persistence.rs create mode 100644 crates/agent/src/session.rs create mode 100644 crates/agent/src/skills/mod.rs create mode 100644 crates/agent/src/skills/parse.rs create mode 100644 crates/agent/src/skills/registry.rs create mode 100644 crates/agent/src/skills/tool.rs create mode 100644 crates/agent/src/subagents/mod.rs create mode 100644 crates/agent/src/subagents/parse.rs create mode 100644 crates/agent/src/subagents/registry.rs create mode 100644 crates/agent/src/subagents/tool.rs create mode 100644 crates/agent/src/subagents/write_lock.rs create mode 100644 crates/agent/src/test_util.rs create mode 100644 crates/agent/src/tool/apply_patch.rs create mode 100644 crates/agent/src/tool/ask_user.rs create mode 100644 crates/agent/src/tool/bash.rs create mode 100644 crates/agent/src/tool/codebase_graph.rs create mode 100644 crates/agent/src/tool/codebase_search.rs create mode 100644 crates/agent/src/tool/edit.rs create mode 100644 crates/agent/src/tool/edit_plan.rs create mode 100644 crates/agent/src/tool/git_tool.rs create mode 100644 crates/agent/src/tool/glob.rs create mode 100644 crates/agent/src/tool/grep.rs create mode 100644 crates/agent/src/tool/mod.rs create mode 100644 crates/agent/src/tool/pr_tool.rs create mode 100644 crates/agent/src/tool/read.rs create mode 100644 crates/agent/src/tool/save_plan.rs create mode 100644 crates/agent/src/tool/schema.rs create mode 100644 crates/agent/src/tool/start_session.rs create mode 100644 crates/agent/src/tool/todo_write.rs create mode 100644 crates/agent/src/tool/write.rs create mode 100644 crates/agent/src/types.rs create mode 100644 crates/agent/src/util.rs create mode 100644 crates/agent/tests/integration.rs create mode 100644 crates/git-ops/Cargo.toml create mode 100644 crates/git-ops/src/checkpoint.rs create mode 100644 crates/git-ops/src/core.rs create mode 100644 crates/git-ops/src/error.rs create mode 100644 crates/git-ops/src/exec.rs create mode 100644 crates/git-ops/src/ide.rs create mode 100644 crates/git-ops/src/lib.rs create mode 100644 crates/git-ops/src/no_window.rs create mode 100644 crates/git-ops/src/pr.rs create mode 100644 crates/git-ops/src/test_util.rs create mode 100644 crates/git-ops/src/types.rs create mode 100644 crates/git-ops/src/worktree.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..014a4c1b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2263 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "env_logger", + "futures", + "git-ops", + "globset", + "ignore", + "include_dir", + "log", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_yml", + "strsim", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "git-ops" +version = "0.1.0" +dependencies = [ + "log", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..e7ddb0c5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +resolver = "2" +members = ["crates/agent", "crates/git-ops"] + +[profile.dev] +debug = 0 +incremental = true +split-debuginfo = "off" + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +opt-level = "s" +strip = true diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 00000000..035533b8 --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "agent" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +log = "0.4" +async-trait = "0.1" +thiserror = "2" +futures = "0.3" +bytes = "1" +tokio-util = "0.7" +ignore = "0.4" +globset = "0.4" +chrono = "0.4" +regex = "1" +strsim = "0.11" +percent-encoding = "2" +serde_yml = "0.0.12" +include_dir = "0.7" +git-ops = { path = "../git-ops" } + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } +tempfile = "3" +env_logger = "0.11" diff --git a/crates/agent/default-skills/.gitkeep b/crates/agent/default-skills/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/agent/default-subagents/.gitkeep b/crates/agent/default-subagents/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/agent/default-subagents/code-architect/code-architect.md b/crates/agent/default-subagents/code-architect/code-architect.md new file mode 100644 index 00000000..72e62150 --- /dev/null +++ b/crates/agent/default-subagents/code-architect/code-architect.md @@ -0,0 +1,37 @@ +--- +name: code-architect +description: Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences +allowed-tools: + - read + - glob + - grep + - todo_write +model: claude-sonnet-4-6 +--- + +You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions. + +## Core Process + +**1. Codebase Pattern Analysis** +Extract existing patterns, conventions, and architectural decisions. Identify the technology stack, module boundaries, abstraction layers, and CLAUDE.md guidelines. Find similar features to understand established approaches. + +**2. Architecture Design** +Based on patterns found, design the complete feature architecture. Make decisive choices - pick one approach and commit. Ensure seamless integration with existing code. Design for testability, performance, and maintainability. + +**3. Complete Implementation Blueprint** +Specify every file to create or modify, component responsibilities, integration points, and data flow. Break implementation into clear phases with specific tasks. + +## Output Guidance + +Deliver a decisive, complete architecture blueprint that provides everything needed for implementation. Include: + +- **Patterns & Conventions Found**: Existing patterns with file:line references, similar features, key abstractions +- **Architecture Decision**: Your chosen approach with rationale and trade-offs +- **Component Design**: Each component with file path, responsibilities, dependencies, and interfaces +- **Implementation Map**: Specific files to create/modify with detailed change descriptions +- **Data Flow**: Complete flow from entry points through transformations to outputs +- **Build Sequence**: Phased implementation steps as a checklist +- **Critical Details**: Error handling, state management, testing, performance, and security considerations + +Make confident architectural choices rather than presenting multiple options. Be specific and actionable - provide file paths, function names, and concrete steps. diff --git a/crates/agent/default-subagents/code-explorer/code-explorer.md b/crates/agent/default-subagents/code-explorer/code-explorer.md new file mode 100644 index 00000000..ed3dc102 --- /dev/null +++ b/crates/agent/default-subagents/code-explorer/code-explorer.md @@ -0,0 +1,53 @@ +--- +name: code-explorer +description: Deeply analyzes existing codebase features by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies to inform new development +allowed-tools: + - read + - glob + - grep +model: claude-sonnet-4-6 +--- + +You are an expert code analyst specializing in tracing and understanding feature implementations across codebases. + +## Core Mission +Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers. + +## Analysis Approach + +**1. Feature Discovery** +- Find entry points (APIs, UI components, CLI commands) +- Locate core implementation files +- Map feature boundaries and configuration + +**2. Code Flow Tracing** +- Follow call chains from entry to output +- Trace data transformations at each step +- Identify all dependencies and integrations +- Document state changes and side effects + +**3. Architecture Analysis** +- Map abstraction layers (presentation → business logic → data) +- Identify design patterns and architectural decisions +- Document interfaces between components +- Note cross-cutting concerns (auth, logging, caching) + +**4. Implementation Details** +- Key algorithms and data structures +- Error handling and edge cases +- Performance considerations +- Technical debt or improvement areas + +## Output Guidance + +Provide a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Include: + +- Entry points with file:line references +- Step-by-step execution flow with data transformations +- Key components and their responsibilities +- Architecture insights: patterns, layers, design decisions +- Dependencies (external and internal) +- Observations about strengths, issues, or opportunities +- List of files that you think are absolutely essential to get an understanding of the topic in question + +Structure your response for maximum clarity and usefulness. Always include specific file paths and line numbers. diff --git a/crates/agent/default-subagents/code-reviewer/code-reviewer.md b/crates/agent/default-subagents/code-reviewer/code-reviewer.md new file mode 100644 index 00000000..12779d2d --- /dev/null +++ b/crates/agent/default-subagents/code-reviewer/code-reviewer.md @@ -0,0 +1,49 @@ +--- +name: code-reviewer +description: Reviews code for bugs, logic errors, security vulnerabilities, code quality issues, and adherence to project conventions, using confidence-based filtering to report only high-priority issues that truly matter +allowed-tools: + - read + - glob + - grep + - bash +model: claude-sonnet-4-6 +--- + +You are an expert code reviewer specializing in modern software development across multiple languages and frameworks. Your primary responsibility is to review code against project guidelines in CLAUDE.md with high precision to minimize false positives. + +## Review Scope + +By default, review unstaged changes from `git diff`. The user may specify different files or scope to review. + +## Core Review Responsibilities + +**Project Guidelines Compliance**: Verify adherence to explicit project rules (typically in CLAUDE.md or equivalent) including import patterns, framework conventions, language-specific style, function declarations, error handling, logging, testing practices, platform compatibility, and naming conventions. + +**Bug Detection**: Identify actual bugs that will impact functionality - logic errors, null/undefined handling, race conditions, memory leaks, security vulnerabilities, and performance problems. + +**Code Quality**: Evaluate significant issues like code duplication, missing critical error handling, accessibility problems, and inadequate test coverage. + +## Confidence Scoring + +Rate each potential issue on a scale from 0-100: + +- **0**: Not confident at all. This is a false positive that doesn't stand up to scrutiny, or is a pre-existing issue. +- **25**: Somewhat confident. This might be a real issue, but may also be a false positive. If stylistic, it wasn't explicitly called out in project guidelines. +- **50**: Moderately confident. This is a real issue, but might be a nitpick or not happen often in practice. Not very important relative to the rest of the changes. +- **75**: Highly confident. Double-checked and verified this is very likely a real issue that will be hit in practice. The existing approach is insufficient. Important and will directly impact functionality, or is directly mentioned in project guidelines. +- **100**: Absolutely certain. Confirmed this is definitely a real issue that will happen frequently in practice. The evidence directly confirms this. + +**Only report issues with confidence ≥ 80.** Focus on issues that truly matter - quality over quantity. + +## Output Guidance + +Start by clearly stating what you're reviewing. For each high-confidence issue, provide: + +- Clear description with confidence score +- File path and line number +- Specific project guideline reference or bug explanation +- Concrete fix suggestion + +Group issues by severity (Critical vs Important). If no high-confidence issues exist, confirm the code meets standards with a brief summary. + +Structure your response for maximum actionability - developers should know exactly what to fix and why. diff --git a/crates/agent/default-subagents/code-simplifier/code-simplifier.md b/crates/agent/default-subagents/code-simplifier/code-simplifier.md new file mode 100644 index 00000000..e1e8805b --- /dev/null +++ b/crates/agent/default-subagents/code-simplifier/code-simplifier.md @@ -0,0 +1,52 @@ +--- +name: code-simplifier +description: Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise. +model: claude-sonnet-4-6 +--- + +You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions. This is a balance that you have mastered as a result your years as an expert software engineer. + +You will analyze recently modified code and apply refinements that: + +1. **Preserve Functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. + +2. **Apply Project Standards**: Follow the established coding standards from CLAUDE.md including: + + - Use ES modules with proper import sorting and extensions + - Prefer `function` keyword over arrow functions + - Use explicit return type annotations for top-level functions + - Follow proper React component patterns with explicit Props types + - Use proper error handling patterns (avoid try/catch when possible) + - Maintain consistent naming conventions + +3. **Enhance Clarity**: Simplify code structure by: + + - Reducing unnecessary complexity and nesting + - Eliminating redundant code and abstractions + - Improving readability through clear variable and function names + - Consolidating related logic + - Removing unnecessary comments that describe obvious code + - IMPORTANT: Avoid nested ternary operators - prefer switch statements or if/else chains for multiple conditions + - Choose clarity over brevity - explicit code is often better than overly compact code + +4. **Maintain Balance**: Avoid over-simplification that could: + + - Reduce code clarity or maintainability + - Create overly clever solutions that are hard to understand + - Combine too many concerns into single functions or components + - Remove helpful abstractions that improve code organization + - Prioritize "fewer lines" over readability (e.g., nested ternaries, dense one-liners) + - Make the code harder to debug or extend + +5. **Focus Scope**: Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope. + +Your refinement process: + +1. Identify the recently modified code sections +2. Analyze for opportunities to improve elegance and consistency +3. Apply project-specific best practices and coding standards +4. Ensure all functionality remains unchanged +5. Verify the refined code is simpler and more maintainable +6. Document only significant changes that affect understanding + +You operate autonomously and proactively, refining code immediately after it's written or modified without requiring explicit requests. Your goal is to ensure all code meets the highest standards of elegance and maintainability while preserving its complete functionality. diff --git a/crates/agent/src/agent/ask_prompt.txt b/crates/agent/src/agent/ask_prompt.txt new file mode 100644 index 00000000..a8c17b49 --- /dev/null +++ b/crates/agent/src/agent/ask_prompt.txt @@ -0,0 +1,85 @@ +You are a coding assistant. You help users understand codebases, answer questions about code, and plan implementation strategies. You can also analyze images shared by the user — identify UI elements, read text from screenshots, and use visual context to inform your coding assistance. + +You have READ-ONLY access to the codebase. You can read files, search for patterns, and explore the project structure, but you cannot make changes directly. + +When the user wants you to write code, fix bugs, or make any changes to files, use the `start_session` tool to begin a coding session. The coding session gives you a separate git worktree with full write access. + +# Available Tools + +- `read` — Read file contents (line-numbered) or list directory entries. Supports `offset` and `limit` for large files. +- `glob` — Find files by glob pattern (e.g., `**/*.rs`, `src/**/*.ts`). Returns up to 100 results sorted by modification time (newest first). Respects .gitignore. +- `grep` — Search file contents with regex. Returns up to 100 matches with file paths and line numbers. Supports `include` glob filter for file types. +- `start_session` — Start a coding session when the user wants changes made. Requires a project path and task summary. +- `ask_user` — Ask the user a clarifying question when you need more information before proceeding. Optionally provide a list of choices. +- `spawn_subagent` — Dispatch a specialist subagent (see "Default Subagents" below). + +# Default Subagents + +Four specialists are always available via `spawn_subagent`. Prefer spawning one when the subtask has a clear boundary and could eat 10+ of your own tool calls: + +- `code-explorer` — "how does X work" / mapping an unfamiliar area +- `code-reviewer` — before declaring a change done (reads files + `git diff`) +- `code-architect` — "design/plan" this feature +- `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) + +Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). + +## User subagent mentions +If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. + +# Tool Usage + +## read +- Always read a file before answering questions about it. Never guess at file contents. +- Use `offset` and `limit` for large files — read the section you need, not the entire file. +- When reading a directory, the output lists entries with "/" suffix for subdirectories. + +## glob +- Use to discover project structure and find files by name pattern. +- Good patterns: `**/*.rs` (all Rust files), `src/**/test*` (test files in src), `**/Cargo.toml` (all Cargo files). +- Results are capped at 100. If you need more precision, combine with `grep`. + +## grep +- Use for finding specific code: function definitions, imports, references, error messages. +- The `pattern` is a regex — escape special characters: `\.`, `\(`, `\{`. +- Use `include` to narrow by file type: `include: "*.rs"` or `include: "*.{ts,tsx}"`. +- Results are capped at 100 matches. If too many, narrow with a more specific pattern or `include` filter. + +## start_session +- Call when the user wants to write code, fix bugs, refactor, or make any file changes. +- Provide a clear, specific `task_summary` — it becomes the context for the coding session. +- The coding session runs in an isolated git worktree with full tool access. + +## ask_user +- Use when the request is ambiguous, has multiple valid approaches, or you need the user to make a decision. +- Ask all your questions at once rather than one at a time. +- Provide `options` when there are clear choices to pick from. + +# How to Work + +1. **Explore first** — Use `glob` to understand project structure, `grep` to find relevant code. Don't guess based on directory names alone. +2. **Read before answering** — Always read the actual source code before answering questions about it. +3. **Parallelize** — Make independent tool calls in parallel. For example, read multiple files at once, or run grep + glob simultaneously. +4. **Be thorough** — When investigating a question, trace through the code. Follow imports, check callers, read tests. +5. **Start sessions for changes** — If the user wants code changes, call `start_session` with a clear task summary. Don't try to describe code changes in text. + +# Handling Large Output +When a tool result says "Full output saved to /path. Use the read tool to view specific sections": +- Do NOT re-run the command. The full output is already saved. +- Use `read` with `offset` and `limit` to examine specific sections of the saved file. +- Start with the end of the file for errors, or use `grep` to find specific patterns. + +# Error Recovery +- If `grep` returns no results, try a broader pattern or different search terms. Check spelling. +- If `read` fails with "not found", verify the path with `glob`. +- If `glob` returns no results, try a broader pattern (e.g., `**/*auth*` instead of `src/auth.rs`). + +# Professional Objectivity +Prioritize technical accuracy over validating the user's beliefs. If you find something that contradicts what the user said, say so directly and show the evidence. Objective guidance is more valuable than false agreement. + +# Output +- Be concise. Lead with the answer, not reasoning. A concise response is generally less than 4 lines of text, not including tool calls. +- Do not add unnecessary preamble ("Let me look at that...") or postamble ("Let me know if you need anything else."). +- Use markdown. Reference code with `file_path:line_number`. +- Do not use emojis unless requested. +- Do not explain what you're about to do — just do it. diff --git a/crates/agent/src/agent/coding_prompt.txt b/crates/agent/src/agent/coding_prompt.txt new file mode 100644 index 00000000..cb4f5655 --- /dev/null +++ b/crates/agent/src/agent/coding_prompt.txt @@ -0,0 +1,138 @@ +You are a coding agent working in an isolated git worktree. You have full access to read, write, edit, and execute code. Your changes are isolated from the user's working copy until they choose to merge. + +You MUST keep going until the task is completely resolved before ending your turn. Do not stop after a partial implementation — complete the full task including testing. + +CRITICAL — Worktree isolation: +- You are running inside an isolated git worktree at `{{working_dir}}`. The main project lives at a separate path (the plan or task instructions may reference it). +- Your worktree was branched from a specific commit, so it may NOT contain newer files, untracked files, or uncommitted changes from the main project. +- READS from outside your worktree are ALLOWED. If the plan references an absolute path in the main project, or you need to gather context from a file that doesn't exist in your worktree yet, you may read it directly from the main project path. +- WRITES, EDITS, and bash commands MUST happen INSIDE `{{working_dir}}`. Writing outside the worktree triggers a security prompt and breaks isolation. +- When you need to modify or create a file based on something you read from the main project: do the read against the absolute main-project path, then write the new/modified version inside your worktree at the corresponding logical location. Do NOT echo the absolute main-project path into your write/edit calls. + +# Available Tools + +- `read` — Read file contents (line-numbered) or list directory entries. Use `offset`/`limit` for large files. +- `write` — Create new files or overwrite existing ones. Creates parent directories automatically. +- `edit` — Replace text in existing files. Supports fuzzy whitespace and indentation matching. Prefer over `write` for existing files. +- `bash` — Execute shell commands with timeout (default 120s). Always provide a `description`. +- `glob` — Find files by glob pattern. Returns up to 100 results sorted by mtime. Respects .gitignore. +- `grep` — Search file contents with regex. Returns up to 100 matches. Supports `include` filter. +- `git` — Run git commands (status, diff, add, commit, log, branch, push, etc.). Flags like --force, --amend, --hard, --no-verify are blocked. +- `create_pr` — Create a GitHub pull request from the current branch. +- `todo_write` — Track your progress on multi-step tasks. Use frequently for complex work. +- `apply_patch` — Apply unified diff patches for coordinated multi-file changes. +- `spawn_subagent` — Dispatch a specialist subagent (see "Default Subagents" below). + +# Default Subagents + +Four specialists are always available via `spawn_subagent`. Prefer spawning one when the subtask has a clear boundary and could eat 10+ of your own tool calls: + +- `code-explorer` — "how does X work" / mapping an unfamiliar area +- `code-reviewer` — before declaring a change done (reads files + `git diff`) +- `code-architect` — "design/plan" this feature +- `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) + +Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). + +## User subagent mentions +If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. + +# Tool Usage Policy + +Use specialized tools instead of bash equivalents: +- `read` instead of `cat`, `head`, `tail` +- `edit` instead of `sed`, `awk` +- `write` instead of `echo >` or `cat <= threshold_pct * context_limit (token-based), OR +/// - message_count >= max_messages (array length guard) +/// If total_tokens is 0 (first call, or after compaction reset), only the message count check applies. +pub fn needs_compaction_by_tokens(total_tokens: usize, message_count: usize, config: &CompactionConfig) -> bool { + // Message array length guard — prevents OpenAI 400 "array too long" errors + if message_count >= config.max_messages { + return true; + } + // Token-based check + if total_tokens == 0 { + return false; + } + let threshold = (config.context_limit as f64 * config.threshold_pct) as usize; + total_tokens >= threshold +} + +/// Estimate total token count from message content using chars/4 heuristic. +/// Used as a pre-LLM safety check when total_tokens_used is stale. +pub fn estimate_token_count(messages: &[ChatMessage]) -> usize { + messages.iter().map(|m| { + let content_len = m.content.as_ref().map_or(0, |c| c.text().len()); + let tool_calls_len = m.tool_calls.as_ref().map_or(0, |tcs| { + tcs.iter().map(|tc| tc.function.name.len() + tc.function.arguments.len() + 20).sum() + }); + let thinking_len = m.thinking.as_ref().map_or(0, |t| t.len()); + (content_len + tool_calls_len + thinking_len) / 4 + 4 // +4 per message overhead (role, delimiters) + }).sum() +} + +/// Calculate the boundaries for compaction. +/// +/// Strategy: keep system prompt + last `keep_recent` messages, summarize everything in between. +/// The `end` boundary is snapped to a turn boundary so tool_call/tool_result pairs are never split. +/// +/// Returns `Some((start, end))` — the range of messages to compact. +/// Returns `None` if there's nothing meaningful to compact. +pub fn compaction_boundaries( + messages: &[ChatMessage], + keep_recent: usize, +) -> Option<(usize, usize)> { + if messages.is_empty() { + return None; + } + + // Start after system prompt + let start = if messages[0].role == "system" { 1 } else { 0 }; + + // Raw end = total - keep_recent + let total = messages.len(); + let raw_end = total.saturating_sub(keep_recent); + + if raw_end <= start { + return None; + } + + // Snap end to a turn boundary — don't split tool_call/tool_result pairs. + // Walk backward from raw_end until we land on a message that isn't a "tool" result + // and isn't an assistant with tool_calls whose results would be cut off. + let mut end = snap_to_turn_boundary(messages, raw_end); + + // Bug 3 fix (Layer 3): forward-walk safety check. After the backward snap, + // verify no retained tool_result in messages[end..] references a tool_call_id + // that exists ONLY in the about-to-be-compacted range [start..end). If found, + // advance `end` to drop those orphans (compacting them away is safer than + // sending an orphaned tool_result to the LLM). + end = advance_past_orphan_tool_results(messages, start, end); + + if end <= start { + return None; + } + + // Need at least 2 messages to be worth compacting + if end - start < 2 { + return None; + } + + Some((start, end)) +} + +/// Advance `end` forward past any tool_result messages in messages[end..] whose +/// tool_call_id has no matching tool_call in the kept regions (messages[..start] +/// or messages[end..]). This is a defense-in-depth check on top of +/// `snap_to_turn_boundary` for unusual message sequences. +fn advance_past_orphan_tool_results( + messages: &[ChatMessage], + start: usize, + end: usize, +) -> usize { + use std::collections::HashSet; + + // Build the set of tool_call_ids that will survive compaction. + let mut valid_ids: HashSet<&str> = HashSet::new(); + for msg in messages[..start].iter().chain(messages[end..].iter()) { + if let Some(ref tcs) = msg.tool_calls { + for tc in tcs { + valid_ids.insert(tc.id.as_str()); + } + } + } + + let mut new_end = end; + while new_end < messages.len() { + let msg = &messages[new_end]; + if msg.role != "tool" { + break; + } + let id = match msg.tool_call_id.as_deref() { + Some(id) => id, + None => break, + }; + if valid_ids.contains(id) { + break; + } + // Orphan — push it into the compacted range. + new_end += 1; + } + new_end +} + +/// Find the nearest safe compaction boundary at or before `target`. +/// A safe boundary is a position where the message at `target` is NOT: +/// - A tool result (role="tool") — would orphan it from its assistant+tool_calls +/// - Immediately after an assistant with tool_calls — would split the pair +fn snap_to_turn_boundary(messages: &[ChatMessage], target: usize) -> usize { + let mut end = target; + while end > 0 { + // If the message just before `end` is a tool result, we're mid-turn + if messages[end - 1].role == "tool" { + end -= 1; + continue; + } + // If it's an assistant with tool_calls, the tool results follow after it + // and would be split — step back past this assistant too + if messages[end - 1].role == "assistant" && messages[end - 1].tool_calls.is_some() { + end -= 1; + continue; + } + break; + } + end +} + +/// Build the compaction prompt — formats the messages to be summarized. +/// The summarization instruction is provided separately as a system message in the LLM call. +pub fn build_compaction_prompt(messages: &[ChatMessage]) -> String { + let mut prompt = String::new(); + + for msg in messages { + let role = &msg.role; + let content = msg.content.as_ref().map(|c| c.text()).unwrap_or("[no content]"); + prompt.push_str(&format!("{role}: {content}\n\n")); + } + + prompt +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::types::{FunctionCall, ToolCall}; + + fn default_config() -> CompactionConfig { + CompactionConfig::default() + } + + #[test] + fn test_needs_compaction_by_tokens_zero() { + let config = default_config(); + assert!(!needs_compaction_by_tokens(0, 5, &config)); + } + + #[test] + fn test_needs_compaction_by_tokens_under() { + let config = default_config(); + assert!(!needs_compaction_by_tokens(50_000, 5, &config)); + } + + #[test] + fn test_needs_compaction_by_tokens_over() { + let config = default_config(); + assert!(needs_compaction_by_tokens(110_000, 5, &config)); + } + + #[test] + fn test_needs_compaction_by_tokens_exact_threshold() { + let mut config = default_config(); + config.context_limit = 100; + assert!(needs_compaction_by_tokens(80, 5, &config)); + } + + #[test] + fn test_needs_compaction_by_message_count() { + let config = default_config(); + // Under max_messages → no compaction (even with 0 tokens) + assert!(!needs_compaction_by_tokens(0, 100, &config)); + // At max_messages → triggers compaction regardless of tokens + assert!(needs_compaction_by_tokens(0, config.max_messages, &config)); + assert!(needs_compaction_by_tokens(0, config.max_messages + 1, &config)); + } + + #[test] + fn test_compaction_boundaries_preserves_system_prompt() { + let messages = vec![ + ChatMessage::system("You are helpful"), + ChatMessage::user("msg 1"), + ChatMessage::assistant(Some("reply 1".into()), None, None), + ChatMessage::user("msg 2"), + ChatMessage::assistant(Some("reply 2".into()), None, None), + ChatMessage::user("msg 3"), + ChatMessage::assistant(Some("reply 3".into()), None, None), + ]; + let result = compaction_boundaries(&messages, 2); + // Should compact [1, 5) — everything except system prompt and last 2 + assert_eq!(result, Some((1, 5))); + } + + #[test] + fn test_compaction_boundaries_keeps_recent_turns() { + let messages = vec![ + ChatMessage::user("old 1"), + ChatMessage::assistant(Some("old reply".into()), None, None), + ChatMessage::user("recent 1"), + ChatMessage::assistant(Some("recent reply".into()), None, None), + ]; + let result = compaction_boundaries(&messages, 2); + // Compact [0, 2) — first 2 messages, keep last 2 + assert_eq!(result, Some((0, 2))); + } + + #[test] + fn test_compaction_boundaries_nothing_to_compact() { + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("hello"), + ]; + let result = compaction_boundaries(&messages, 10); + assert_eq!(result, None); + } + + #[test] + fn test_compaction_boundaries_too_few_messages() { + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("only one"), + ]; + let result = compaction_boundaries(&messages, 0); + assert_eq!(result, None); + } + + #[test] + fn test_snap_avoids_splitting_tool_pair() { + let tc = vec![ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "read".into(), arguments: "{}".into() }, + }]; + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("first question"), // idx 1 + ChatMessage::assistant(Some("answer 1".into()), None, None),// idx 2 + ChatMessage::user("do something"), // idx 3 + ChatMessage::assistant(None, Some(tc), None), // idx 4: assistant with tools + ChatMessage::tool_result("tc1", "file contents"), // idx 5: tool result + ChatMessage::user("thanks"), // idx 6 + ChatMessage::assistant(Some("done".into()), None, None), // idx 7 + ]; + + // keep_recent=2 → raw_end = 8-2 = 6 + // messages[5] is tool → snap to 5, messages[4] is assistant+tools → snap to 4 + // messages[3] is user → safe. end=4. + // Compacts [1, 4) = user("first question"), assistant("answer 1"), user("do something") + let result = compaction_boundaries(&messages, 2); + assert_eq!(result, Some((1, 4))); + } + + #[test] + fn test_snap_leaves_clean_boundary() { + // user + assistant(text only) + user + assistant(text only) + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("msg 1"), + ChatMessage::assistant(Some("reply 1".into()), None, None), + ChatMessage::user("msg 2"), + ChatMessage::assistant(Some("reply 2".into()), None, None), + ]; + // keep_recent=2 → raw_end = 5-2 = 3 + // messages[2] is assistant(no tool_calls) → safe + let result = compaction_boundaries(&messages, 2); + assert_eq!(result, Some((1, 3))); + } + + #[test] + fn test_snap_with_multiple_tool_results() { + let tc = vec![ + ToolCall { id: "tc1".into(), type_: "function".into(), function: FunctionCall { name: "read".into(), arguments: "{}".into() } }, + ToolCall { id: "tc2".into(), type_: "function".into(), function: FunctionCall { name: "grep".into(), arguments: "{}".into() } }, + ]; + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("first"), // idx 1 + ChatMessage::assistant(Some("ok".into()), None, None), // idx 2 + ChatMessage::user("search"), // idx 3 + ChatMessage::assistant(None, Some(tc), None), // idx 4 + ChatMessage::tool_result("tc1", "result 1"), // idx 5 + ChatMessage::tool_result("tc2", "result 2"), // idx 6 + ChatMessage::assistant(Some("found it".into()), None, None), // idx 7 + ChatMessage::user("thanks"), // idx 8 + ]; + // keep_recent=2 → raw_end=7 + // messages[6] is tool → 6, messages[5] is tool → 5, + // messages[4] is assistant+tools → 4, messages[3] is user → safe. end=4 + let result = compaction_boundaries(&messages, 2); + assert_eq!(result, Some((1, 4))); + } + + #[test] + fn test_snap_returns_none_when_all_are_tool_pairs() { + let tc = vec![ToolCall { + id: "tc1".into(), type_: "function".into(), + function: FunctionCall { name: "read".into(), arguments: "{}".into() }, + }]; + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::assistant(None, Some(tc), None), + ChatMessage::tool_result("tc1", "result"), + ]; + // keep_recent=0 → raw_end=3, but everything is a tool pair + // snap: messages[2] is tool → 2, messages[1] is assistant+tools → 1, messages[0] is system + // end=1, start=1 → end <= start → None + let result = compaction_boundaries(&messages, 0); + assert_eq!(result, None); + } + + #[test] + fn test_build_compaction_prompt_formats_messages() { + let messages = vec![ + ChatMessage::user("What does main.rs do?"), + ChatMessage::assistant(Some("It starts the server.".into()), None, None), + ChatMessage::user("Can you refactor it?"), + ]; + let prompt = build_compaction_prompt(&messages); + assert!(prompt.contains("user: What does main.rs do?")); + assert!(prompt.contains("assistant: It starts the server.")); + assert!(prompt.contains("user: Can you refactor it?")); + } + + #[test] + fn test_build_compaction_prompt_handles_none_content() { + let messages = vec![ + ChatMessage::assistant(None, Some(vec![ToolCall { + id: "call_1".to_string(), + type_: "function".to_string(), + function: FunctionCall { + name: "read".to_string(), + arguments: "{}".to_string(), + }, + }]), None), + ]; + let prompt = build_compaction_prompt(&messages); + assert!(prompt.contains("assistant: [no content]")); + } + + #[test] + fn test_build_compaction_prompt_no_instruction_text() { + let messages = vec![ChatMessage::user("hello")]; + let prompt = build_compaction_prompt(&messages); + assert!(!prompt.contains("Summarize"), "Prompt should not contain instruction text"); + } + + // ─── Bug 3 fix (Layer 3): forward-walk orphan check ─── + + #[test] + fn test_forward_walk_advances_past_unmatched_tool_result() { + // Construct a scenario where the backward snap lands on `end` but the + // first kept message is a `tool` whose tool_call_id only existed in + // the about-to-be-compacted range. The forward walk must advance past + // this orphan so it doesn't get shipped to the LLM. + let tc1 = vec![ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "read".into(), arguments: "{}".into() }, + }]; + let messages = vec![ + ChatMessage::system("prompt"), // 0 + ChatMessage::assistant(None, Some(tc1), None), // 1: would be compacted + ChatMessage::tool_result("tc1", "data"), // 2: orphan if assistant compacted + ChatMessage::user("next"), // 3 + ChatMessage::assistant(Some("ok".into()), None, None), // 4 + ]; + // Force a scenario where snap backward lands at end=2 (mid-pair). + // We exercise the helper directly: + let advanced = advance_past_orphan_tool_results(&messages, 1, 2); + assert_eq!( + advanced, 3, + "tool_result at idx 2 references tc1 which lives in [1..2) (about to be compacted) — must advance" + ); + } + + #[test] + fn test_forward_walk_keeps_valid_tool_result() { + // tool_result references a tool_call that survives in messages[end..]. + let tc1 = vec![ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "read".into(), arguments: "{}".into() }, + }]; + let messages = vec![ + ChatMessage::system("prompt"), // 0 + ChatMessage::user("old"), // 1: compacted + ChatMessage::assistant(None, Some(tc1), None), // 2: KEPT (assistant) + ChatMessage::tool_result("tc1", "data"), // 3: KEPT (paired) + ]; + // start=1, end=2 → kept range starts with assistant(tc1), tool_result(tc1). + let advanced = advance_past_orphan_tool_results(&messages, 1, 2); + assert_eq!(advanced, 2, "tc1 is in the kept range — should NOT advance"); + } + + #[test] + fn test_forward_walk_noop_when_first_kept_is_not_tool() { + let messages = vec![ + ChatMessage::system("prompt"), + ChatMessage::user("old"), + ChatMessage::user("new"), + ]; + let advanced = advance_past_orphan_tool_results(&messages, 1, 2); + assert_eq!(advanced, 2, "non-tool first kept message — no advancement"); + } +} diff --git a/crates/agent/src/agent/config.rs b/crates/agent/src/agent/config.rs new file mode 100644 index 00000000..a5370fb5 --- /dev/null +++ b/crates/agent/src/agent/config.rs @@ -0,0 +1,126 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use crate::context_engine::ContextEngineApi; +use crate::llm::LlmClientConfig; +use crate::skills::SkillRegistry; +use crate::subagents::{SubagentInheritance, SubagentRegistry}; +use crate::tool::ToolMode; +use super::model_profile::ModelProfile; +use super::prompt::SystemBlock; + +/// Configuration for the agent loop. +pub struct AgentConfig { + /// LLM client configuration + pub llm: LlmClientConfig, + /// Working directory for tool execution + pub working_dir: PathBuf, + /// Agent mode — determines tool set and system prompt (default Coding) + pub mode: ToolMode, + /// Maximum loop iterations before stopping (default 100) + pub max_iterations: u32, + /// System prompt prepended to conversation. A vector of ordered blocks so + /// individual segments can carry `cache_control` markers for prompt caching. + pub system_prompt: Option>, + /// Retry configuration for LLM API calls + pub retry_config: RetryConfig, + /// Compaction configuration for context window management + pub compaction_config: CompactionConfig, + /// Optional dedicated LLM client config for compaction summarization. + /// When set, summarization uses this (typically a cheaper model) instead of `llm`. + /// When None, falls back to the main `llm` client. + pub compaction_llm: Option, + /// Optional context engine. When set, codebase_search and codebase_graph are registered. + pub context_engine: Option>, + /// Canonical repo path for context engine (main checkout, not worktree). + /// Required when context_engine is Some. + pub context_engine_repo_path: Option, + /// Optional skill registry. When set, the skills list is injected into the + /// system prompt and the `skill` tool is registered in all modes. + pub skills: Option>, + /// Optional subagent registry. When set, the subagent list is injected + /// into the system prompt and `spawn_subagent` is registered in all modes. + pub subagents: Option>, + /// Bundle supplied by the Tauri layer so `SpawnSubagentTool` can build + /// child loops (LLM client config, persister factory, approval handler + /// factory, write-lock registry, etc.). Required at parent-turn + /// registration time when `subagents` is Some. + pub subagent_inheritance: Option>, +} + +impl AgentConfig { + pub fn new(llm: LlmClientConfig, working_dir: PathBuf) -> Self { + Self { + llm, + working_dir, + mode: ToolMode::Coding, + max_iterations: 100, + system_prompt: None, + retry_config: RetryConfig::default(), + compaction_config: CompactionConfig::default(), + compaction_llm: None, + context_engine: None, + context_engine_repo_path: None, + skills: None, + subagents: None, + subagent_inheritance: None, + } + } + + /// Set compaction context_limit from a model profile. + /// Call this after construction to get the correct context window for the model. + pub fn with_model_profile(mut self, profile: &ModelProfile) -> Self { + self.compaction_config.context_limit = profile.context_window; + self + } +} + +/// Configuration for exponential backoff retries on LLM API errors. +#[derive(Debug, Clone)] +pub struct RetryConfig { + /// Maximum number of retries (default 3) + pub max_retries: u32, + /// Initial backoff delay (default 1s) + pub initial_delay: Duration, + /// Backoff multiplier (default 2.0) + pub multiplier: f64, + /// Maximum backoff delay cap (default 30s) + pub max_delay: Duration, +} + +/// Configuration for context window compaction. +#[derive(Debug, Clone)] +pub struct CompactionConfig { + /// Maximum context size in estimated tokens (default 128_000) + pub context_limit: usize, + /// Threshold percentage at which to trigger compaction (default 0.80) + pub threshold_pct: f64, + /// Number of recent messages to keep uncompacted (default 10) + pub keep_recent_messages: usize, + /// Maximum number of messages in the array before forcing compaction (default 10_000). + /// OpenAI's limit is 16,384 — we compact well before that. + pub max_messages: usize, +} + +impl Default for CompactionConfig { + fn default() -> Self { + Self { + context_limit: 128_000, + threshold_pct: 0.80, + keep_recent_messages: 10, + max_messages: 10_000, + } + } +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: 3, + initial_delay: Duration::from_secs(1), + multiplier: 2.0, + max_delay: Duration::from_secs(30), + } + } +} diff --git a/crates/agent/src/agent/loop_.rs b/crates/agent/src/agent/loop_.rs new file mode 100644 index 00000000..49f0594c --- /dev/null +++ b/crates/agent/src/agent/loop_.rs @@ -0,0 +1,4092 @@ +use std::sync::Arc; + +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::approval::{ApprovalDecision, ApprovalHandler}; +use crate::error::AgentError; +use crate::llm::client::{LlmClient, LlmProvider}; +use crate::llm::types::{ChatMessage, ContentBlock, LlmResponse, MessageContent, ToolDefinition}; +use crate::persistence::{AgentMessage, MessagePersister, MessageRole, MessageType, Sender}; +use crate::tool::schema::validate_args; +use crate::tool::{ToolContext, ToolRegistry, ToolResult}; +use crate::types::{AgentEvent, AgentResult}; +use crate::util::{truncate_and_persist, truncate_str}; +use super::compaction; +use super::config::AgentConfig; + +const MAX_TOOL_OUTPUT_LINES: usize = 2000; +const MAX_TOOL_OUTPUT_BYTES: usize = 50 * 1024; // 50KB +const DOOM_LOOP_THRESHOLD: u32 = 3; + +/// Message sent through the ordered persistence channel. +struct PersistItem { + message: AgentMessage, +} + +/// The core agent loop that drives LLM ↔ tool execution cycles. +pub struct AgentLoop { + config: AgentConfig, + client: Box, + /// Dedicated client for compaction summarization. Built from `config.compaction_llm` + /// when present (typically a cheaper model like Sonnet), else None and we fall back + /// to `client` for summarization. + compaction_client: Option>, + registry: Arc, + messages: Vec, + cancel_token: CancellationToken, + event_tx: mpsc::Sender, + session_id: String, + /// Ordered persistence channel — messages are written sequentially by a background worker. + persist_tx: Option>, + /// Handle to the persistence worker — awaited at end of run() to ensure all writes complete. + persist_worker: Option>, + /// Optional approval handler — when Some, tools are checked for approval before execution. + approval_handler: Option>, + /// Actual token count from the most recent LLM API response. + /// Updated after each `chat_completion` call from `response.usage.total_tokens`. + /// Used for compaction decisions instead of the chars/4 heuristic when available. + total_tokens_used: usize, + /// Offset added to iteration counter for globally unique turn numbering + /// across multiple AgentLoop invocations in the same thread. + iteration_offset: u32, +} + +impl AgentLoop { + pub fn new( + config: AgentConfig, + registry: ToolRegistry, + cancel_token: CancellationToken, + event_tx: mpsc::Sender, + session_id: String, + ) -> Self { + let client: Box = Box::new(LlmClient::new(config.llm.clone())); + Self::with_provider(config, client, registry, cancel_token, event_tx, session_id) + } + + /// Create an agent loop with a custom LLM provider (useful for testing with mocks). + pub fn with_provider( + config: AgentConfig, + client: Box, + registry: ToolRegistry, + cancel_token: CancellationToken, + event_tx: mpsc::Sender, + session_id: String, + ) -> Self { + let mut messages = Vec::new(); + + // System prompt: if configured, emit as a block-array message so each + // segment can carry its own `cache_control` marker. Anthropic caches by + // block; OpenAI ignores the markers and treats the joined content as a + // single system string (same behavior as before). + if let Some(ref blocks) = config.system_prompt { + if blocks.len() == 1 && blocks[0].cache_control.is_none() { + // Single uncached block → emit as a plain string for OpenAI-shape + // minimalism. Functionally identical to a 1-element block array. + messages.push(ChatMessage::system(blocks[0].text.clone())); + } else { + let content_blocks: Vec = blocks + .iter() + .map(|b| ContentBlock::Text { + text: b.text.clone(), + cache_control: b.cache_control.clone(), + }) + .collect(); + messages.push(ChatMessage { + role: "system".into(), + content: Some(MessageContent::Blocks(content_blocks)), + tool_calls: None, + tool_call_id: None, + name: None, + thinking: None, + }); + } + } + + let compaction_client: Option> = config + .compaction_llm + .as_ref() + .map(|cfg| Box::new(LlmClient::new(cfg.clone())) as Box); + + Self { + config, + client, + compaction_client, + registry: Arc::new(registry), + messages, + cancel_token, + event_tx, + session_id, + persist_tx: None, + persist_worker: None, + approval_handler: None, + total_tokens_used: 0, + iteration_offset: 0, + } + } + + /// Attach an approval handler that gates tool execution on user approval. + pub fn with_approval_handler(mut self, handler: Arc) -> Self { + self.approval_handler = Some(handler); + self + } + + /// Attach a persister — spawns a background worker that writes messages in order. + /// Panics (in debug builds) if called more than once on the same AgentLoop. + pub fn with_persister(mut self, persister: Arc, thread_id: Option) -> Self { + debug_assert!(self.persist_tx.is_none(), "with_persister called twice on the same AgentLoop"); + let (tx, mut rx) = mpsc::unbounded_channel::(); + // Spawn a single ordered worker that drains messages sequentially. + // thread_id is captured once here — it never changes for the lifetime of an AgentLoop. + let handle = tokio::spawn(async move { + while let Some(item) = rx.recv().await { + if let Err(e) = persister.persist_message(&item.message, thread_id.as_deref()).await { + log::error!("Persist failed: {e}"); + } + } + }); + self.persist_tx = Some(tx); + self.persist_worker = Some(handle); + self + } + + /// Number of messages currently in the conversation context. + pub fn message_count(&self) -> usize { + self.messages.len() + } + + /// Seed the token count from a previously persisted value (session resume). + pub fn with_initial_token_count(mut self, tokens: usize) -> Self { + self.total_tokens_used = tokens; + self + } + + /// Seed the iteration offset for globally unique turn numbering across + /// multiple AgentLoop invocations in the same coding session thread. + pub fn with_turn_offset(mut self, offset: u32) -> Self { + log::info!("[AgentLoop {}] turn_offset set to {offset}", self.session_id); + self.iteration_offset = offset; + self + } + + /// Update context limit when the user switches models mid-conversation. + /// If the new limit is smaller and current token usage exceeds the new threshold, + /// compaction will trigger automatically on the next loop iteration. + pub fn update_context_limit(&mut self, new_context_window: usize) { + let old = self.config.compaction_config.context_limit; + self.config.compaction_config.context_limit = new_context_window; + log::info!( + "[AgentLoop {}] Context limit updated: {} → {}", + self.session_id, old, new_context_window + ); + } + + /// Seed the conversation with initial context messages (e.g., sliding window from ask mode). + /// Seed the conversation with messages from a DIFFERENT scope (ask→coding transfer). + /// These are COPIES of messages already posted to the chat service (in the ask session), + /// so they're persisted as "completion_summary" type — which is excluded from the retry + /// queue's `get_pending_messages` query. This prevents double-posting to chat service. + /// + /// ONLY use for cross-scope transfers (ask→coding, plan→coding). For same-scope resume + /// (ask turn N, coding thread resume), use `with_resumed_context` instead. + pub fn with_initial_context(mut self, messages: Vec) -> Self { + for msg in &messages { + let sender = if msg.role == "user" { Sender::HumanUser } else { Sender::Agent }; + self.persist_fire_and_forget(Self::chat_to_agent_message(msg, MessageType::CompletionSummary, sender, None)); + } + self.messages.extend(messages); + self + } + + /// Reload prior context into the conversation buffer WITHOUT re-persisting. + /// Use for same-scope resume (ask turn N, coding thread resume, rewind). + /// The messages already exist in SQLite — writing copies would cause exponential + /// duplication (each resume doubles the row count). + pub fn with_resumed_context(mut self, messages: Vec) -> Self { + self.messages.extend(messages); + self + } + + /// Run the agent loop with a user message. Returns an AgentResult indicating + /// whether the agent completed normally or wants to start a coding session. + pub async fn run(&mut self, user_message: ChatMessage) -> Result { + let result = self.run_inner(user_message).await; + // Flush persistence: drop the sender so the worker drains, then await it. + // This guarantees ALL messages are written to SQLite before we return. + self.persist_tx.take(); // drop sender — worker's recv() will return None after draining + if let Some(worker) = self.persist_worker.take() { + let _ = worker.await; + } + result + } + + async fn run_inner(&mut self, user_message: ChatMessage) -> Result { + // Push user message (caller provides ChatMessage directly — may contain image blocks) + let user_text_preview: String = user_message.content.as_ref() + .map_or_else(String::new, |c| c.text()[..c.text().len().min(100)].to_string()); + let user_turn = 1 + self.iteration_offset; + if self.iteration_offset > 0 { + log::info!("[AgentLoop {}] User message persisted with turn_count={user_turn} (1 + offset {})", self.session_id, self.iteration_offset); + } + self.persist_fire_and_forget(Self::chat_to_agent_message( + &user_message, + MessageType::Text, + Sender::HumanUser, + Some(user_turn), + )); + self.messages.push(user_message); + + log::info!( + "[AgentLoop {}] run() started — {} messages in context, user_message: {:?}", + self.session_id, + self.messages.len(), + user_text_preview + ); + + let tool_defs = self.registry.tool_definitions(); + let tool_names: Vec<&str> = tool_defs.iter().map(|d| d.function.name.as_str()).collect(); + log::info!( + "[v1.0] Session {} | mode={:?} | tools=[{}] | doom_loop=enabled | max_iter={}", + self.session_id, self.config.mode, tool_names.join(", "), self.config.max_iterations + ); + let mut iteration: u32 = 0; + let mut turn_modified_files: Vec = Vec::new(); + let mut consecutive_failures: u32 = 0; + let mut compaction_cooldown: u32 = 0; + // Index into self.messages at which total_tokens_used was last measured. + // Messages after this index were added since the last LLM response (tool results, etc.) + // and need chars/4 estimation for the pre-LLM compaction check. + let mut tokens_measured_at: usize = self.messages.len(); + + // Safety: on session resume, total_tokens_used may be stale (seeded from a previous + // session) or 0 (lookup failed). Re-estimate ALL messages so the compaction check + // uses an accurate count on the first iteration. + // Skip for truly fresh sessions (system prompt + user message only = 2 messages). + if self.messages.len() > 2 { + let full_estimate = compaction::estimate_token_count(&self.messages); + log::info!( + "[AgentLoop {}] Resume token check: seeded={}, full_estimate={}, context_limit={}, threshold={}", + self.session_id, self.total_tokens_used, full_estimate, + self.config.compaction_config.context_limit, + (self.config.compaction_config.context_limit as f64 * self.config.compaction_config.threshold_pct) as usize + ); + if full_estimate > self.total_tokens_used { + log::info!( + "[AgentLoop {}] Stale token count corrected: seeded={}, re-estimated={}. Using estimate.", + self.session_id, self.total_tokens_used, full_estimate + ); + self.total_tokens_used = full_estimate; + } + } else { + log::info!( + "[AgentLoop {}] Fresh session, total_tokens_used={}, messages={}", + self.session_id, self.total_tokens_used, self.messages.len() + ); + } + + loop { + // Check cancellation + if self.cancel_token.is_cancelled() { + return Err(AgentError::Cancelled); + } + + // Check iteration limit + iteration += 1; + if iteration > self.config.max_iterations { + let summary = format!( + "Reached maximum of {} steps. Progress preserved.", + self.config.max_iterations + ); + let _ = self.event_tx.send(AgentEvent::Done { + session_id: self.session_id.clone(), + summary: Some(summary.clone()), + }).await; + return Ok(AgentResult::Done { summary }); + } + + // Pre-LLM compaction check (Bug 3 fix). + // total_tokens_used reflects the last LLM response, but tool results added + // since then can push context way over the limit. We use the real token count + // + chars/4 estimate of only the new messages added since measurement. + if compaction_cooldown == 0 { + let new_msg_tokens = if tokens_measured_at < self.messages.len() { + compaction::estimate_token_count(&self.messages[tokens_measured_at..]) + } else { + 0 + }; + let estimated = self.total_tokens_used + new_msg_tokens; + let needs = compaction::needs_compaction_by_tokens(estimated, self.messages.len(), &self.config.compaction_config); + if iteration == 1 { + log::info!( + "[AgentLoop {}] Pre-LLM compaction check (iter {}): total_tokens_used={}, new_msg_tokens={}, estimated={}, messages={}, needs_compaction={}", + self.session_id, iteration, self.total_tokens_used, new_msg_tokens, estimated, self.messages.len(), needs + ); + } + if needs { + log::info!( + "[AgentLoop {}] Compaction triggered at iter {}: estimated={}, messages={}", + self.session_id, iteration, estimated, self.messages.len() + ); + compaction_cooldown = self.try_compaction().await; + tokens_measured_at = self.messages.len(); + } + } + + // Pre-flight: strip orphaned tool_result messages before API call + self.strip_orphaned_tool_results(); + + // Call LLM with retry + let response = self + .call_llm_with_retry(&tool_defs) + .await?; + + // Track actual token usage from API response. + // The gateway normalizes total_tokens to include cache tokens for all + // providers (Anthropic's adapter sums input + cache_read + cache_creation + // + output). So usage.total_tokens is the real context size — no + // client-side adjustment needed. + if let Some(ref usage) = response.usage { + let (cache_read, cache_write) = usage + .prompt_tokens_details + .as_ref() + .map(|d| (d.cached_tokens, d.cache_creation_tokens)) + .unwrap_or((None, None)); + + self.total_tokens_used = usage.total_tokens as usize; + tokens_measured_at = self.messages.len(); // mark measurement point + log::info!( + "[AgentLoop {}] tokens={}/{} ({}%)", + self.session_id, self.total_tokens_used, self.config.compaction_config.context_limit, + (self.total_tokens_used as f64 / self.config.compaction_config.context_limit as f64 * 100.0) as u32 + ); + if cache_read.unwrap_or(0) > 0 || cache_write.unwrap_or(0) > 0 { + log::info!( + "[AgentLoop {}] cache: read={} write={}", + self.session_id, + cache_read.unwrap_or(0), + cache_write.unwrap_or(0) + ); + } + let _ = self.event_tx.send(AgentEvent::TokenUsage { + session_id: self.session_id.clone(), + total_tokens: usage.total_tokens, + context_limit: self.config.compaction_config.context_limit as u32, + cache_read_tokens: cache_read, + cache_creation_tokens: cache_write, + }).await; + } + + // Push assistant message to history + let tool_calls = if response.tool_calls.is_empty() { + None + } else { + Some(response.tool_calls.clone()) + }; + let has_tool_calls = tool_calls.is_some(); + let assistant_msg = ChatMessage::assistant( + response.content.clone(), + tool_calls, + response.thinking.clone(), + ); + let msg_type = if has_tool_calls { MessageType::ToolCall } else { MessageType::Text }; + self.persist_fire_and_forget(Self::chat_to_agent_message( + &assistant_msg, + msg_type, + Sender::Agent, + Some(iteration + self.iteration_offset), + )); + self.messages.push(assistant_msg); + + // If no tool calls, we're done + if !has_tool_calls { + let summary = response.content.unwrap_or_default(); + // Guard against the LLM returning an empty response with no tool_calls. + // This happens occasionally after context compaction and produces a + // spurious "Done" state that looks to the user like the agent silently + // stopped. Surface it as an error with retry guidance instead. + if summary.trim().is_empty() { + log::warn!( + "[AgentLoop {}] Empty response with no tool_calls — treating as error", + self.session_id + ); + let _ = self.event_tx.send(AgentEvent::Error { + session_id: self.session_id.clone(), + message: "The model returned an empty response. This sometimes happens after context compaction. Please try again.".into(), + retrying: false, + }).await; + return Err(AgentError::LlmParseError( + "empty response with no tool_calls".into() + )); + } + let _ = self.event_tx.send(AgentEvent::Done { + session_id: self.session_id.clone(), + summary: Some(summary.clone()), + }).await; + return Ok(AgentResult::Done { summary }); + } + + // Execute tool calls — check cancellation once before the batch + if self.cancel_token.is_cancelled() { + return Err(AgentError::Cancelled); + } + + // Parallel execution via JoinSet + // Save (idx, tool_call_id) so we can recover context if a task panics + let mut task_index: Vec<(usize, String)> = Vec::new(); + let mut join_set = tokio::task::JoinSet::new(); + for (idx, tool_call) in response.tool_calls.iter().enumerate() { + task_index.push((idx, tool_call.id.clone())); + + let registry = Arc::clone(&self.registry); + let working_dir = self.config.working_dir.clone(); + let cancel_token = self.cancel_token.clone(); + let event_tx = self.event_tx.clone(); + let session_id = self.session_id.clone(); + let tool_call_id = tool_call.id.clone(); + let tool_name = tool_call.function.name.clone(); + let arguments_json = tool_call.function.arguments.clone(); + let approval_ref = self.approval_handler.clone(); + + join_set.spawn(async move { + let result = execute_tool_call_impl( + &tool_call_id, + &tool_name, + &arguments_json, + ®istry, + &working_dir, + cancel_token, + &event_tx, + &session_id, + approval_ref.as_deref(), + ) + .await; + (idx, tool_call_id, result) + }); + } + + // Collect results and sort by original index + let mut results = Vec::new(); + while let Some(join_result) = join_set.join_next().await { + match join_result { + Ok(tuple) => results.push(tuple), + Err(e) => { + log::error!("Tool task panicked: {e}"); + } + } + } + + // If a task panicked, its tool_call_id is missing from results. + // Inject synthetic error responses to avoid API protocol violations. + let completed_ids: std::collections::HashSet = + results.iter().map(|(_, id, _)| id.clone()).collect(); + for (idx, tc_id) in &task_index { + if !completed_ids.contains(tc_id) { + log::error!("Injecting synthetic error for panicked tool call: {tc_id}"); + results.push((*idx, tc_id.clone(), ToolResult::error( + "Tool execution crashed unexpectedly. Please try again.", + ))); + } + } + + results.sort_by_key(|(idx, _, _)| *idx); + + // Doom loop detection: track consecutive all-failed tool rounds + let all_failed = !results.is_empty() && results.iter().all(|(_, _, r)| r.is_error); + if all_failed { + consecutive_failures += 1; + } else { + consecutive_failures = 0; + } + + // Push tool results to message history FIRST (before checking yield) + let mut yield_data: Option = None; + for (_, tc_id, result) in &results { + let tool_msg = ChatMessage::tool_result(tc_id, &result.output); + self.persist_fire_and_forget(Self::chat_to_agent_message( + &tool_msg, + MessageType::ToolResult, + Sender::Agent, + Some(iteration + self.iteration_offset), + )); + self.messages.push(tool_msg); + // Capture the first yield_data if any + if yield_data.is_none() { + if let Some(ref data) = result.yield_data { + yield_data = Some(data.clone()); + } + } + // Accumulate modified files for TurnCompleted + turn_modified_files.extend(result.modified_files.iter().cloned()); + } + + // Doom loop: if N consecutive rounds had ALL tools fail, inject a hint + if consecutive_failures >= DOOM_LOOP_THRESHOLD { + log::warn!( + "[AgentLoop {}] Doom loop detected: {} consecutive all-failed tool rounds", + self.session_id, consecutive_failures + ); + let hint = ChatMessage::system( + "You have failed multiple consecutive tool calls. Stop retrying the same approach. \ + Either try a completely different strategy, or explain to the user what is blocking you.", + ); + self.messages.push(hint); + consecutive_failures = 0; + + let _ = self.event_tx.send(AgentEvent::Error { + session_id: self.session_id.clone(), + message: format!( + "Doom loop detected: {} consecutive tool failures — injecting course-correction hint", + DOOM_LOOP_THRESHOLD + ), + retrying: true, + }).await; + } + + // Check for yield_data (e.g., start_session, ask_user) — after results are persisted. + // Note: TurnCompleted is intentionally NOT emitted on yield. Ask/Plan modes + // (the only modes with yielding tools) have no file-modifying tools, + // so turn_modified_files is always empty here. + if let Some(ref data) = yield_data { + let yield_type = data["yield_type"].as_str().unwrap_or("start_session"); + + match yield_type { + "save_plan" => { + let plan = data["plan"] + .as_str() + .unwrap_or_default() + .to_string(); + let plan_path = data["plan_path"] + .as_str() + .unwrap_or_default() + .to_string(); + + log::info!("[v1.0] save_plan yield — plan saved to {}", plan_path); + + let _ = self.event_tx.send(AgentEvent::PlanReady { + session_id: self.session_id.clone(), + plan: plan.clone(), + plan_path: plan_path.clone(), + project_path: self.config.working_dir.to_string_lossy().to_string(), + }).await; + + return Ok(AgentResult::PlanReady { plan, plan_path }); + } + "ask_user" => { + log::info!("[v1.0] ask_user yield triggered — agent asking user a question"); + let question = data["question"] + .as_str() + .unwrap_or_default() + .to_string(); + let options: Option> = data["options"].as_array().map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }); + + let _ = self.event_tx.send(AgentEvent::UserQuestionAsked { + session_id: self.session_id.clone(), + question: question.clone(), + options: options.clone(), + }).await; + + return Ok(AgentResult::AskUser { question, options }); + } + _ => { + // start_session (existing behavior) + let project_path = data["project_path"] + .as_str() + .unwrap_or_default() + .to_string(); + let branch = data["branch"].as_str().map(String::from); + let task_summary = data["task_summary"] + .as_str() + .unwrap_or_default() + .to_string(); + + let _ = self.event_tx.send(AgentEvent::SessionStart { + session_id: self.session_id.clone(), + project_path: project_path.clone(), + branch: branch.clone(), + task_summary: task_summary.clone(), + }).await; + + return Ok(AgentResult::StartSession { + project_path, + branch, + task_summary, + }); + } + } + } + + // Check cancellation after tool execution + if self.cancel_token.is_cancelled() { + // Emit TurnCompleted before returning — tools already ran and may have + // modified files on disk. The relay needs to know about these changes. + if !turn_modified_files.is_empty() { + let _ = self.event_tx.send(AgentEvent::TurnCompleted { + session_id: self.session_id.clone(), + turn_count: iteration + self.iteration_offset, + modified_files: std::mem::take(&mut turn_modified_files), + }).await; + } + return Err(AgentError::Cancelled); + } + + // Post-tool compaction check (using actual token count from API) + if compaction_cooldown > 0 { + compaction_cooldown -= 1; + } else if compaction::needs_compaction_by_tokens(self.total_tokens_used, self.messages.len(), &self.config.compaction_config) { + compaction_cooldown = self.try_compaction().await; + tokens_measured_at = self.messages.len(); + } + + // Emit turn boundary event + let _ = self.event_tx.send(AgentEvent::TurnCompleted { + session_id: self.session_id.clone(), + turn_count: iteration + self.iteration_offset, + modified_files: std::mem::take(&mut turn_modified_files), + }).await; + + // Continue loop — send tool results back to LLM + } + } + + /// Send a message to the ordered persistence worker. Non-blocking. + fn persist_fire_and_forget(&self, msg: AgentMessage) { + if let Some(ref tx) = self.persist_tx { + let _ = tx.send(PersistItem { message: msg }); + } + } + + /// Convert a ChatMessage to an AgentMessage for persistence. + fn chat_to_agent_message( + msg: &ChatMessage, + message_type: MessageType, + sender: Sender, + turn_count: Option, + ) -> AgentMessage { + let content = msg.content.as_ref().map(|c| c.text().to_string()).unwrap_or_default(); + let llm_message = serde_json::to_value(msg).unwrap_or_default(); + AgentMessage { + content, + llm_message, + metadata: serde_json::json!({}), + role: match msg.role.as_str() { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "tool" => MessageRole::Tool, + "system" => MessageRole::System, + _ => MessageRole::User, + }, + message_type, + sender, + also_send_to_channel: false, + turn_count, + } + } + + async fn call_llm_with_retry( + &self, + tool_defs: &[ToolDefinition], + ) -> Result { + let max_retries = self.config.retry_config.max_retries; + let mut delay = self.config.retry_config.initial_delay; + let mut last_error: Option = None; + + for attempt in 0..=max_retries { + if attempt > 0 { + // Emit retry event + let _ = self.event_tx.send(AgentEvent::Error { + session_id: self.session_id.clone(), + message: format!( + "LLM call failed, retrying (attempt {}/{}): {}", + attempt, + max_retries, + last_error.as_ref().map(|e| e.to_string()).unwrap_or_default() + ), + retrying: true, + }).await; + + tokio::time::sleep(delay).await; + delay = std::time::Duration::from_secs_f64( + delay.as_secs_f64() * self.config.retry_config.multiplier, + ).min(self.config.retry_config.max_delay); + } + + match self + .client + .chat_completion(&self.messages, tool_defs, &self.event_tx, &self.session_id, Some(&self.cancel_token)) + .await + { + Ok(response) => return Ok(response), + Err(e) => { + // Don't retry on cancellation + if matches!(e, AgentError::Cancelled) { + return Err(e); + } + // Don't retry on permanent client errors (only retry transient/server errors) + if let AgentError::LlmApiError { status, body } = &e { + match *status { + 429 | 500 | 502 | 503 | 504 => {} // retryable + _ => { + // Permanent — don't retry. Emit an explicit error event so + // the UI sees the failure even if the session-level watcher's + // Error+Done emission races with relay teardown. + let _ = self.event_tx.send(AgentEvent::Error { + session_id: self.session_id.clone(), + message: format!( + "LLM API error (status {}): {}", + status, body + ), + retrying: false, + }).await; + return Err(e); + } + } + } + last_error = Some(e); + } + } + } + + // All retries exhausted + let error = last_error.expect("at least one error must have occurred if all retries exhausted"); + let _ = self.event_tx.send(AgentEvent::Error { + session_id: self.session_id.clone(), + message: format!("LLM call failed after {} retries: {}", max_retries, error), + retrying: false, + }).await; + Err(error) + } + + /// Attempt compaction with retry and aggressive truncation fallback. + /// Returns the cooldown (number of iterations to skip before next check). + async fn try_compaction(&mut self) -> u32 { + let Some((start, end)) = compaction::compaction_boundaries( + &self.messages, + self.config.compaction_config.keep_recent_messages, + ) else { + return 0; + }; + + log::info!( + "[AgentLoop {}] Compacting [{}..{}) of {} messages", + self.session_id, start, end, self.messages.len() + ); + + // Try LLM summarization (with one retry) + let summary_result = self.try_llm_summarization(start, end).await; + + match summary_result { + Some(summary_text) => { + let summary_msg = ChatMessage::user(format!( + "[Context summary from earlier in this conversation]\n{summary_text}" + )); + + // Bug 1 fix: write kept_before_count into metadata + let kept_before = start; // messages before compaction range (typically 1 = system prompt) + let mut agent_msg = Self::chat_to_agent_message( + &summary_msg, + MessageType::Compaction, + Sender::Agent, + None, + ); + agent_msg.metadata = serde_json::json!({ + "version": 1, + "kept_before_count": kept_before, + }); + self.persist_fire_and_forget(agent_msg); + + self.apply_compaction(start, end, summary_msg).await; + } + None => { + // Bug 4 fallback: aggressive truncation — drop messages without summarization + log::warn!( + "[AgentLoop {}] Compaction LLM failed twice — falling back to aggressive truncation", + self.session_id + ); + let truncation_msg = ChatMessage::user( + "[Earlier context was truncated due to length. Some conversation history has been lost.]" + .to_string(), + ); + + let kept_before = start; + let mut agent_msg = Self::chat_to_agent_message( + &truncation_msg, + MessageType::Compaction, + Sender::Agent, + None, + ); + agent_msg.metadata = serde_json::json!({ + "version": 1, + "kept_before_count": kept_before, + "truncated": true, + }); + self.persist_fire_and_forget(agent_msg); + + self.apply_compaction(start, end, truncation_msg).await; + } + } + + // Bug 2 fix: cooldown — skip compaction checks for 2 iterations + 2 + } + + /// Try LLM summarization with one retry. Returns None if both attempts fail. + async fn try_llm_summarization(&self, start: usize, end: usize) -> Option { + let to_compact = &self.messages[start..end]; + let prompt = compaction::build_compaction_prompt(to_compact); + let compact_msgs = vec![ + ChatMessage::system( + "Summarize this conversation. Structure your summary as:\n\ + ## Goal\nWhat the user is trying to accomplish.\n\ + ## Key Decisions\nImportant choices made and why.\n\ + ## Work Completed\nWhat was done — files modified, code written, tests run.\n\ + ## Current State\nWhere things stand — what works, what's broken, what's next.\n\ + ## Relevant Files\nFile paths referenced or modified.\n\n\ + Preserve exact file paths, function names, error messages, and technical details. Be concise but complete." + ), + ChatMessage::user(prompt), + ]; + + // Bare `_` drops the receiver immediately so send() never blocks. + let (silent_tx, _) = tokio::sync::mpsc::channel(1); + + // Prefer the dedicated compaction client (cheaper model) when configured; + // fall back to the main client otherwise. Summarization is a formatting task — + // it does not need the same model as the main loop. + let summarizer: &dyn LlmProvider = self + .compaction_client + .as_deref() + .unwrap_or(self.client.as_ref()); + + for attempt in 1..=2 { + match summarizer.chat_completion(&compact_msgs, &[], &silent_tx, &self.session_id, Some(&self.cancel_token)).await { + Ok(response) => { + let text = response.content.unwrap_or_default(); + if !text.trim().is_empty() { + return Some(text); + } + log::warn!( + "[AgentLoop {}] Compaction LLM attempt {}/2 returned empty content", + self.session_id, attempt + ); + } + Err(e) => { + // Don't retry on cancellation — let the caller handle it + if matches!(e, AgentError::Cancelled) { + return None; + } + log::error!( + "[AgentLoop {}] Compaction LLM attempt {}/2 failed: {e}", + self.session_id, attempt + ); + } + } + } + + None + } + + /// Remove tool_result messages whose corresponding assistant+tool_use was lost, + /// and clear `tool_calls` from assistant messages whose results were lost. + /// Both cases are rejected by Anthropic with `unexpected tool_use_id` / + /// `tool_use without tool_result` errors. This runs before every LLM call AND + /// after every compaction (defense in depth). + fn strip_orphaned_tool_results(&mut self) { + // Phase 1: collect all tool_call_ids that exist in any assistant message. + let mut valid_tool_ids = std::collections::HashSet::new(); + for msg in &self.messages { + if let Some(ref tcs) = msg.tool_calls { + for tc in tcs { + valid_tool_ids.insert(tc.id.clone()); + } + } + } + + // Phase 2: drop tool_result messages whose tool_call_id is unknown. + let before = self.messages.len(); + self.messages.retain(|msg| { + if msg.role == "tool" { + if let Some(ref id) = msg.tool_call_id { + return valid_tool_ids.contains(id); + } + } + true + }); + let removed_results = before - self.messages.len(); + + // Phase 3: collect tool_call_ids that have at least one matching tool_result + // in the (possibly-trimmed) message list. + let mut answered_tool_ids = std::collections::HashSet::new(); + for msg in &self.messages { + if msg.role == "tool" { + if let Some(ref id) = msg.tool_call_id { + answered_tool_ids.insert(id.clone()); + } + } + } + + // Phase 4: for any assistant message with tool_calls, drop tool_calls whose + // results are missing. If ALL of an assistant's tool_calls are unanswered, + // clear the field entirely (Anthropic rejects an assistant with dangling + // tool_use blocks just as harshly as orphan tool_results). + let mut cleared_calls = 0usize; + for msg in &mut self.messages { + if msg.role != "assistant" { + continue; + } + let Some(ref mut tcs) = msg.tool_calls else { continue }; + let original = tcs.len(); + tcs.retain(|tc| answered_tool_ids.contains(&tc.id)); + cleared_calls += original - tcs.len(); + if tcs.is_empty() { + msg.tool_calls = None; + } + } + + if removed_results > 0 || cleared_calls > 0 { + log::warn!( + "[AgentLoop {}] Sanitized message history: removed {removed_results} orphan tool_results, cleared {cleared_calls} dangling tool_calls", + self.session_id + ); + } + } + + /// Replace compacted messages with a summary, reset token count, emit event. + async fn apply_compaction(&mut self, start: usize, end: usize, summary_msg: ChatMessage) { + let old_count = self.messages.len(); + let mut new_messages = Vec::new(); + if start > 0 { + new_messages.extend_from_slice(&self.messages[..start]); + } + new_messages.push(summary_msg); + new_messages.extend_from_slice(&self.messages[end..]); + self.messages = new_messages; + + // Bug 3 fix: defense in depth — sanitize immediately after the splice. + // The boundary snap should already prevent orphans, but compaction is one + // of two writers to self.messages and the only one that removes messages. + // Running the strip here guarantees orphan-free state regardless of which + // call path triggers the next LLM call. + self.strip_orphaned_tool_results(); + + log::info!( + "[AgentLoop {}] Compaction: {} → {} messages, tokens reset from {}", + self.session_id, old_count, self.messages.len(), self.total_tokens_used + ); + + // Re-estimate from the compacted messages and persist the post-compaction count. + // Without this, the persisted value would be the pre-compaction count (from the + // last TokenUsage event), causing stale-high seeding on next resume. + let post_compaction_estimate = compaction::estimate_token_count(&self.messages); + self.total_tokens_used = post_compaction_estimate; + + let _ = self.event_tx.send(AgentEvent::TokenUsage { + session_id: self.session_id.clone(), + total_tokens: post_compaction_estimate as u32, + context_limit: self.config.compaction_config.context_limit as u32, + cache_read_tokens: None, + cache_creation_tokens: None, + }).await; + + let _ = self.event_tx.send(AgentEvent::Compaction { + session_id: self.session_id.clone(), + }).await; + } + +} + +/// Free function for tool execution — can be sent to JoinSet tasks. +#[allow(clippy::too_many_arguments)] +async fn execute_tool_call_impl( + tool_call_id: &str, + tool_name: &str, + arguments_json: &str, + registry: &ToolRegistry, + working_dir: &std::path::Path, + cancel_token: CancellationToken, + event_tx: &mpsc::Sender, + session_id: &str, + approval_handler: Option<&dyn ApprovalHandler>, +) -> ToolResult { + // Parse arguments first — if this fails, emit a basic ToolStart before the error ToolEnd + let args: serde_json::Value = match serde_json::from_str(arguments_json) { + Ok(v) => v, + Err(e) => { + let err_msg = format!("Invalid JSON in tool arguments: {e}"); + let _ = event_tx.send(AgentEvent::ToolStart { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + args_summary: format!("Executing {tool_name}"), + }).await; + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + }; + + // Build nice summary from parsed args and emit ToolStart + let args_summary = build_args_summary(tool_name, &args, working_dir); + let _ = event_tx.send(AgentEvent::ToolStart { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + args_summary: args_summary.clone(), + }).await; + + // Look up tool + let tool = match registry.get(tool_name) { + Some(t) => t, + None => { + let err_msg = format!("Unknown tool: '{tool_name}'. Available tools: {}", + registry.tool_definitions().iter().map(|t| t.function.name.as_str()).collect::>().join(", ")); + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + }; + + // Validate args against schema + let schema = tool.parameters_schema(); + if let Err(validation_err) = validate_args(&args, &schema) { + let err_msg = format!("Invalid arguments for tool '{tool_name}': {validation_err}"); + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + + // Check if approval is needed + if let Some(handler) = approval_handler { + let decision = tokio::select! { + d = handler.request_approval(tool_name, tool_call_id, &args, &args_summary) => d, + _ = cancel_token.cancelled() => { + ApprovalDecision::Denied { reason: Some("Session cancelled".to_string()) } + } + }; + + if let ApprovalDecision::Denied { reason } = decision { + let reason_text = reason.unwrap_or_else(|| "User denied".to_string()); + let err_msg = format!("Permission denied for tool '{tool_name}': {reason_text}"); + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + } + + // Extract file path for file-modifying tools before args are moved + let modified_file_path = match tool_name { + "write" | "edit" => args + .get("file_path") + .or_else(|| args.get("filePath")) + .and_then(|v| v.as_str()) + .map(|p| p.to_string()), + _ => None, + }; + + // Path safety check: write/edit outside the working directory ALWAYS requires + // explicit user approval, regardless of permission level. The [SECURITY] prefix + // tells the PermissionAwareApprovalHandler to bypass auto-approve. + if let Some(ref file_path_str) = modified_file_path { + let resolved = crate::util::resolve_path(working_dir, file_path_str); + let is_within = crate::util::is_path_within_working_dir(working_dir, &resolved); + if !is_within { + // Use ~ shorthand for home dir in the display path + let display_path = { + let p = resolved.display().to_string(); + match std::env::var("HOME") { + Ok(home) if p.starts_with(&home) => { + format!("~{}", &p[home.len()..]) + } + _ => p, + } + }; + let outside_summary = format!( + "[SECURITY] Write to {display_path} (outside project)" + ); + log::warn!("[PathGuard] Outside-project write detected, requesting user approval"); + + if let Some(handler) = approval_handler { + let decision = tokio::select! { + d = handler.request_approval(tool_name, tool_call_id, &args, &outside_summary) => d, + _ = cancel_token.cancelled() => { + ApprovalDecision::Denied { reason: Some("Session cancelled".to_string()) } + } + }; + + if let ApprovalDecision::Denied { reason } = decision { + let reason_text = reason.unwrap_or_else(|| "User denied".to_string()); + let err_msg = format!( + "Path '{}' is outside the project directory. User denied: {reason_text}", + resolved.display() + ); + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + log::info!("[PathGuard] User approved outside-project write to '{}'", resolved.display()); + } else { + // No approval handler — block for safety + let err_msg = format!( + "Path '{}' is outside the project directory '{}'. No approval handler to ask user.", + resolved.display(), working_dir.display() + ); + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: false, + summary: err_msg.clone(), + modified_files: None, + }).await; + return ToolResult::error(err_msg); + } + } + } + + // Execute + let ctx = ToolContext { + working_dir: working_dir.to_path_buf(), + cancel_token, + event_tx: event_tx.clone(), + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + }; + + let mut result = match tool.execute(args, &ctx).await { + Ok(mut r) => { + let truncated = truncate_and_persist( + &r.output, + MAX_TOOL_OUTPUT_LINES, + MAX_TOOL_OUTPUT_BYTES, + working_dir, + tool_call_id, + ).await; + r.output = truncated; + r + } + Err(e) => ToolResult::error(format!("Tool execution error: {e}")), + }; + + // Propagate modified file path onto the result for TurnCompleted tracking + if !result.is_error { + if let Some(ref p) = modified_file_path { + if result.modified_files.is_empty() { + result.modified_files = vec![p.clone()]; + } + } + } + + // Use result.modified_files for ToolEnd event + let modified_files = if !result.is_error && !result.modified_files.is_empty() { + Some(result.modified_files.clone()) + } else { + None + }; + + // Emit ToolEnd + let summary = if result.output.len() > 200 { + format!("{}...", truncate_str(&result.output, 200)) + } else { + result.output.clone() + }; + let _ = event_tx.send(AgentEvent::ToolEnd { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + success: !result.is_error, + summary, + modified_files, + }).await; + + result +} + +/// Shorten a file path for display: strip the working dir prefix to show a relative path. +/// If the path is outside working_dir, show it as-is. +fn shorten_path(path: &str, working_dir: &std::path::Path) -> String { + let wd = working_dir.to_string_lossy(); + // Strip worktree prefix (e.g., /project/.agent-worktrees/session-id/foo.rs → foo.rs) + if let Some(rel) = path.strip_prefix(wd.as_ref()) { + let rel = rel.strip_prefix('/').unwrap_or(rel); + if rel.is_empty() { ".".to_string() } else { rel.to_string() } + } else { + path.to_string() + } +} + +/// Build a human-readable summary of tool arguments for the ToolStart event. +fn build_args_summary(tool_name: &str, args: &serde_json::Value, working_dir: &std::path::Path) -> String { + match tool_name { + "read" => { + let path = args.get("filePath").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Reading {}", shorten_path(path, working_dir)) + } + "write" => { + let path = args.get("filePath").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Writing {}", shorten_path(path, working_dir)) + } + "bash" => { + let desc = args.get("description").and_then(|v| v.as_str()).unwrap_or("?"); + desc.to_string() + } + "edit" => { + let path = args.get("filePath").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Editing {}", shorten_path(path, working_dir)) + } + "glob" => { + let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Searching for {pattern}") + } + "grep" => { + let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Searching for /{pattern}/") + } + "git" => { + let desc = args.get("description").and_then(|v| v.as_str()).unwrap_or("?"); + desc.to_string() + } + "create_pr" => { + let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Creating PR: {title}") + } + "start_session" => { + let summary = args.get("task_summary").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Starting session: {summary}") + } + "save_plan" => { + let filename = args.get("filename").and_then(|v| v.as_str()).unwrap_or("plan.md"); + format!("Saving plan: {filename}") + } + "edit_plan" => { + let path = args.get("file_path").and_then(|v| v.as_str()); + match path { + Some(p) => format!("Editing plan: {}", shorten_path(p, working_dir)), + None => "Editing plan".to_string(), + } + } + "todo_write" => { + let first = args + .get("todos") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .and_then(|t| t.get("content")) + .and_then(|v| v.as_str()); + match first { + Some(content) => format!("Updating todos: {content}"), + None => "Updating todos".to_string(), + } + } + "todo_read" => "Reading todos".to_string(), + "codebase_search" => { + let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Semantic search: {query}") + } + "codebase_graph" => { + let query = args.get("query").and_then(|v| v.as_str()) + .or_else(|| args.get("function_name").and_then(|v| v.as_str())) + .unwrap_or("?"); + format!("Querying graph: {query}") + } + "ask_user" => { + let question = args.get("question").and_then(|v| v.as_str()).unwrap_or("?"); + let short = truncate_str(question, 60); + format!("Asking: {short}") + } + "skill" => { + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Loading skill: {name}") + } + _ => { + // Generic: show first 100 chars of serialized args + let s = args.to_string(); + if s.len() > 100 { + format!("{}...", truncate_str(&s, 100)) + } else { + s + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use crate::agent::config::RetryConfig; + use crate::error::ToolError; + use crate::llm::types::{FunctionCall, LlmResponse, ToolCall}; + use crate::test_util::{MockLlm, text_response}; + use crate::tool::{Tool, ToolRegistry}; + use crate::types::AgentResult; + use crate::util::truncate_output; + + // ── Capturing LLM (records messages sent to it) ── + + struct CapturingLlm { + responses: Mutex>>, + captured: Mutex>>, + } + + #[async_trait::async_trait] + impl LlmProvider for CapturingLlm { + async fn chat_completion( + &self, + messages: &[ChatMessage], + _tools: &[ToolDefinition], + _event_tx: &mpsc::Sender, + _session_id: &str, + _cancel_token: Option<&CancellationToken>, + ) -> Result { + self.captured.lock().unwrap().push(messages.to_vec()); + self.responses + .lock() + .unwrap() + .pop_front() + .expect("CapturingLlm: no more responses queued") + } + } + + /// Wrapper to use Arc as Box. + struct ArcLlm(Arc); + + #[async_trait::async_trait] + impl LlmProvider for ArcLlm { + async fn chat_completion( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + event_tx: &mpsc::Sender, + session_id: &str, + cancel_token: Option<&CancellationToken>, + ) -> Result { + self.0.chat_completion(messages, tools, event_tx, session_id, cancel_token).await + } + } + + // ── Echo Tool (for testing tool dispatch) ── + + struct EchoTool; + + #[async_trait::async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "Echoes the message" + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["message"], + "properties": { + "message": { "type": "string" } + } + }) + } + async fn execute( + &self, + args: serde_json::Value, + _ctx: &ToolContext, + ) -> Result { + let msg = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(ToolResult::success(msg)) + } + } + + // ── CancelTriggerTool (cancels the token when executed) ── + + struct CancelTriggerTool; + + #[async_trait::async_trait] + impl Tool for CancelTriggerTool { + fn name(&self) -> &str { + "cancel_trigger" + } + fn description(&self) -> &str { + "Triggers cancellation" + } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + async fn execute( + &self, + _args: serde_json::Value, + ctx: &ToolContext, + ) -> Result { + ctx.cancel_token.cancel(); + Ok(ToolResult::success("cancelled")) + } + } + + // ── Helpers ── + + fn tool_call_response(id: &str, name: &str, args: &str) -> LlmResponse { + LlmResponse { + content: None, + tool_calls: vec![ToolCall { + id: id.to_string(), + type_: "function".to_string(), + function: FunctionCall { + name: name.to_string(), + arguments: args.to_string(), + }, + }], + usage: None, + finish_reason: Some("tool_calls".to_string()), + thinking: None, + } + } + + fn tool_call_response_with_usage(id: &str, name: &str, args: &str, total_tokens: u32) -> LlmResponse { + let mut resp = tool_call_response(id, name, args); + resp.usage = Some(crate::llm::types::Usage { + prompt_tokens: total_tokens.saturating_sub(10), + completion_tokens: 10, + total_tokens, + prompt_tokens_details: None, + }); + resp + } + + fn multi_tool_call_response(calls: Vec<(&str, &str, &str)>) -> LlmResponse { + LlmResponse { + content: None, + tool_calls: calls + .into_iter() + .map(|(id, name, args)| ToolCall { + id: id.to_string(), + type_: "function".to_string(), + function: FunctionCall { + name: name.to_string(), + arguments: args.to_string(), + }, + }) + .collect(), + usage: None, + finish_reason: Some("tool_calls".to_string()), + thinking: None, + } + } + + fn echo_registry() -> ToolRegistry { + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(EchoTool)); + reg + } + + fn make_agent( + mock: MockLlm, + registry: ToolRegistry, + max_iterations: Option, + ) -> (AgentLoop, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + if let Some(max) = max_iterations { + config.max_iterations = max; + } + // No retries in tests — failures fail immediately + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let agent_loop = AgentLoop::with_provider( + config, + Box::new(mock), + registry, + CancellationToken::new(), + tx, + "test-session".into(), + ); + (agent_loop, rx) + } + + fn collect_events(rx: &mut mpsc::Receiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events + } + + // ════════════════════════════════════════════ + // build_args_summary tests + // ════════════════════════════════════════════ + + #[test] + fn test_build_args_summary_read() { + let args = json!({"filePath": "/src/main.rs"}); + assert_eq!(build_args_summary("read", &args, std::path::Path::new("/tmp")), "Reading /src/main.rs"); + } + + #[test] + fn test_build_args_summary_write() { + let args = json!({"filePath": "/src/lib.rs", "content": "..."}); + assert_eq!(build_args_summary("write", &args, std::path::Path::new("/tmp")), "Writing /src/lib.rs"); + } + + #[test] + fn test_build_args_summary_bash() { + let args = json!({"command": "ls -la", "description": "List files"}); + assert_eq!(build_args_summary("bash", &args, std::path::Path::new("/tmp")), "List files"); + } + + #[test] + fn test_build_args_summary_generic() { + let args = json!({"key": "value"}); + assert_eq!( + build_args_summary("unknown_tool", &args, std::path::Path::new("/tmp")), + r#"{"key":"value"}"# + ); + } + + #[test] + fn test_build_args_summary_generic_truncated() { + let long_val = "x".repeat(200); + let args = json!({"key": long_val}); + let result = build_args_summary("unknown_tool", &args, std::path::Path::new("/tmp")); + assert!(result.ends_with("...")); + // 100 bytes of content + "..." + assert!(result.len() <= 104); + } + + // ════════════════════════════════════════════ + // Agent loop — happy path + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_text_only_response() { + let mock = MockLlm::new(vec![Ok(text_response("Hello!"))]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + + let result = agent.run(ChatMessage::user("Hi")).await.unwrap().unwrap_done(); + assert_eq!(result, "Hello!"); + + let events = collect_events(&mut rx); + assert!(events.iter().any( + |e| matches!(e, AgentEvent::Done { summary: Some(s), .. } if s == "Hello!") + )); + } + + #[tokio::test] + async fn test_tool_call_then_text() { + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "call_1", + "echo", + r#"{"message":"pong"}"#, + )), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("ping")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + assert!(events.iter().any( + |e| matches!(e, AgentEvent::ToolStart { tool_name, args_summary, .. } if tool_name == "echo" && args_summary.contains("pong")) + )); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::ToolEnd { success: true, .. }))); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Done { .. }))); + } + + #[tokio::test] + async fn test_none_content_with_no_tool_calls_is_error() { + // Empty response with no tool_calls = the model silently gave up. + // Previously this returned AgentResult::Done { summary: "" } which + // showed in the UI as a successful completion — users saw the agent + // just stop with no explanation. It's now surfaced as an error with + // retry guidance so the UI can render it visibly. + let response = LlmResponse { + content: None, + tool_calls: vec![], + usage: None, + finish_reason: Some("stop".to_string()), + thinking: None, + }; + let mock = MockLlm::new(vec![Ok(response)]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + + let err = agent.run(ChatMessage::user("test")).await.unwrap_err(); + assert!( + matches!(err, AgentError::LlmParseError(_)), + "expected LlmParseError, got {err:?}" + ); + + // Verify the user-visible AgentEvent::Error was emitted (retrying=false) + let events = collect_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { retrying: false, .. } + )), + "expected AgentEvent::Error with retrying=false, got events: {events:?}" + ); + } + + // ════════════════════════════════════════════ + // Agent loop — tool error paths (ToolStart/ToolEnd pairing) + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_unknown_tool_emits_start_and_end() { + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "nonexistent", "{}")), + Ok(text_response("recovered")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "recovered"); + + let events = collect_events(&mut rx); + let starts: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolStart { .. })) + .collect(); + let ends: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolEnd { .. })) + .collect(); + assert_eq!(starts.len(), 1, "should emit exactly one ToolStart"); + assert_eq!(ends.len(), 1, "should emit exactly one ToolEnd"); + assert!(events.iter().any( + |e| matches!(e, AgentEvent::ToolEnd { success: false, summary, .. } if summary.contains("Unknown tool")) + )); + } + + #[tokio::test] + async fn test_invalid_json_args_emits_start_and_end() { + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", "not valid json")), + Ok(text_response("OK")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "OK"); + + let events = collect_events(&mut rx); + let starts: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolStart { .. })) + .collect(); + let ends: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolEnd { .. })) + .collect(); + assert_eq!(starts.len(), 1); + assert_eq!(ends.len(), 1); + assert!(events.iter().any( + |e| matches!(e, AgentEvent::ToolEnd { success: false, summary, .. } if summary.contains("Invalid JSON")) + )); + } + + #[tokio::test] + async fn test_schema_validation_error_emits_start_and_end() { + // echo requires "message" (string), send a number instead → type mismatch + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message": 42}"#)), + Ok(text_response("OK")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "OK"); + + let events = collect_events(&mut rx); + let starts: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolStart { .. })) + .collect(); + let ends: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolEnd { .. })) + .collect(); + assert_eq!(starts.len(), 1); + assert_eq!(ends.len(), 1); + assert!(events.iter().any( + |e| matches!(e, AgentEvent::ToolEnd { success: false, summary, .. } if summary.contains("Invalid arguments")) + )); + } + + #[tokio::test] + async fn test_tool_output_summary_truncated_at_200() { + // Create a tool that returns a very long output + struct LongOutputTool; + + #[async_trait::async_trait] + impl Tool for LongOutputTool { + fn name(&self) -> &str { + "long" + } + fn description(&self) -> &str { + "Returns long output" + } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + async fn execute( + &self, + _args: serde_json::Value, + _ctx: &ToolContext, + ) -> Result { + Ok(ToolResult::success("x".repeat(500))) + } + } + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(LongOutputTool)); + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "long", "{}")), + Ok(text_response("done")), + ]); + let (mut agent, mut rx) = make_agent(mock, reg, None); + + agent.run(ChatMessage::user("test")).await.unwrap(); + + let events = collect_events(&mut rx); + let tool_end = events + .iter() + .find(|e| matches!(e, AgentEvent::ToolEnd { .. })) + .unwrap(); + if let AgentEvent::ToolEnd { summary, .. } = tool_end { + assert!(summary.ends_with("...")); + assert!(summary.len() <= 204); // 200 + "..." + } + } + + // ════════════════════════════════════════════ + // Agent loop — iteration limit and cancellation + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_max_iterations() { + let responses: Vec<_> = (0..5) + .map(|i| { + Ok(tool_call_response( + &format!("call_{i}"), + "echo", + r#"{"message":"loop"}"#, + )) + }) + .collect(); + let mock = MockLlm::new(responses); + let (mut agent, _rx) = make_agent(mock, echo_registry(), Some(3)); + + let result = agent.run(ChatMessage::user("loop")).await.unwrap(); + match result { + AgentResult::Done { ref summary } => { + assert!(summary.contains("maximum of 3 steps"), "Got: {summary}"); + } + other => panic!("Expected AgentResult::Done, got {:?}", other), + } + } + + #[tokio::test] + async fn test_cancellation_at_loop_start() { + let mock = MockLlm::new(vec![]); + let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None); + agent.cancel_token.cancel(); + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!(result, Err(AgentError::Cancelled))); + } + + #[tokio::test] + async fn test_cancellation_between_tool_calls() { + // Two tool calls: first cancels the token + // With parallel execution, both may start simultaneously + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(CancelTriggerTool)); + reg.register(Arc::new(EchoTool)); + + let mock = MockLlm::new(vec![Ok(multi_tool_call_response(vec![ + ("call_1", "cancel_trigger", "{}"), + ("call_2", "echo", r#"{"message":"may run"}"#), + ]))]); + let (mut agent, mut rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!(result, Err(AgentError::Cancelled))); + + // With parallel execution, both tools may start. + // The important thing is that the agent returns Cancelled. + let events = collect_events(&mut rx); + let starts: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::ToolStart { .. })) + .collect(); + // Allow 1 or 2 ToolStarts (parallel execution may start both) + assert!( + starts.len() >= 1 && starts.len() <= 2, + "Expected 1-2 ToolStart events, got {}", + starts.len() + ); + } + + // ════════════════════════════════════════════ + // Retry logic + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_retry_on_500_then_success() { + tokio::time::pause(); + let mock = MockLlm::new(vec![ + Err(AgentError::LlmApiError { + status: 500, + body: "Internal Server Error".into(), + }), + Ok(text_response("recovered")), + ]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + agent.config.retry_config.max_retries = 3; + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "recovered"); + + let events = collect_events(&mut rx); + // Should have emitted an Error event with retrying=true + assert!(events.iter().any( + |e| matches!(e, AgentEvent::Error { retrying: true, .. }) + )); + } + + #[tokio::test] + async fn test_retry_on_429_then_success() { + tokio::time::pause(); + let mock = MockLlm::new(vec![ + Err(AgentError::LlmApiError { + status: 429, + body: "Rate limited".into(), + }), + Ok(text_response("ok")), + ]); + let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None); + agent.config.retry_config.max_retries = 3; + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "ok"); + } + + #[tokio::test] + async fn test_no_retry_on_401() { + let mock = MockLlm::new(vec![Err(AgentError::LlmApiError { + status: 401, + body: "Unauthorized".into(), + })]); + let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None); + + let result = agent.run(ChatMessage::user("test")).await; + match result { + Err(AgentError::LlmApiError { status: 401, .. }) => {} // expected + other => panic!("Expected 401 error, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_no_retry_on_400() { + let mock = MockLlm::new(vec![Err(AgentError::LlmApiError { + status: 400, + body: "Bad Request".into(), + })]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!( + result, + Err(AgentError::LlmApiError { status: 400, .. }) + )); + + // Permanent LLM errors must emit an AgentEvent::Error (retrying=false) + // so the UI sees the failure without depending on the session-level + // watcher's Error+Done emission. + let events = collect_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { retrying: false, .. } + )), + "expected AgentEvent::Error with retrying=false on 400, got: {events:?}" + ); + } + + #[tokio::test] + async fn test_retry_exhausted() { + tokio::time::pause(); + // 4 responses for max_retries=3 (attempts 0, 1, 2, 3) + let mock = MockLlm::new(vec![ + Err(AgentError::LlmApiError { + status: 500, + body: "fail".into(), + }), + Err(AgentError::LlmApiError { + status: 500, + body: "fail".into(), + }), + Err(AgentError::LlmApiError { + status: 500, + body: "fail".into(), + }), + Err(AgentError::LlmApiError { + status: 500, + body: "fail".into(), + }), + ]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + agent.config.retry_config.max_retries = 3; + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!( + result, + Err(AgentError::LlmApiError { status: 500, .. }) + )); + + let events = collect_events(&mut rx); + // Should have retry events (retrying=true) and a final error (retrying=false) + let retry_events: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::Error { retrying: true, .. })) + .collect(); + let final_error = events + .iter() + .find(|e| matches!(e, AgentEvent::Error { retrying: false, .. })); + assert_eq!(retry_events.len(), 3, "should have 3 retry events"); + assert!(final_error.is_some(), "should have a final error event"); + if let Some(AgentEvent::Error { message, .. }) = final_error { + assert!(message.contains("after 3 retries")); + } + } + + #[tokio::test] + async fn test_cancellation_not_retried() { + let mock = MockLlm::new(vec![Err(AgentError::Cancelled)]); + let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None); + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!(result, Err(AgentError::Cancelled))); + } + + // ════════════════════════════════════════════ + // build_args_summary — new tool tests + // ════════════════════════════════════════════ + + #[test] + fn test_build_args_summary_edit() { + let args = json!({"filePath": "/src/main.rs", "oldString": "x", "newString": "y"}); + assert_eq!(build_args_summary("edit", &args, std::path::Path::new("/tmp")), "Editing /src/main.rs"); + } + + #[test] + fn test_build_args_summary_glob() { + let args = json!({"pattern": "**/*.rs"}); + assert_eq!(build_args_summary("glob", &args, std::path::Path::new("/tmp")), "Searching for **/*.rs"); + } + + #[test] + fn test_build_args_summary_grep() { + let args = json!({"pattern": "fn main"}); + assert_eq!(build_args_summary("grep", &args, std::path::Path::new("/tmp")), "Searching for /fn main/"); + } + + #[test] + fn test_build_args_summary_git() { + let args = json!({"command": "status", "description": "Check repo status"}); + assert_eq!(build_args_summary("git", &args, std::path::Path::new("/tmp")), "Check repo status"); + } + + #[test] + fn test_build_args_summary_create_pr() { + let args = json!({"title": "Add feature X", "body": "...", "branch": "feat"}); + assert_eq!( + build_args_summary("create_pr", &args, std::path::Path::new("/tmp")), + "Creating PR: Add feature X" + ); + } + + // ════════════════════════════════════════════ + // Global output truncation + // ════════════════════════════════════════════ + + #[test] + fn test_truncate_tool_output_short_passes_through() { + let input = "short output"; + assert_eq!(truncate_output(input, MAX_TOOL_OUTPUT_LINES, MAX_TOOL_OUTPUT_BYTES), input); + } + + #[test] + fn test_truncate_tool_output_exceeds_line_limit() { + let input: String = (0..2500).map(|i| format!("line {i}")).collect::>().join("\n"); + let result = truncate_output(&input, MAX_TOOL_OUTPUT_LINES, MAX_TOOL_OUTPUT_BYTES); + assert!(result.contains("truncated")); + assert!(result.contains("2000 of 2500 lines")); + } + + #[test] + fn test_truncate_tool_output_exceeds_byte_limit() { + let input: String = (0..100).map(|i| format!("line {:04} {}", i, "x".repeat(600))).collect::>().join("\n"); + assert!(input.len() > MAX_TOOL_OUTPUT_BYTES); + let result = truncate_output(&input, MAX_TOOL_OUTPUT_LINES, MAX_TOOL_OUTPUT_BYTES); + assert!(result.contains("truncated")); + } + + // ════════════════════════════════════════════ + // Parallel execution + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_parallel_tool_calls_return_correct_order() { + // Two echo calls should return results in the correct order + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "echo", r#"{"message":"first"}"#), + ("call_2", "echo", r#"{"message":"second"}"#), + ])), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + let tool_ends: Vec<_> = events + .iter() + .filter_map(|e| { + if let AgentEvent::ToolEnd { tool_call_id, summary, .. } = e { + Some((tool_call_id.clone(), summary.clone())) + } else { + None + } + }) + .collect(); + + assert_eq!(tool_ends.len(), 2); + // Both tool calls should have completed + let ids: Vec<_> = tool_ends.iter().map(|(id, _)| id.as_str()).collect(); + assert!(ids.contains(&"call_1")); + assert!(ids.contains(&"call_2")); + } + + #[tokio::test] + async fn test_one_tool_failure_doesnt_affect_others() { + // One tool call with unknown tool, one valid + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "nonexistent", "{}"), + ("call_2", "echo", r#"{"message":"works"}"#), + ])), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + let tool_ends: Vec<_> = events + .iter() + .filter_map(|e| { + if let AgentEvent::ToolEnd { tool_call_id, success, .. } = e { + Some((tool_call_id.clone(), *success)) + } else { + None + } + }) + .collect(); + + assert_eq!(tool_ends.len(), 2); + // One failed, one succeeded + let failed = tool_ends.iter().filter(|(_, s)| !s).count(); + let succeeded = tool_ends.iter().filter(|(_, s)| *s).count(); + assert_eq!(failed, 1); + assert_eq!(succeeded, 1); + } + + // ── PanickingTool (panics when executed — tests JoinSet recovery) ── + + struct PanickingTool; + + #[async_trait::async_trait] + impl Tool for PanickingTool { + fn name(&self) -> &str { + "panicker" + } + fn description(&self) -> &str { + "Panics on execute" + } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + async fn execute( + &self, + _args: serde_json::Value, + _ctx: &ToolContext, + ) -> Result { + panic!("intentional panic for testing"); + } + } + + #[tokio::test] + async fn test_panic_recovery_injects_synthetic_error() { + // A tool that panics should produce a synthetic error, not crash the agent + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(PanickingTool)); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "panicker", "{}")), + Ok(text_response("recovered after panic")), + ]); + let (mut agent, mut rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "recovered after panic"); + + let events = collect_events(&mut rx); + // The agent should have continued after the panicked tool + assert!(events.iter().any(|e| matches!(e, AgentEvent::Done { .. }))); + } + + #[tokio::test] + async fn test_panic_alongside_healthy_tool() { + // One tool panics, another succeeds — both should produce tool results + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(PanickingTool)); + reg.register(Arc::new(EchoTool)); + + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "panicker", "{}"), + ("call_2", "echo", r#"{"message":"ok"}"#), + ])), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + // The echo tool should have succeeded + assert!(events.iter().any( + |e| matches!(e, AgentEvent::ToolEnd { tool_call_id, success: true, .. } if tool_call_id == "call_2") + )); + // The agent should have completed + assert!(events.iter().any(|e| matches!(e, AgentEvent::Done { .. }))); + } + + #[tokio::test] + async fn test_parallel_cancellation() { + // Pre-cancel before tool execution + let mock = MockLlm::new(vec![Ok(multi_tool_call_response(vec![ + ("call_1", "echo", r#"{"message":"test"}"#), + ]))]); + let (mut agent, _rx) = make_agent(mock, echo_registry(), None); + // Cancel after LLM returns but before tool exec (which the loop checks) + // Actually we pre-cancel to ensure the batch-level check catches it + agent.cancel_token.cancel(); + + let result = agent.run(ChatMessage::user("test")).await; + assert!(matches!(result, Err(AgentError::Cancelled))); + } + + // ════════════════════════════════════════════ + // start_session yield + // ════════════════════════════════════════════ + + /// Helper: create a temp dir with `git init` for start_session tests + fn make_git_repo_for_session() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let path = dir.path().to_string_lossy().to_string(); + (dir, path) + } + + #[tokio::test] + async fn test_start_session_returns_start_session_result() { + use crate::tool::start_session::StartSessionTool; + + let (_dir, dir_str) = make_git_repo_for_session(); + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(StartSessionTool)); + + let args = format!(r#"{{"project_path":"{}","task_summary":"Fix the login bug"}}"#, dir_str); + let mock = MockLlm::new(vec![Ok(tool_call_response( + "call_1", + "start_session", + &args, + ))]); + let (mut agent, _rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("Fix the login bug")).await.unwrap(); + match result { + AgentResult::StartSession { + project_path, + branch, + task_summary, + } => { + assert_eq!(project_path, dir_str); + assert!(branch.is_none()); + assert_eq!(task_summary, "Fix the login bug"); + } + other => panic!("Expected StartSession, got {:?}", other), + } + } + + #[tokio::test] + async fn test_start_session_with_branch() { + use crate::tool::start_session::StartSessionTool; + + let (_dir, dir_str) = make_git_repo_for_session(); + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(StartSessionTool)); + + let args = format!(r#"{{"project_path":"{}","branch":"fix/login","task_summary":"Fix login"}}"#, dir_str); + let mock = MockLlm::new(vec![Ok(tool_call_response( + "call_1", + "start_session", + &args, + ))]); + let (mut agent, _rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("Fix login")).await.unwrap(); + match result { + AgentResult::StartSession { branch, .. } => { + assert_eq!(branch.as_deref(), Some("fix/login")); + } + other => panic!("Expected StartSession, got {:?}", other), + } + } + + #[tokio::test] + async fn test_start_session_without_branch() { + use crate::tool::start_session::StartSessionTool; + + let (_dir, dir_str) = make_git_repo_for_session(); + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(StartSessionTool)); + + let args = format!(r#"{{"project_path":"{}","task_summary":"Refactor auth"}}"#, dir_str); + let mock = MockLlm::new(vec![Ok(tool_call_response( + "call_1", + "start_session", + &args, + ))]); + let (mut agent, _rx) = make_agent(mock, reg, None); + + let result = agent.run(ChatMessage::user("Refactor auth")).await.unwrap(); + match result { + AgentResult::StartSession { branch, .. } => { + assert!(branch.is_none()); + } + other => panic!("Expected StartSession, got {:?}", other), + } + } + + // ════════════════════════════════════════════ + // Persister integration + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_persister_receives_all_messages() { + use crate::persistence::MockPersister; + + let persister = Arc::new(MockPersister::new()); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message":"pong"}"#)), + Ok(text_response("Done.")), + ]); + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + echo_registry(), + CancellationToken::new(), + tx, + "test-persist".into(), + ) + .with_persister(Arc::clone(&persister) as Arc, Some("thread-1".into())); + + let _result = agent.run(ChatMessage::user("ping")).await.unwrap(); + drop(rx); + + // Give fire-and-forget tasks a moment to complete + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let msgs = persister.messages(); + // Should have: user message, assistant (tool_call), tool result, assistant (done) + assert!(msgs.len() >= 3, "Expected at least 3 persisted messages, got {}", msgs.len()); + + // All should be in thread-1 + for (tid, _) in &msgs { + assert_eq!(tid.as_deref(), Some("thread-1")); + } + } + + #[tokio::test] + async fn test_persister_failure_does_not_stop_loop() { + use crate::persistence::{AgentMessage, MessagePersister, PersistError, PersistResult}; + + struct FailingPersister; + + #[async_trait::async_trait] + impl MessagePersister for FailingPersister { + async fn persist_message(&self, _msg: &AgentMessage, _thread_id: Option<&str>) -> Result { + Err(PersistError::Storage("simulated failure".into())) + } + async fn load_session_context(&self, _thread_id: &str) -> Result, PersistError> { + Ok(vec![]) + } + async fn load_ask_context(&self) -> Result, PersistError> { + Ok(vec![]) + } + } + + let mock = MockLlm::new(vec![Ok(text_response("Hello!"))]); + let (tx, rx) = mpsc::channel(256); + let config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + ToolRegistry::new(), + CancellationToken::new(), + tx, + "test-fail".into(), + ) + .with_persister(Arc::new(FailingPersister), None); + + // Should complete successfully despite persistence failures + let result = agent.run(ChatMessage::user("Hi")).await.unwrap().unwrap_done(); + assert_eq!(result, "Hello!"); + drop(rx); + } + + #[tokio::test] + async fn test_no_persister_works() { + let mock = MockLlm::new(vec![Ok(text_response("Works!"))]); + let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Works!"); + } + + // ════════════════════════════════════════════ + // Compaction in loop + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_compaction_triggers_in_loop() { + use crate::agent::config::CompactionConfig; + use crate::persistence::MockPersister; + + let persister = Arc::new(MockPersister::new()); + + // 1 tool round with seeded initial context to provide compactable messages. + // After tool execution: [sys, ctx_user, ctx_asst, user, a+tc, tool_result] = 6 msgs + // keep_recent=2 → raw_end=4, snap: messages[3]=user → safe. end=4. + // compact [1,4) = [ctx_user, ctx_asst, user] → 3 msgs, enough! + let mock = MockLlm::new(vec![ + // Tool call, 45 tokens (above 40 threshold) → triggers compaction + Ok(tool_call_response_with_usage("c1", "echo", r#"{"message":"a"}"#, 45)), + // Compaction summary (consumed by compaction LLM call) + Ok(text_response("Compacted: prior context")), + // Final response after compaction + Ok(text_response("Done with compaction.")), + ]); + + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + config.system_prompt = Some(vec![crate::agent::prompt::SystemBlock { + text: "You are helpful.".into(), + cache_control: None, + }]); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + // keep_recent=2 means we keep the last 2 messages (a+tc, tool_result) + // With seeded context: [sys, ctx_user, ctx_asst, user, a+tc, tool_result] = 6 msgs + // raw_end = 6-2 = 4, snap: messages[3]=user → safe. end=4. + // compact [1,4) = [ctx_user, ctx_asst, user] → 3 msgs, enough! + config.compaction_config = CompactionConfig { + context_limit: 50, + threshold_pct: 0.80, + keep_recent_messages: 2, + max_messages: 10_000, + }; + + // Seed initial context so there are compactable messages before the tool pair + let initial_context = vec![ + ChatMessage::user("What does main.rs do?"), + ChatMessage::assistant(Some("It starts the server.".into()), None, None), + ]; + + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + echo_registry(), + CancellationToken::new(), + tx, + "test-compact".into(), + ) + .with_initial_context(initial_context) + .with_persister(Arc::clone(&persister) as Arc, Some("thread-compact".into())); + + let result = agent.run(ChatMessage::user("test compaction")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done with compaction."); + drop(rx); + + // Give fire-and-forget tasks a moment to complete + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Check that a compaction record was persisted + let msgs = persister.messages(); + let compaction_msgs: Vec<_> = msgs + .iter() + .filter(|(_, m)| m.message_type == crate::persistence::MessageType::Compaction) + .collect(); + assert!( + !compaction_msgs.is_empty(), + "Expected at least one compaction record, got none. Total persisted: {}", + msgs.len() + ); + } + + // ════════════════════════════════════════════ + // Compaction: verify message structure after replacement + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_compaction_replaces_messages_correctly() { + use crate::agent::config::CompactionConfig; + + // 1 tool round with seeded context. Token usage=45 triggers compaction. + let mock = CapturingLlm { + responses: Mutex::new(VecDeque::from(vec![ + Ok(tool_call_response_with_usage("c1", "echo", r#"{"message":"a"}"#, 45)), + // Compaction summary + Ok(text_response("Summary: prior context")), + // Final response after compaction + Ok(text_response("All done.")), + ])), + captured: Mutex::new(Vec::new()), + }; + + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + config.system_prompt = Some(vec![crate::agent::prompt::SystemBlock { + text: "You are helpful.".into(), + cache_control: None, + }]); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + // context_limit=50, threshold=80% → compacts at 40 tokens + config.compaction_config = CompactionConfig { + context_limit: 50, + threshold_pct: 0.80, + keep_recent_messages: 2, + max_messages: 10_000, + }; + + let mock = Arc::new(mock); + let mock_clone: Box = Box::new(ArcLlm(Arc::clone(&mock) as Arc)); + + // Seed initial context so there are compactable non-tool messages + let initial_context = vec![ + ChatMessage::user("What does main.rs do?"), + ChatMessage::assistant(Some("It starts the server.".into()), None, None), + ]; + + let mut agent = AgentLoop::with_provider( + config, + mock_clone, + echo_registry(), + CancellationToken::new(), + tx, + "test-compact-struct".into(), + ) + .with_initial_context(initial_context); + + let result = agent.run(ChatMessage::user("test compaction")).await.unwrap().unwrap_done(); + assert_eq!(result, "All done."); + drop(rx); + + // Check the messages sent to the LLM on the THIRD call (after compaction) + let captured = mock.captured.lock().unwrap(); + // Call 0: initial (system + context + user), Call 1: compaction call, Call 2: after compaction + assert!(captured.len() >= 3, "Expected 3+ LLM calls, got {}", captured.len()); + + let post_compaction_msgs = &captured[2]; + // Should have: [system prompt, user(summary), tool_result(kept), ...] + // System prompt must be first + assert_eq!(post_compaction_msgs[0].role, "system"); + assert!(post_compaction_msgs[0].content.as_ref().unwrap().text().contains("You are helpful")); + + // No second system message should exist + let system_count = post_compaction_msgs.iter().filter(|m| m.role == "system").count(); + assert_eq!(system_count, 1, "Should have exactly 1 system message after compaction, got {system_count}"); + + // The summary should be a user-role message + assert_eq!(post_compaction_msgs[1].role, "user"); + assert!( + post_compaction_msgs[1].content.as_ref().unwrap().text().contains("[Context summary from earlier"), + "Summary message should contain context summary prefix" + ); + } + + // ════════════════════════════════════════════ + // with_initial_context: messages appear in LLM call and are persisted + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_initial_context_appears_in_llm_call() { + use crate::persistence::MockPersister; + + let mock = Arc::new(CapturingLlm { + responses: Mutex::new(VecDeque::from(vec![ + Ok(text_response("I see the context!")), + ])), + captured: Mutex::new(Vec::new()), + }); + + let persister = Arc::new(MockPersister::new()); + let (tx, rx) = mpsc::channel(256); + let config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + + let mock_box: Box = Box::new(ArcLlm(Arc::clone(&mock) as Arc)); + let mut agent = AgentLoop::with_provider( + config, + mock_box, + ToolRegistry::new(), + CancellationToken::new(), + tx, + "test-ctx".into(), + ) + .with_persister(Arc::clone(&persister) as Arc, Some("thread-ctx".into())) + .with_initial_context(vec![ + ChatMessage::user("What does main.rs do?"), + ChatMessage::assistant(Some("It starts the server.".into()), None, None), + ]); + + let result = agent.run(ChatMessage::user("Now fix the bug")).await.unwrap().unwrap_done(); + assert_eq!(result, "I see the context!"); + drop(rx); + + // Verify the LLM received the initial context messages + let captured = mock.captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + let msgs = &captured[0]; + // Should be: [user("What does main.rs do?"), assistant("It starts the server."), user("Now fix the bug")] + assert!(msgs.len() >= 3, "Expected at least 3 messages, got {}", msgs.len()); + assert_eq!(msgs[0].role, "user"); + assert_eq!(msgs[0].content.as_ref().unwrap().text(), "What does main.rs do?"); + assert_eq!(msgs[1].role, "assistant"); + assert_eq!(msgs[1].content.as_ref().unwrap().text(), "It starts the server."); + assert_eq!(msgs[2].role, "user"); + assert_eq!(msgs[2].content.as_ref().unwrap().text(), "Now fix the bug"); + + // Verify initial context was persisted + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let persisted = persister.messages(); + // Should have: context msg 1, context msg 2, user msg, assistant response = at least 4 + assert!( + persisted.len() >= 4, + "Expected at least 4 persisted messages (2 context + user + assistant), got {}", + persisted.len() + ); + // First two should be the context messages + assert_eq!(persisted[0].1.content, "What does main.rs do?"); + assert_eq!(persisted[1].1.content, "It starts the server."); + } + + // ════════════════════════════════════════════ + // Persistence ordering: messages arrive in submission order + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_persistence_ordering() { + use crate::persistence::MockPersister; + + let persister = Arc::new(MockPersister::new()); + + // LLM returns 3 parallel tool calls, then a text response + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_a", "echo", r#"{"message":"alpha"}"#), + ("call_b", "echo", r#"{"message":"beta"}"#), + ("call_c", "echo", r#"{"message":"gamma"}"#), + ])), + Ok(text_response("Done.")), + ]); + + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + echo_registry(), + CancellationToken::new(), + tx, + "test-order".into(), + ) + .with_persister(Arc::clone(&persister) as Arc, Some("thread-order".into())); + + let result = agent.run(ChatMessage::user("test ordering")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + drop(rx); + + // Let the persistence worker drain + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let persisted = persister.messages(); + // Expected order: user, assistant(tool_calls), tool_result(alpha), tool_result(beta), tool_result(gamma), assistant(Done) + let contents: Vec<&str> = persisted.iter().map(|(_, m)| m.content.as_str()).collect(); + assert!(persisted.len() >= 6, "Expected at least 6 persisted messages, got {}: {:?}", persisted.len(), contents); + + // Tool results should be in submission order (alpha, beta, gamma) + let tool_results: Vec<&str> = persisted.iter() + .filter(|(_, m)| m.message_type == crate::persistence::MessageType::ToolResult) + .map(|(_, m)| m.content.as_str()) + .collect(); + assert_eq!(tool_results, vec!["alpha", "beta", "gamma"], "Tool results should be in submission order"); + } + + // ════════════════════════════════════════════ + // Yield ordering: tool results are persisted before StartSession return + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_yield_persists_tool_results_before_returning() { + use crate::persistence::MockPersister; + use crate::tool::start_session::StartSessionTool; + + let persister = Arc::new(MockPersister::new()); + + // Create a real git repo for start_session validation + let git_dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git").args(["init"]).current_dir(git_dir.path()).output().unwrap(); + let git_path = git_dir.path().to_string_lossy().to_string(); + + // LLM calls start_session + let args = format!(r#"{{"project_path":"{}","task_summary":"fix bug"}}"#, git_path); + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_ss", "start_session", &args)), + ]); + + let mut registry = ToolRegistry::new(); + registry.register(Arc::new(StartSessionTool)); + + let (tx, rx) = mpsc::channel(256); + let config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + git_dir.path().to_path_buf(), + ); + + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + registry, + CancellationToken::new(), + tx, + "test-yield".into(), + ) + .with_persister(Arc::clone(&persister) as Arc, Some("thread-yield".into())); + + let result = agent.run(ChatMessage::user("fix the bug")).await.unwrap(); + drop(rx); + + // Should be StartSession + match &result { + AgentResult::StartSession { task_summary, .. } => { + assert!(task_summary.contains("fix bug"), "Got: {task_summary}"); + } + other => panic!("Expected StartSession, got {:?}", other), + } + + // Let persistence drain + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let persisted = persister.messages(); + // Should have: user msg, assistant(tool_call), tool_result = at least 3 + assert!( + persisted.len() >= 3, + "Expected at least 3 persisted messages (user + assistant + tool_result), got {}", + persisted.len() + ); + + // The tool result should be persisted (this was the original bug — it was skipped before) + let has_tool_result = persisted.iter().any(|(_, m)| m.message_type == crate::persistence::MessageType::ToolResult); + assert!(has_tool_result, "Tool result for start_session should be persisted"); + } + + // ════════════════════════════════════════════ + // Approval handler tests + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_execute_tool_with_approval_denied() { + use crate::approval::test_util::AutoDenyHandler; + + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message":"hello"}"#)), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + agent = agent.with_approval_handler(Arc::new(AutoDenyHandler { + reason: "not allowed".into(), + })); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + + // ToolEnd should show permission denied + let tool_end = events.iter().find_map(|e| { + if let AgentEvent::ToolEnd { success, summary, .. } = e { + Some((success, summary)) + } else { + None + } + }).expect("Should have ToolEnd event"); + assert!(!tool_end.0, "Tool should not have succeeded"); + assert!(tool_end.1.contains("Permission denied"), "Summary should mention permission denied, got: {}", tool_end.1); + } + + #[tokio::test] + async fn test_execute_tool_with_approval_approved() { + use crate::approval::test_util::AutoApproveHandler; + + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message":"pong"}"#)), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + agent = agent.with_approval_handler(Arc::new(AutoApproveHandler)); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + // Tool should have executed successfully + let tool_end = events.iter().find_map(|e| { + if let AgentEvent::ToolEnd { success, summary, .. } = e { + Some((*success, summary.clone())) + } else { + None + } + }).expect("Should have ToolEnd event"); + assert!(tool_end.0, "Tool should have succeeded"); + assert!(tool_end.1.contains("pong"), "Summary should contain echo output, got: {}", tool_end.1); + } + + #[tokio::test] + async fn test_execute_tool_without_handler_auto_approves() { + // No approval handler — current behavior preserved + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message":"pong"}"#)), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + // Explicitly NOT setting an approval handler + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + let tool_end = events.iter().find_map(|e| { + if let AgentEvent::ToolEnd { success, .. } = e { Some(*success) } else { None } + }).expect("Should have ToolEnd event"); + assert!(tool_end, "Tool should auto-approve when no handler is set"); + } + + #[tokio::test] + async fn test_approval_check_after_schema_validation() { + use crate::approval::test_util::RecordingApprovalHandler; + + // Send invalid args (missing required "message" field) + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{}"#)), + Ok(text_response("Done.")), + ]); + let handler = Arc::new(RecordingApprovalHandler::new()); + let (mut agent, _rx) = make_agent(mock, echo_registry(), None); + agent = agent.with_approval_handler(handler.clone()); + + let _result = agent.run(ChatMessage::user("test")).await.unwrap(); + + // Handler should NOT have been called — schema validation fails first + let calls = handler.calls.lock().unwrap(); + assert!(calls.is_empty(), "Approval should not be requested for invalid args, got: {:?}", *calls); + } + + #[tokio::test] + async fn test_parallel_tools_approval_independent() { + use crate::approval::test_util::RecordingApprovalHandler; + + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "echo", r#"{"message":"first"}"#), + ("call_2", "echo", r#"{"message":"second"}"#), + ])), + Ok(text_response("Done.")), + ]); + let handler = Arc::new(RecordingApprovalHandler::new()); + handler.queue_decision(crate::approval::ApprovalDecision::Approved); + handler.queue_decision(crate::approval::ApprovalDecision::Denied { + reason: Some("blocked".into()), + }); + + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + agent = agent.with_approval_handler(handler.clone()); + + let _result = agent.run(ChatMessage::user("test")).await.unwrap(); + + let events = collect_events(&mut rx); + let tool_ends: Vec<(String, bool)> = events + .iter() + .filter_map(|e| { + if let AgentEvent::ToolEnd { tool_call_id, success, .. } = e { + Some((tool_call_id.clone(), *success)) + } else { + None + } + }) + .collect(); + + assert_eq!(tool_ends.len(), 2, "Should have 2 ToolEnd events"); + + // Both calls were made to the handler + let calls = handler.calls.lock().unwrap(); + assert_eq!(calls.len(), 2, "Handler should have been called for both tools"); + + // One succeeded, one failed + let successes: Vec = tool_ends.iter().map(|(_, s)| *s).collect(); + assert!(successes.contains(&true), "One tool should have succeeded"); + assert!(successes.contains(&false), "One tool should have been denied"); + } + + // ════════════════════════════════════════════ + // TurnCompleted event + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_turn_completed_event_fires_per_iteration() { + let mock = MockLlm::new(vec![ + Ok(tool_call_response("call_1", "echo", r#"{"message":"turn1"}"#)), + Ok(tool_call_response("call_2", "echo", r#"{"message":"turn2"}"#)), + Ok(text_response("Done.")), + ]); + let (mut agent, mut rx) = make_agent(mock, echo_registry(), None); + + let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done(); + assert_eq!(result, "Done."); + + let events = collect_events(&mut rx); + let turn_events: Vec = events + .iter() + .filter_map(|e| { + if let AgentEvent::TurnCompleted { turn_count, .. } = e { + Some(*turn_count) + } else { + None + } + }) + .collect(); + + assert_eq!(turn_events.len(), 2, "Should have 2 TurnCompleted events"); + assert_eq!(turn_events[0], 1); + assert_eq!(turn_events[1], 2); + } + + #[tokio::test] + async fn test_turn_completed_carries_modified_files() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("test.txt"); + let file_path_str = file_path.to_str().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "call_1", + "write", + &format!(r#"{{"filePath":"{}","content":"hello"}}"#, file_path_str), + )), + Ok(text_response("Done.")), + ]); + + let reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-turn".into(), + ); + + agent.run(ChatMessage::user("write a file")).await.unwrap(); + + let events = collect_events(&mut rx); + let turn_event = events.iter().find_map(|e| { + if let AgentEvent::TurnCompleted { modified_files, .. } = e { + Some(modified_files.clone()) + } else { + None + } + }); + + let modified = turn_event.expect("Should have TurnCompleted event"); + assert!( + modified.iter().any(|f| f == file_path_str), + "modified_files should contain {}, got: {:?}", + file_path_str, + modified + ); + } + + #[tokio::test] + async fn test_turn_completed_empty_modified_files_for_read_only() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("existing.txt"); + std::fs::write(&file_path, "existing content").unwrap(); + let file_path_str = file_path.to_str().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "call_1", + "read", + &format!(r#"{{"filePath":"{}"}}"#, file_path_str), + )), + Ok(text_response("Done.")), + ]); + + let reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-turn-ro".into(), + ); + + agent.run(ChatMessage::user("read a file")).await.unwrap(); + + let events = collect_events(&mut rx); + let turn_event = events.iter().find_map(|e| { + if let AgentEvent::TurnCompleted { modified_files, .. } = e { + Some(modified_files.clone()) + } else { + None + } + }); + + let modified = turn_event.expect("Should have TurnCompleted event"); + assert!(modified.is_empty(), "modified_files should be empty for read-only, got: {:?}", modified); + } + + #[tokio::test] + async fn test_no_turn_completed_on_text_only_response() { + let mock = MockLlm::new(vec![Ok(text_response("Hello!"))]); + let (mut agent, mut rx) = make_agent(mock, ToolRegistry::new(), None); + + agent.run(ChatMessage::user("Hi")).await.unwrap(); + + let events = collect_events(&mut rx); + let has_turn_completed = events.iter().any(|e| matches!(e, AgentEvent::TurnCompleted { .. })); + assert!(!has_turn_completed, "Should not emit TurnCompleted on text-only response"); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Done { .. }))); + } + + #[tokio::test] + async fn test_turn_completed_accumulates_parallel_tool_files() { + let tmp = tempfile::tempdir().unwrap(); + let file_a = tmp.path().join("a.txt"); + let file_b = tmp.path().join("b.txt"); + let path_a = file_a.to_str().unwrap(); + let path_b = file_b.to_str().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "write", &format!(r#"{{"filePath":"{}","content":"aaa"}}"#, path_a)), + ("call_2", "write", &format!(r#"{{"filePath":"{}","content":"bbb"}}"#, path_b)), + ])), + Ok(text_response("Done.")), + ]); + + let reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-turn-parallel".into(), + ); + + agent.run(ChatMessage::user("write two files")).await.unwrap(); + + let events = collect_events(&mut rx); + let turn_event = events.iter().find_map(|e| { + if let AgentEvent::TurnCompleted { modified_files, .. } = e { + Some(modified_files.clone()) + } else { + None + } + }); + + let modified = turn_event.expect("Should have TurnCompleted event"); + assert_eq!(modified.len(), 2, "Should have 2 modified files, got: {:?}", modified); + assert!(modified.contains(&path_a.to_string()), "Should contain {}", path_a); + assert!(modified.contains(&path_b.to_string()), "Should contain {}", path_b); + } + + #[tokio::test] + async fn test_turn_completed_fires_on_cancelled_turn_with_modified_files() { + // A write tool + cancel trigger run in parallel. + // The write modifies a file, then cancellation fires. + // TurnCompleted should still be emitted with the modified file. + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("modified.txt"); + let file_path_str = file_path.to_str().unwrap(); + + let mut reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + reg.register(Arc::new(CancelTriggerTool)); + + let mock = MockLlm::new(vec![ + Ok(multi_tool_call_response(vec![ + ("call_1", "write", &format!(r#"{{"filePath":"{}","content":"data"}}"#, file_path_str)), + ("call_2", "cancel_trigger", "{}"), + ])), + ]); + + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-cancel-turn".into(), + ); + + let result = agent.run(ChatMessage::user("write and cancel")).await; + assert!(matches!(result, Err(AgentError::Cancelled))); + + let events = collect_events(&mut rx); + let turn_event = events.iter().find_map(|e| { + if let AgentEvent::TurnCompleted { modified_files, .. } = e { + Some(modified_files.clone()) + } else { + None + } + }); + + let modified = turn_event.expect("TurnCompleted should fire even on cancelled turn"); + assert!( + modified.iter().any(|f| f == file_path_str), + "modified_files should contain {}, got: {:?}", + file_path_str, + modified + ); + } + + #[tokio::test] + async fn test_turn_completed_excludes_failed_write() { + // Write to a path that will fail (directory that doesn't exist and can't be created + // because we use a path with a null byte which is invalid). + let tmp = tempfile::tempdir().unwrap(); + let bad_path = "/nonexistent_root_dir_xyz/impossible/file.txt"; + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "call_1", + "write", + &format!(r#"{{"filePath":"{}","content":"data"}}"#, bad_path), + )), + Ok(text_response("Done.")), + ]); + + let reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-failed-write".into(), + ); + + agent.run(ChatMessage::user("write to bad path")).await.unwrap(); + + let events = collect_events(&mut rx); + let turn_event = events.iter().find_map(|e| { + if let AgentEvent::TurnCompleted { modified_files, .. } = e { + Some(modified_files.clone()) + } else { + None + } + }); + + let modified = turn_event.expect("Should have TurnCompleted event"); + assert!( + modified.is_empty(), + "Failed write should not appear in modified_files, got: {:?}", + modified + ); + } + + #[tokio::test] + async fn test_turn_completed_resets_between_turns() { + // Turn 1 writes file_a, turn 2 writes file_b. + // Each TurnCompleted should only contain that turn's file. + let tmp = tempfile::tempdir().unwrap(); + let file_a = tmp.path().join("a.txt"); + let file_b = tmp.path().join("b.txt"); + let path_a = file_a.to_str().unwrap(); + let path_b = file_b.to_str().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "call_1", + "write", + &format!(r#"{{"filePath":"{}","content":"aaa"}}"#, path_a), + )), + Ok(tool_call_response( + "call_2", + "write", + &format!(r#"{{"filePath":"{}","content":"bbb"}}"#, path_b), + )), + Ok(text_response("Done.")), + ]); + + let reg = ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None); + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-turn-reset".into(), + ); + + agent.run(ChatMessage::user("write files across turns")).await.unwrap(); + + let events = collect_events(&mut rx); + let turn_events: Vec<(u32, Vec)> = events + .iter() + .filter_map(|e| { + if let AgentEvent::TurnCompleted { turn_count, modified_files, .. } = e { + Some((*turn_count, modified_files.clone())) + } else { + None + } + }) + .collect(); + + assert_eq!(turn_events.len(), 2, "Should have 2 TurnCompleted events"); + + // Turn 1 should only have file_a + assert_eq!(turn_events[0].0, 1); + assert_eq!(turn_events[0].1, vec![path_a.to_string()], "Turn 1 should only contain a.txt"); + + // Turn 2 should only have file_b + assert_eq!(turn_events[1].0, 2); + assert_eq!(turn_events[1].1, vec![path_b.to_string()], "Turn 2 should only contain b.txt"); + } + + // ── Doom loop detection tests ── + + /// A tool that always returns an error. + struct FailTool; + + #[async_trait::async_trait] + impl Tool for FailTool { + fn name(&self) -> &str { "fail" } + fn description(&self) -> &str { "Always fails" } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + async fn execute( + &self, + _args: serde_json::Value, + _ctx: &ToolContext, + ) -> Result { + Ok(ToolResult::error("This tool always fails.")) + } + } + + #[tokio::test] + async fn test_doom_loop_injects_hint_after_3_failures() { + // LLM calls the fail tool 4 times (3 failures trigger hint, then 1 more), then returns text. + let mock = MockLlm::new(vec![ + Ok(tool_call_response("c1", "fail", "{}")), + Ok(tool_call_response("c2", "fail", "{}")), + Ok(tool_call_response("c3", "fail", "{}")), + // After doom loop hint injected, LLM gets one more chance + Ok(text_response("I give up.")), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(FailTool)); + + let (mut agent, mut rx) = make_agent(mock, reg, Some(10)); + let result = agent.run(ChatMessage::user("do something")).await.unwrap(); + + // Should complete normally (not error out) + let summary = result.unwrap_done(); + assert!(summary.contains("give up")); + + // Should have emitted an Error event about doom loop + let events = collect_events(&mut rx); + let doom_event = events.iter().any(|e| { + if let AgentEvent::Error { message, .. } = e { + message.contains("Doom loop") + } else { + false + } + }); + assert!(doom_event, "Should have doom loop error event"); + + // Verify the hint system message was injected (LLM received it on the 4th call) + // The LLM returned text after the hint, which means it saw the hint message. + } + + #[tokio::test] + async fn test_doom_loop_resets_on_success() { + // 2 failures, then 1 success (resets counter), then 2 more failures → no doom loop + let mock = MockLlm::new(vec![ + Ok(tool_call_response("c1", "fail", "{}")), // fail 1 + Ok(tool_call_response("c2", "fail", "{}")), // fail 2 + Ok(tool_call_response("c3", "echo", r#"{"message":"ok"}"#)), // success → resets + Ok(tool_call_response("c4", "fail", "{}")), // fail 1 (reset) + Ok(tool_call_response("c5", "fail", "{}")), // fail 2 + Ok(text_response("Done.")), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(FailTool)); + reg.register(Arc::new(EchoTool)); + + let (mut agent, mut rx) = make_agent(mock, reg, Some(10)); + agent.run(ChatMessage::user("test")).await.unwrap(); + + // Should NOT have doom loop event (never hit 3 consecutive) + let events = collect_events(&mut rx); + let doom_event = events.iter().any(|e| { + if let AgentEvent::Error { message, .. } = e { + message.contains("Doom loop") + } else { + false + } + }); + assert!(!doom_event, "Should NOT have doom loop event — success reset the counter"); + } + + // ── AskUser yield tests ── + + #[tokio::test] + async fn test_ask_user_yield_returns_result() { + use crate::tool::ask_user::AskUserTool; + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "c1", + "ask_user", + r#"{"question":"Which database?","options":["Postgres","MySQL"]}"#, + )), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(AskUserTool)); + + let (mut agent, mut rx) = make_agent(mock, reg, Some(10)); + let result = agent.run(ChatMessage::user("help me choose")).await.unwrap(); + + // Should yield AskUser, not Done + match result { + AgentResult::AskUser { question, options } => { + assert!(question.contains("database"), "Got: {question}"); + assert_eq!(options.as_ref().unwrap().len(), 2); + } + other => panic!("Expected AskUser, got {:?}", other), + } + + // Should have emitted UserQuestionAsked event + let events = collect_events(&mut rx); + let question_event = events.iter().any(|e| { + matches!(e, AgentEvent::UserQuestionAsked { .. }) + }); + assert!(question_event, "Should have UserQuestionAsked event"); + } + + // ── Truncation handling tests ── + + /// Tool that returns output exceeding the 2000-line / 50KB limit. + struct HugeOutputTool; + + #[async_trait::async_trait] + impl Tool for HugeOutputTool { + fn name(&self) -> &str { "huge" } + fn description(&self) -> &str { "Returns huge output" } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object"}) + } + async fn execute( + &self, + _args: serde_json::Value, + _ctx: &ToolContext, + ) -> Result { + // Generate 3000 lines (exceeds 2000 line limit) + let output: String = (0..3000) + .map(|i| format!("line {}: {}", i, "x".repeat(50))) + .collect::>() + .join("\n"); + Ok(ToolResult::success(output)) + } + } + + #[tokio::test] + async fn test_truncation_saves_full_output_to_file() { + let tmp = tempfile::tempdir().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response("c1", "huge", "{}")), + Ok(text_response("Done.")), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(HugeOutputTool)); + + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-truncation".into(), + ); + + agent.run(ChatMessage::user("generate huge output")).await.unwrap(); + + // Check that the tool output file was created + let tool_output_dir = tmp.path().join(".agent").join("tool-output"); + assert!(tool_output_dir.exists(), ".agent/tool-output/ should exist"); + + let files: Vec<_> = std::fs::read_dir(&tool_output_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(files.len(), 1, "Should have exactly one truncated output file"); + + // The saved file should contain the full 3000 lines + let saved_content = std::fs::read_to_string(files[0].path()).unwrap(); + let saved_lines = saved_content.lines().count(); + assert!(saved_lines >= 3000, "Saved file should have full output, got {} lines", saved_lines); + + // The tool result in the LLM context should mention the file path + let events = collect_events(&mut rx); + let tool_end = events.iter().find_map(|e| { + if let AgentEvent::ToolEnd { summary, .. } = e { + Some(summary.clone()) + } else { + None + } + }); + assert!(tool_end.is_some(), "Should have ToolEnd event"); + // The summary is truncated at 200 chars, but the actual tool result sent to LLM + // contains "Full output saved to" — we verify via the file existence above. + } + + // ── Edit strategy tests (via tool execution) ── + + #[tokio::test] + async fn test_edit_whitespace_normalized_strategy() { + let tmp = tempfile::tempdir().unwrap(); + // Create file with extra whitespace — fuzzy matching needed + std::fs::write( + tmp.path().join("test.rs"), + "fn main() {\n println!( \"hello\" );\n}\n", + ).unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "c1", + "edit", + &serde_json::json!({ + "filePath": "test.rs", + "oldString": "fn main() {\n println!( \"hello\" );\n}", + "newString": "fn main() {\n println!(\"world\");\n}" + }).to_string(), + )), + Ok(text_response("Done.")), + ]); + + let (tx, _rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + ToolRegistry::for_mode(crate::tool::ToolMode::Coding, None, None), + CancellationToken::new(), + tx, + "test-edit-ws".into(), + ); + + agent.run(ChatMessage::user("fix the file")).await.unwrap(); + + let content = std::fs::read_to_string(tmp.path().join("test.rs")).unwrap(); + assert!(content.contains("world"), "Edit should have applied. Content: {content}"); + } + + // ── save_plan yield test ── + + #[tokio::test] + async fn test_save_plan_yield_returns_plan_ready() { + use crate::tool::save_plan::SavePlanTool; + + let tmp = tempfile::tempdir().unwrap(); + + let mock = MockLlm::new(vec![ + Ok(tool_call_response( + "c1", + "save_plan", + &serde_json::json!({"plan": "## Goal\nFix the bug\n## Steps\n1. Read file\n2. Edit file", "filename": "plan-fix-bug.md"}).to_string(), + )), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(SavePlanTool)); + + let (tx, mut rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + tmp.path().to_path_buf(), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + let mut agent = AgentLoop::with_provider( + config, + Box::new(mock), + reg, + CancellationToken::new(), + tx, + "test-save-plan".into(), + ); + + let result = agent.run(ChatMessage::user("make a plan")).await.unwrap(); + + // Should yield PlanReady + match result { + AgentResult::PlanReady { plan, plan_path } => { + assert!(plan.contains("Fix the bug"), "Plan should contain goal. Got: {plan}"); + assert!(plan_path.contains("plan-fix-bug.md"), "Path should use the filename. Got: {plan_path}"); + } + other => panic!("Expected PlanReady, got {:?}", other), + } + + // File should exist on disk + let plan_file = tmp.path().join(".agent").join("plan-fix-bug.md"); + assert!(plan_file.exists(), "Plan file should be written to disk"); + + // Should have PlanReady event + let events = collect_events(&mut rx); + let plan_event = events.iter().any(|e| { + matches!(e, AgentEvent::PlanReady { .. }) + }); + assert!(plan_event, "Should have PlanReady event"); + } + + // ══════════════════════════════════════════════ + // Compaction fix tests + // ══════════════════════════════════════════════ + + /// Helper: make an agent with tiny compaction thresholds for testing. + fn make_compact_agent( + mock: MockLlm, + registry: ToolRegistry, + ) -> (AgentLoop, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(256); + let mut config = AgentConfig::new( + crate::llm::LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + std::path::PathBuf::from("/tmp"), + ); + config.max_iterations = 20; + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + // Tiny context: 200 tokens, 80% threshold = 160 tokens + config.compaction_config.context_limit = 200; + config.compaction_config.threshold_pct = 0.80; + config.compaction_config.keep_recent_messages = 2; + + let agent = AgentLoop::with_provider( + config, + Box::new(mock), + registry, + CancellationToken::new(), + tx, + "test-compact".into(), + ); + (agent, rx) + } + + /// Build a seeded context with clean compaction boundaries. + /// Returns messages: [system, user, assistant_text, user, assistant_text, user, assistant_text] + /// The text-only assistant messages create clean snap boundaries for compaction. + fn compactable_context() -> Vec { + vec![ + ChatMessage::user("question 1"), + ChatMessage::assistant(Some("answer 1".into()), None, None), + ChatMessage::user("question 2"), + ChatMessage::assistant(Some("answer 2".into()), None, None), + ChatMessage::user("question 3"), + ChatMessage::assistant(Some("answer 3".into()), None, None), + ] + } + + #[tokio::test] + async fn test_post_tool_compaction_triggers_on_high_token_usage() { + // Seed with 6 context messages (clean boundaries), then one tool-call turn + // reports 170 tokens (above 160 threshold) → post-tool compaction fires. + let mock = MockLlm::new(vec![ + // Turn 1: high token usage → triggers post-tool compaction + Ok(tool_call_response_with_usage("c1", "echo", r#"{"message":"hi"}"#, 170)), + // Compaction LLM call → returns summary + Ok(text_response("Summary of conversation so far.")), + // Turn 2: after compaction, LLM finishes + Ok(text_response("Done.")), + ]); + + let (mut agent, mut rx) = make_compact_agent(mock, echo_registry()); + agent = agent.with_initial_context(compactable_context()); + agent.run(ChatMessage::user("now do something")).await.unwrap(); + + let events = collect_events(&mut rx); + let compaction_fired = events.iter().any(|e| matches!(e, AgentEvent::Compaction { .. })); + assert!(compaction_fired, "Compaction should have fired from post-tool check"); + } + + #[tokio::test] + async fn test_pre_llm_compaction_triggers_on_large_tool_output() { + // Seed with context. Turn 1 reports low tokens (50), but HugeOutputTool + // dumps massive text. Pre-LLM check before turn 2 estimates + // 50 + huge_output/4 > threshold → compaction fires BEFORE the LLM call. + let mock = MockLlm::new(vec![ + // Turn 1: low token usage, calls huge output tool + Ok(tool_call_response_with_usage("c1", "huge", "{}", 50)), + // Compaction LLM call (triggered by pre-LLM check before turn 2) + Ok(text_response("Summary after huge output.")), + // Turn 2: after compaction + Ok(text_response("Done.")), + ]); + + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(HugeOutputTool)); + + let (mut agent, mut rx) = make_compact_agent(mock, reg); + agent = agent.with_initial_context(compactable_context()); + agent.run(ChatMessage::user("generate huge output")).await.unwrap(); + + let events = collect_events(&mut rx); + let compaction_fired = events.iter().any(|e| matches!(e, AgentEvent::Compaction { .. })); + assert!(compaction_fired, "Compaction should have fired from pre-LLM check due to large tool output"); + } + + #[tokio::test] + async fn test_compaction_cooldown_prevents_immediate_refire() { + // Compaction fires on turn 1. Turns 2-3 report high tokens but cooldown + // prevents compaction from firing again. The agent should complete normally + // without consuming extra compaction LLM responses (proving cooldown worked). + let mock = MockLlm::new(vec![ + // Turn 1: high usage → triggers compaction + Ok(tool_call_response_with_usage("c1", "echo", r#"{"message":"a"}"#, 170)), + // Compaction LLM call + Ok(text_response("Summary.")), + // Turn 2: high usage — cooldown blocks compaction (no compaction LLM call consumed) + Ok(tool_call_response_with_usage("c2", "echo", r#"{"message":"b"}"#, 170)), + // Turn 3: still cooling down (no compaction LLM call consumed) + Ok(text_response("Done.")), + ]); + + let (mut agent, mut rx) = make_compact_agent(mock, echo_registry()); + agent = agent.with_initial_context(compactable_context()); + agent.run(ChatMessage::user("keep going")).await.unwrap(); + + let events = collect_events(&mut rx); + let compaction_count = events.iter().filter(|e| matches!(e, AgentEvent::Compaction { .. })).count(); + // Compaction fires exactly once — if cooldown didn't work, the agent would + // try to consume a compaction LLM response that doesn't exist and panic. + assert_eq!(compaction_count, 1, "Compaction should fire exactly once — cooldown blocks turn 2"); + } + + #[tokio::test] + async fn test_compaction_fallback_truncation_on_llm_failure() { + // Compaction LLM call fails twice → fallback to aggressive truncation. + // The agent should survive and continue. + use crate::error::AgentError; + + let mock = MockLlm::new(vec![ + // Turn 1: high usage → triggers compaction + Ok(tool_call_response_with_usage("c1", "echo", r#"{"message":"hi"}"#, 170)), + // Compaction LLM attempt 1 → fails + Err(AgentError::LlmParseError("compaction failed".into())), + // Compaction LLM attempt 2 (retry) → fails again + Err(AgentError::LlmParseError("compaction failed again".into())), + // After fallback truncation, turn 2: agent finishes + Ok(text_response("Done after truncation.")), + ]); + + let (mut agent, mut rx) = make_compact_agent(mock, echo_registry()); + agent = agent.with_initial_context(compactable_context()); + let result = agent.run(ChatMessage::user("test")).await.unwrap(); + + let summary = result.unwrap_done(); + assert!(summary.contains("truncation"), "Agent should finish after fallback truncation"); + + let events = collect_events(&mut rx); + let compaction_fired = events.iter().any(|e| matches!(e, AgentEvent::Compaction { .. })); + assert!(compaction_fired, "Compaction event should fire even for fallback truncation"); + } + + // ════════════════════════════════════════════ + // strip_orphaned_tool_results tests (Bug 3 fix) + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_strip_removes_orphan_tool_results() { + let mock = MockLlm::new(vec![Ok(text_response("done"))]); + let (mut agent, _rx) = make_agent(mock, echo_registry(), None); + + // Set up: assistant has tc1, but a stray tool_result for tc_orphan also exists + let tc = vec![ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "echo".into(), arguments: "{}".into() }, + }]; + agent.messages = vec![ + ChatMessage::user("hi"), + ChatMessage::assistant(None, Some(tc), None), + ChatMessage::tool_result("tc1", "valid result"), + ChatMessage::tool_result("tc_orphan", "orphaned result"), + ]; + + agent.strip_orphaned_tool_results(); + + // Orphan removed, valid result kept, assistant tool_calls intact + assert_eq!(agent.messages.len(), 3); + assert!(agent.messages.iter().all(|m| m.tool_call_id.as_deref() != Some("tc_orphan"))); + let assistant = agent.messages.iter().find(|m| m.role == "assistant").unwrap(); + assert!(assistant.tool_calls.is_some(), "valid tool_calls should be preserved"); + } + + #[tokio::test] + async fn test_strip_clears_dangling_tool_calls_when_all_results_missing() { + let mock = MockLlm::new(vec![Ok(text_response("done"))]); + let (mut agent, _rx) = make_agent(mock, echo_registry(), None); + + // Assistant declared two tool_calls but neither has a tool_result — + // this is the scenario that produces "tool_use without tool_result" errors. + let tc = vec![ + ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "echo".into(), arguments: "{}".into() }, + }, + ToolCall { + id: "tc2".into(), + type_: "function".into(), + function: FunctionCall { name: "echo".into(), arguments: "{}".into() }, + }, + ]; + agent.messages = vec![ + ChatMessage::user("hi"), + ChatMessage::assistant(Some("partial".into()), Some(tc), None), + ]; + + agent.strip_orphaned_tool_results(); + + // tool_calls field should be cleared entirely (None), turning the + // assistant message into a plain text turn. + let assistant = agent.messages.iter().find(|m| m.role == "assistant").unwrap(); + assert!( + assistant.tool_calls.is_none(), + "assistant.tool_calls should be cleared when all results are missing" + ); + // Content is preserved + assert!(assistant.content.is_some()); + } + + #[tokio::test] + async fn test_strip_partial_clears_only_unanswered_calls() { + let mock = MockLlm::new(vec![Ok(text_response("done"))]); + let (mut agent, _rx) = make_agent(mock, echo_registry(), None); + + // Assistant has tc1 (answered) and tc2 (unanswered) + let tc = vec![ + ToolCall { + id: "tc1".into(), + type_: "function".into(), + function: FunctionCall { name: "echo".into(), arguments: "{}".into() }, + }, + ToolCall { + id: "tc2".into(), + type_: "function".into(), + function: FunctionCall { name: "echo".into(), arguments: "{}".into() }, + }, + ]; + agent.messages = vec![ + ChatMessage::user("hi"), + ChatMessage::assistant(None, Some(tc), None), + ChatMessage::tool_result("tc1", "ok"), + ]; + + agent.strip_orphaned_tool_results(); + + let assistant = agent.messages.iter().find(|m| m.role == "assistant").unwrap(); + let tcs = assistant.tool_calls.as_ref().expect("tc1 is answered, field stays"); + assert_eq!(tcs.len(), 1, "only tc1 should remain"); + assert_eq!(tcs[0].id, "tc1"); + } + + #[tokio::test] + async fn test_estimate_token_count() { + // Verify the chars/4 estimate is in a reasonable ballpark + let messages = vec![ + ChatMessage::system("You are helpful."), // 16 chars → ~4 tokens + 4 overhead = 8 + ChatMessage::user("Hello world test"), // 16 chars → ~4 tokens + 4 overhead = 8 + ]; + let estimate = compaction::estimate_token_count(&messages); + assert!(estimate > 0, "Estimate should be positive"); + assert!(estimate < 100, "Estimate for 2 short messages should be small, got {}", estimate); + + // Large message should produce proportionally larger estimate + let big_content = "x".repeat(4000); // 4000 chars → ~1000 tokens + let big_messages = vec![ChatMessage::user(&big_content)]; + let big_estimate = compaction::estimate_token_count(&big_messages); + assert!(big_estimate >= 1000, "4000 chars should estimate to at least 1000 tokens, got {}", big_estimate); + } +} diff --git a/crates/agent/src/agent/mod.rs b/crates/agent/src/agent/mod.rs new file mode 100644 index 00000000..d0f60085 --- /dev/null +++ b/crates/agent/src/agent/mod.rs @@ -0,0 +1,95 @@ +pub mod config; +pub mod loop_; +pub mod prompt; +pub mod compaction; +pub mod worktree; +pub mod model_profile; + +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::approval::ApprovalHandler; +use crate::error::AgentError; +use crate::llm::types::ChatMessage; +use crate::persistence::MessagePersister; +use crate::tool::ToolRegistry; +use crate::types::{AgentEvent, AgentResult}; +pub use config::AgentConfig; +pub use loop_::AgentLoop; + +/// Handle returned by `spawn_agent` with everything needed to interact with a running session. +pub struct SpawnedAgent { + /// Task handle — await for the final AgentResult or error. + pub handle: JoinHandle>, + /// Receiver for streaming UI events (TextDelta, ToolStart, ToolEnd, Done, Error). + pub event_rx: mpsc::Receiver, + /// Cancel token — call `.cancel()` to stop the agent. + pub cancel_token: CancellationToken, + /// Session ID embedded in every emitted event, for UI correlation. + pub session_id: String, +} + +/// Spawn an agent loop as a background tokio task. +pub fn spawn_agent( + mut config: AgentConfig, + user_message: ChatMessage, + persister: Option>, + thread_id: Option, + approval_handler: Option>, +) -> SpawnedAgent { + let (event_tx, event_rx) = mpsc::channel(256); + let cancel_token = CancellationToken::new(); + let session_id = uuid::Uuid::new_v4().to_string(); + + let context_engine_arg = config.context_engine.as_ref().map(|engine| { + let repo_path = config.context_engine_repo_path.clone().unwrap_or_else(|| { + log::warn!( + "context_engine_repo_path not set; falling back to working_dir. \ + Worktree overlay will be disabled." + ); + config.working_dir.clone() + }); + (engine.clone(), repo_path) + }); + let skills_arg = config.skills.clone(); + let mut registry = ToolRegistry::for_mode(config.mode, context_engine_arg, skills_arg); + if let (Some(sub_reg), Some(inherit)) = (config.subagents.clone(), config.subagent_inheritance.clone()) { + registry.register_spawn_subagent(sub_reg, inherit); + } + + // Set default system prompt if none provided — uses mode from config + if config.system_prompt.is_none() { + config.system_prompt = Some(prompt::build_system_prompt( + config.mode, + &config.working_dir, + None, + None, + config.skills.as_deref(), + config.subagents.as_deref(), + )); + } + + let handle = { + let cancel_token = cancel_token.clone(); + let session_id = session_id.clone(); + tokio::spawn(async move { + let mut agent_loop = AgentLoop::new(config, registry, cancel_token, event_tx, session_id); + if let Some(p) = persister { + agent_loop = agent_loop.with_persister(p, thread_id); + } + if let Some(h) = approval_handler { + agent_loop = agent_loop.with_approval_handler(h); + } + agent_loop.run(user_message).await + }) + }; + + SpawnedAgent { + handle, + event_rx, + cancel_token, + session_id, + } +} diff --git a/crates/agent/src/agent/model_profile.rs b/crates/agent/src/agent/model_profile.rs new file mode 100644 index 00000000..7f8a849e --- /dev/null +++ b/crates/agent/src/agent/model_profile.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +/// Minimal model metadata — only what the agent crate needs for correct behavior. +/// Display info (display_name, provider) is for the frontend model picker. +/// The only field that affects agent logic is `context_window` (drives compaction). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelProfile { + pub id: String, + pub display_name: String, + pub provider: String, + pub context_window: usize, +} + +/// Registry of known model profiles. +/// Initialized with hardcoded defaults, optionally refreshed from the gateway. +pub struct ModelRegistry { + profiles: HashMap, +} + +impl ModelRegistry { + /// Create a registry with hardcoded fallback profiles. + pub fn with_defaults() -> Self { + let profiles = default_profiles() + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect(); + Self { profiles } + } + + /// Create an empty registry (for tests). + pub fn empty() -> Self { + Self { profiles: HashMap::new() } + } + + /// Merge profiles from a gateway `GET /v1/models` response. + /// Overwrites existing entries by ID, adds new ones. + pub fn merge(&mut self, models: Vec) { + for m in models { + self.profiles.insert(m.id.clone(), m); + } + } + + /// Look up a profile by exact model ID. + pub fn get(&self, model_id: &str) -> Option<&ModelProfile> { + self.profiles.get(model_id) + } + + /// Get the context window for a model, with a conservative fallback for unknown models. + pub fn context_window_for(&self, model_id: &str) -> usize { + self.get(model_id) + .map(|p| p.context_window) + .unwrap_or(DEFAULT_CONTEXT_WINDOW) + } + + /// List all profiles (for frontend model picker). + pub fn list(&self) -> Vec<&ModelProfile> { + let mut profiles: Vec<_> = self.profiles.values().collect(); + profiles.sort_by(|a, b| a.id.cmp(&b.id)); + profiles + } +} + +/// Conservative fallback context window for unknown models. +const DEFAULT_CONTEXT_WINDOW: usize = 128_000; + +/// Gateway response wrapper for deserialization. +#[derive(Debug, Deserialize)] +pub struct ModelsResponse { + pub models: Vec, +} + +fn default_profiles() -> Vec { + vec![ + // Anthropic + ModelProfile { + id: "claude-sonnet-4-6".into(), + display_name: "Claude Sonnet 4.6".into(), + provider: "anthropic".into(), + context_window: 200_000, + }, + ModelProfile { + id: "claude-opus-4-6".into(), + display_name: "Claude Opus 4.6".into(), + provider: "anthropic".into(), + context_window: 200_000, + }, + ModelProfile { + id: "claude-haiku-4-5-20251001".into(), + display_name: "Claude Haiku 4.5".into(), + provider: "anthropic".into(), + context_window: 200_000, + }, + // OpenAI + ModelProfile { + id: "gpt-4o".into(), + display_name: "GPT-4o".into(), + provider: "openai".into(), + context_window: 128_000, + }, + ModelProfile { + id: "gpt-4o-mini".into(), + display_name: "GPT-4o Mini".into(), + provider: "openai".into(), + context_window: 128_000, + }, + ModelProfile { + id: "o3".into(), + display_name: "O3".into(), + provider: "openai".into(), + context_window: 200_000, + }, + ModelProfile { + id: "o4-mini".into(), + display_name: "O4 Mini".into(), + provider: "openai".into(), + context_window: 200_000, + }, + ModelProfile { + id: "gpt-5.4-2026-03-05".into(), + display_name: "GPT-5.4".into(), + provider: "openai".into(), + context_window: 200_000, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_registry_has_all_models() { + let reg = ModelRegistry::with_defaults(); + assert!(reg.get("claude-sonnet-4-6").is_some()); + assert!(reg.get("claude-opus-4-6").is_some()); + assert!(reg.get("claude-haiku-4-5-20251001").is_some()); + assert!(reg.get("gpt-4o").is_some()); + assert!(reg.get("gpt-4o-mini").is_some()); + assert!(reg.get("o3").is_some()); + assert!(reg.get("o4-mini").is_some()); + assert!(reg.get("gpt-5.4-2026-03-05").is_some()); + } + + #[test] + fn test_get_known_model() { + let reg = ModelRegistry::with_defaults(); + let profile = reg.get("claude-sonnet-4-6").unwrap(); + assert_eq!(profile.context_window, 200_000); + assert_eq!(profile.provider, "anthropic"); + assert_eq!(profile.display_name, "Claude Sonnet 4.6"); + } + + #[test] + fn test_get_unknown_returns_none() { + let reg = ModelRegistry::with_defaults(); + assert!(reg.get("some-unknown-model").is_none()); + } + + #[test] + fn test_context_window_for_known() { + let reg = ModelRegistry::with_defaults(); + assert_eq!(reg.context_window_for("claude-sonnet-4-6"), 200_000); + assert_eq!(reg.context_window_for("gpt-4o-mini"), 128_000); + } + + #[test] + fn test_context_window_for_unknown_returns_fallback() { + let reg = ModelRegistry::with_defaults(); + assert_eq!(reg.context_window_for("mystery-model"), DEFAULT_CONTEXT_WINDOW); + } + + #[test] + fn test_merge_overwrites_existing() { + let mut reg = ModelRegistry::with_defaults(); + assert_eq!(reg.context_window_for("gpt-4o-mini"), 128_000); + + reg.merge(vec![ModelProfile { + id: "gpt-4o-mini".into(), + display_name: "GPT-4o Mini (Updated)".into(), + provider: "openai".into(), + context_window: 256_000, // gateway says it grew + }]); + + assert_eq!(reg.context_window_for("gpt-4o-mini"), 256_000); + assert_eq!(reg.get("gpt-4o-mini").unwrap().display_name, "GPT-4o Mini (Updated)"); + } + + #[test] + fn test_merge_adds_new() { + let mut reg = ModelRegistry::with_defaults(); + assert!(reg.get("gpt-5").is_none()); + + reg.merge(vec![ModelProfile { + id: "gpt-5".into(), + display_name: "GPT-5".into(), + provider: "openai".into(), + context_window: 1_000_000, + }]); + + assert_eq!(reg.context_window_for("gpt-5"), 1_000_000); + } + + #[test] + fn test_list_sorted_by_id() { + let reg = ModelRegistry::with_defaults(); + let list = reg.list(); + let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + assert_eq!(ids, sorted); + } + + #[test] + fn test_empty_registry() { + let reg = ModelRegistry::empty(); + assert!(reg.get("claude-sonnet-4-6").is_none()); + assert_eq!(reg.context_window_for("anything"), DEFAULT_CONTEXT_WINDOW); + assert!(reg.list().is_empty()); + } + + #[test] + fn test_models_response_deserialization() { + let json = r#"{ + "models": [ + { + "id": "test-model", + "display_name": "Test Model", + "provider": "test", + "context_window": 32000 + } + ] + }"#; + let resp: ModelsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.models.len(), 1); + assert_eq!(resp.models[0].id, "test-model"); + assert_eq!(resp.models[0].context_window, 32000); + } +} diff --git a/crates/agent/src/agent/plan_prompt.txt b/crates/agent/src/agent/plan_prompt.txt new file mode 100644 index 00000000..2c21d227 --- /dev/null +++ b/crates/agent/src/agent/plan_prompt.txt @@ -0,0 +1,101 @@ +You are a planning agent. Your job is to deeply understand the user's request, explore the codebase thoroughly, ask clarifying questions when needed, and produce a well-researched implementation plan. + +You have READ-ONLY access to the codebase. You CANNOT make any changes — no file edits, no shell commands, no git operations. You can only observe, analyze, and plan. + +When your plan is complete, present it as your final message. The user will review it and decide whether to implement it in a coding session. + +# Available Tools + +- `read` — Read file contents (line-numbered) or list directory entries. Use `offset`/`limit` for large files. +- `glob` — Find files by glob pattern. Returns up to 100 results sorted by mtime. Respects .gitignore. +- `grep` — Search file contents with regex. Returns up to 100 matches. Supports `include` filter. +- `ask_user` — Ask the user a clarifying question. Optionally provide choices. Use when the request is ambiguous or has multiple valid approaches. +- `save_plan` — Save your completed plan and present it to the user. Provide a descriptive `filename` (e.g. `plan-improve-auth-flow.md`). The plan is saved to `.agent/{filename}` and shown to the user with an "Implement" button. +- `edit_plan` — Make targeted edits to a plan file using find-and-replace. Provide the exact text to find and the replacement. If there is only one plan file in `.agent/`, it is auto-discovered. Otherwise specify `file_path`. Use for small revisions — for complete rewrites, use `save_plan` instead. +- `spawn_subagent` — Dispatch a specialist subagent (see "Default Subagents" below). + +# Default Subagents + +Four specialists are always available via `spawn_subagent`. Prefer spawning one when the subtask has a clear boundary and could eat 10+ of your own tool calls: + +- `code-explorer` — "how does X work" / mapping an unfamiliar area (great for fanning out codebase exploration before drafting a plan) +- `code-reviewer` — before declaring a change done (reads files + `git diff`) +- `code-architect` — "design/plan" this feature (produces a build blueprint — useful for complex subsections of a plan) +- `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) + +Spawn code-explorer and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). + +## User subagent mentions +If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. + +# How to Work + +1. **Explore thoroughly** — Before planning, read the relevant code. Use `glob` and `grep` to find ALL files involved. Don't plan based on assumptions — verify by reading the actual code. + +2. **Ask clarifying questions** — Use `ask_user` when: + - The request is ambiguous or has multiple valid interpretations + - There are multiple valid approaches with meaningful tradeoffs + - You need to understand constraints (performance, compatibility, scope) + - Ask all your questions at once rather than one at a time. + +3. **Understand conventions** — Before proposing changes, study existing patterns: + - How is similar code structured in the project? + - What naming conventions are used? + - What testing patterns exist? + - What dependencies are already available? + +4. **Think deeply** — Consider: + - Edge cases and error scenarios + - Backward compatibility impact + - Test coverage needed + - Performance implications + - Dependencies between changes + +5. **Produce a structured plan** — Build a clear, actionable plan. Your plan should include: + + ## Plan Structure + - **Goal**: One sentence describing what will be achieved + - **Files to modify**: List each file with a brief description of changes + - **Files to create**: Any new files needed + - **Implementation steps**: Ordered list with clear descriptions + - **Tests**: What tests to add or update + - **Risks/tradeoffs**: Anything the user should be aware of + + Be specific about file paths, function names, and exact changes needed. + +6. **Save the plan** — When your plan is complete, call `save_plan` with the full plan text in markdown format. This saves it to disk and presents it to the user. Do NOT just write the plan as a text response — you MUST call `save_plan` to finalize it. + +# Revising Plans +IMPORTANT: Before creating a new plan, ALWAYS check if a plan already exists by reading `.agent/` directory. + +If existing plans are found: +1. Use `read` on the `.agent/` directory to check for existing plan files +2. Use `read` to load the current plan content and understand its scope/goal +3. Compare the user's new request against the existing plan's goal: + - If the request is about the **SAME task/feature** — use `edit_plan` for targeted revisions (token-efficient) + - If the request is about a **DIFFERENT task/feature** — use `ask_user` to ask: "I found an existing plan for [existing goal]. Your request is about [new goal]. Should I revise the existing plan, or create a separate plan for this?" +4. For complete rewrites, use `save_plan` directly with the full new plan +5. After all edits, you MUST call `save_plan` with the final updated plan to present it to the user. `edit_plan` modifies the file on disk but does NOT update the UI — only `save_plan` triggers the plan card update. + +Prefer `edit_plan` over `save_plan` for revisions — it's cheaper on tokens and preserves existing plan structure. + +# Handling Large Output +When a tool result says "Full output saved to /path. Use the read tool to view specific sections": +- Do NOT re-run the command. The full output is already saved. +- Use `read` with `offset` and `limit` to examine specific sections of the saved file. + +# Professional Objectivity +Prioritize technical accuracy over validating the user's beliefs. If you find that the user's proposed approach has issues, say so directly with evidence. + +# Output +- Use markdown with clear headings for your plan. +- Be thorough but not verbose — every sentence should add information. +- List file paths as absolute paths. +- Reference existing code with `file_path:line_number` when relevant. +- Use mermaid diagrams (```mermaid code blocks) when they clarify architecture, data flows, or state transitions. The frontend renders them natively. Mermaid syntax rules you MUST follow: + - Always wrap node labels in double quotes: `A["My Label"]`, never `A[My Label]`. This is required whenever the label contains `/`, `:`, `(`, `)`, `,`, spaces, arrows, or any non-alphanumeric character. + - Never use `\n` for line breaks inside labels — use `
` instead: `A["line one
line two"]`. + - Node IDs must be alphanumeric with no spaces or special characters. + - Edge labels go between pipes: `A -->|edge text| B`. Quote them only if they contain `|` or other reserved chars. + - A label starting with `/` (e.g. `[/foo]`) is parsed as a parallelogram shape and will fail unless closed with `/]`. Always quote such labels: `["/foo"]`. +- Do not use emojis unless requested. diff --git a/crates/agent/src/agent/prompt.rs b/crates/agent/src/agent/prompt.rs new file mode 100644 index 00000000..54bfcc51 --- /dev/null +++ b/crates/agent/src/agent/prompt.rs @@ -0,0 +1,271 @@ +use std::path::Path; + +use crate::llm::types::CacheControl; +use crate::skills::SkillRegistry; +use crate::subagents::SubagentRegistry; +use crate::tool::ToolMode; + +/// Raw prompt templates embedded at compile time from .txt files. +/// Templates contain the STATIC body only (no env section) plus a few inline +/// placeholders like `{{working_dir}}` that are session-stable. +const ASK_PROMPT_TEMPLATE: &str = include_str!("ask_prompt.txt"); +const CODING_PROMPT_TEMPLATE: &str = include_str!("coding_prompt.txt"); +const PLAN_PROMPT_TEMPLATE: &str = include_str!("plan_prompt.txt"); + +/// One block of the system prompt. Anthropic sends these in order; each block +/// can independently carry a `cache_control` marker. +#[derive(Debug, Clone)] +pub struct SystemBlock { + pub text: String, + pub cache_control: Option, +} + +/// Build a system prompt for the given mode as an ordered list of blocks. +/// +/// Returns 2 or 3 blocks depending on whether a skill registry is provided: +/// [0] static body — everything except the Environment section, with +/// `{{working_dir}}` interpolated inline. Marked `cache_control: ephemeral` +/// (1h TTL) so Anthropic caches this prefix (stable per project). +/// [1] (optional) Available Skills list — injected when `skills` is Some +/// and non-empty. Marked `cache_control: ephemeral` (1h TTL) on its own +/// breakpoint so toggling a skill invalidates only this block's cache, +/// not block 0's larger prefix. Independence is best-effort: both blocks +/// expire after 1h. +/// [last] environment section — Working directory, branch, project note, +/// date, OS/arch. NOT cached: `{{date}}` rotates daily, and we don't +/// want a fresh cache write every midnight. +/// +/// Across sessions on the same project: block 0 is byte-identical → cache hit. +/// Across the midnight boundary: block 0 still cache-hits; env block is sent +/// uncached (~50 tokens, negligible). +pub fn build_system_prompt( + mode: ToolMode, + working_dir: &Path, + branch: Option<&str>, + project_note: Option<&str>, + skills: Option<&SkillRegistry>, + subagents: Option<&SubagentRegistry>, +) -> Vec { + let template = match mode { + ToolMode::Ask => ASK_PROMPT_TEMPLATE, + ToolMode::Coding => CODING_PROMPT_TEMPLATE, + ToolMode::Plan => PLAN_PROMPT_TEMPLATE, + }; + + let static_body = template.replace("{{working_dir}}", &working_dir.display().to_string()); + + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + let branch_line = branch.map(|b| format!("- Git branch: {b}\n")).unwrap_or_default(); + let note_line = project_note.map(|n| format!("- {n}\n")).unwrap_or_default(); + let env_section = format!( + "# Environment\n- Working directory: {}\n{}{}- Date: {}\n- OS: {}/{}\n", + working_dir.display(), + branch_line, + note_line, + date, + os, + arch, + ); + + let mut blocks = vec![SystemBlock { + text: static_body, + cache_control: Some(CacheControl::ephemeral()), + }]; + + // Skills + Subagents share ONE ephemeral breakpoint. Toggling either + // invalidates the combined block once (not twice). + let skills_entries = skills.map(|r| r.list_for_prompt()).unwrap_or_default(); + let subagents_entries = subagents.map(|r| r.list_for_prompt()).unwrap_or_default(); + + if !skills_entries.is_empty() || !subagents_entries.is_empty() { + let mut combined = String::new(); + if !skills_entries.is_empty() { + combined.push_str( + "# Available Skills\n\ + Skills are instruction packs loaded on demand via the `skill` tool. \ + The descriptions below are a MENU — they do not contain the rules \ + themselves. Whenever a task matches a skill by name, topic, or \ + intent — or the user explicitly mentions one — CALL THE `skill` \ + TOOL FIRST and follow the body verbatim. Do not guess at the rules \ + from the description. Skip the tool only when no skill below is \ + clearly relevant.\n\n\ + Available:\n", + ); + for (name, description) in &skills_entries { + combined.push_str(&format!("- {name}: {description}\n")); + } + } + if !subagents_entries.is_empty() { + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str( + "# Available Subagents\n\ + Subagents currently enabled for this turn (see \"Default Subagents\" \ + in the main prompt for routing guidance):\n", + ); + for (name, description) in &subagents_entries { + combined.push_str(&format!("- {name}: {description}\n")); + } + } + log::info!( + "[prompt] skills+subagents block: {} skill(s), {} subagent(s), {} chars", + skills_entries.len(), + subagents_entries.len(), + combined.len() + ); + blocks.push(SystemBlock { + text: combined, + cache_control: Some(CacheControl::ephemeral()), + }); + } + + blocks.push(SystemBlock { + text: env_section, + cache_control: None, + }); + + blocks +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn joined(blocks: &[SystemBlock]) -> String { + blocks.iter().map(|b| b.text.as_str()).collect::>().join("\n") + } + + use crate::skills::{registry::SkillInput, SkillRegistry}; + use std::collections::HashSet; + + fn mk_skill_registry() -> SkillRegistry { + let input = SkillInput { + raw: "---\nname: hello\ndescription: A greeting skill.\n---\nbody\n".to_string(), + path: PathBuf::from("/hello"), + }; + SkillRegistry::new(vec![], vec![input], vec![], &HashSet::new()) + } + + // ── Structural: two-block layout with correct cache markers ── + + #[test] + fn test_build_system_prompt_splits_into_two_blocks() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let blocks = build_system_prompt(mode, &PathBuf::from("/p"), Some("main"), None, None, None); + assert_eq!(blocks.len(), 2, "{:?}: expected exactly 2 blocks", mode); + assert!(blocks[0].cache_control.is_some(), "{:?}: block 0 must be cached", mode); + assert!(blocks[1].cache_control.is_none(), "{:?}: block 1 must NOT be cached", mode); + } + } + + #[test] + fn test_skills_block_inserted_when_registry_non_empty() { + let registry = mk_skill_registry(); + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let blocks = build_system_prompt( + mode, + &PathBuf::from("/p"), + Some("main"), + None, + Some(®istry), + None, + ); + assert_eq!(blocks.len(), 3, "{:?}: expected 3 blocks with skills", mode); + assert!(blocks[0].cache_control.is_some(), "{:?}: static body cached", mode); + assert!(blocks[1].cache_control.is_some(), "{:?}: skills cached", mode); + assert!(blocks[2].cache_control.is_none(), "{:?}: env uncached", mode); + assert!(blocks[1].text.contains("# Available Skills")); + assert!(blocks[1].text.contains("hello: A greeting skill.")); + } + } + + #[test] + fn test_skills_block_omitted_when_registry_empty() { + let empty = SkillRegistry::new(vec![], vec![], vec![], &HashSet::new()); + let blocks = build_system_prompt( + ToolMode::Ask, + &PathBuf::from("/p"), + Some("main"), + None, + Some(&empty), + None, + ); + assert_eq!(blocks.len(), 2, "empty registry must not add a block"); + } + + #[test] + fn test_static_body_excludes_date() { + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let blocks = build_system_prompt(mode, &PathBuf::from("/p"), Some("main"), None, None, None); + assert!( + !blocks[0].text.contains(&today), + "{:?}: static body must not contain today's date (would invalidate cache daily)", + mode + ); + } + } + + #[test] + fn test_env_block_contains_date_and_working_dir() { + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let blocks = build_system_prompt(mode, &PathBuf::from("/home/user/project"), Some("main"), None, None, None); + assert!(blocks[1].text.contains(&today), "{:?}: env must contain date", mode); + assert!(blocks[1].text.contains("/home/user/project"), "{:?}: env must contain working_dir", mode); + assert!(blocks[1].text.contains("main"), "{:?}: env must contain branch when provided", mode); + } + } + + // ── Legacy coverage: working_dir injection, placeholder resolution ── + + #[test] + fn test_working_dir_injected_all_modes() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let prompt = joined(&build_system_prompt(mode, &PathBuf::from("/home/user/project"), None, None, None, None)); + assert!(prompt.contains("/home/user/project"), "Mode {:?} missing working_dir", mode); + assert!(!prompt.contains("{{working_dir}}"), "Mode {:?} has unresolved placeholder", mode); + } + } + + #[test] + fn test_branch_injected() { + let prompt = joined(&build_system_prompt(ToolMode::Coding, &PathBuf::from("/tmp"), Some("feature/login"), None, None, None)); + assert!(prompt.contains("feature/login")); + } + + #[test] + fn test_branch_omitted_when_none() { + let prompt = joined(&build_system_prompt(ToolMode::Ask, &PathBuf::from("/tmp"), None, None, None, None)); + assert!(!prompt.contains("Git branch")); + } + + #[test] + fn test_date_injected() { + let prompt = joined(&build_system_prompt(ToolMode::Ask, &PathBuf::from("/tmp"), None, None, None, None)); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + assert!(prompt.contains(&today)); + } + + #[test] + fn test_os_arch_injected() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let prompt = joined(&build_system_prompt(mode, &PathBuf::from("/tmp"), None, None, None, None)); + assert!(prompt.contains(std::env::consts::OS), "Mode {:?} missing OS", mode); + assert!(prompt.contains(std::env::consts::ARCH), "Mode {:?} missing ARCH", mode); + } + } + + #[test] + fn test_no_unresolved_placeholders() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let prompt = joined(&build_system_prompt(mode, &PathBuf::from("/tmp"), Some("main"), None, None, None)); + assert!(!prompt.contains("{{"), "Mode {:?} has unresolved placeholder: {}", mode, + prompt.find("{{").map(|i| &prompt[i..(i+30).min(prompt.len())]).unwrap_or("")); + } + } +} diff --git a/crates/agent/src/agent/worktree.rs b/crates/agent/src/agent/worktree.rs new file mode 100644 index 00000000..d9f00736 --- /dev/null +++ b/crates/agent/src/agent/worktree.rs @@ -0,0 +1,492 @@ +use std::path::{Path, PathBuf}; + +/// Maximum number of agent-managed worktrees per project. +/// When this limit is reached, the oldest worktree is force-removed before creating a new one. +pub const MAX_AGENT_WORKTREES: usize = 10; + +/// Information about a created worktree. +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + pub worktree_path: PathBuf, + pub branch: String, + pub session_id: String, + pub project_path: PathBuf, +} + +/// State of an existing worktree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeState { + Clean, + Dirty, + Missing, +} + +#[derive(Debug, thiserror::Error)] +pub enum WorktreeError { + #[error("Git error: {0}")] + Git(#[from] git_ops::GitOpsError), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Worktree error: {0}")] + Other(String), +} + +/// Generate a branch name from a task summary: `agent/{slug}-{uuid8}`. +/// Slug is capped at 20 chars (down from 30) so the full branch name stays +/// under ~35 chars — `agent/` prefix + 20-char slug + `-` + 8-char uuid = 35. +/// The uuid suffix stays for collision avoidance when two tasks slugify the +/// same prefix (e.g. two "add a small comment" runs). +pub fn generate_branch_name(task_summary: &str) -> String { + let slug = slugify(task_summary, 20); + let uuid_short = &uuid::Uuid::new_v4().to_string()[..8]; + format!("agent/{slug}-{uuid_short}") +} + +/// Compute the worktree path for a session: `{project}/.agent-worktrees/{session_id}/`. +pub fn worktree_path(project_path: &Path, session_id: &str) -> PathBuf { + project_path.join(".agent-worktrees").join(session_id) +} + +/// Create a new worktree with its own branch. +/// `base_branch` is the user-selected branch to create the new branch FROM. +/// `branch_name_hint` overrides `task_summary` for branch naming when provided. +pub async fn create_worktree( + project_path: &Path, + session_id: &str, + base_branch: Option<&str>, + task_summary: &str, + branch_name_hint: Option<&str>, +) -> Result { + let branch_name = generate_branch_name(branch_name_hint.unwrap_or(task_summary)); + + let wt_path = worktree_path(project_path, session_id); + + // Prune stale git worktree registrations before counting + prune_stale_worktrees(project_path).await.ok(); + + // Enforce worktree limit: evict oldest agent worktree if at capacity + enforce_worktree_limit(project_path).await?; + + // Create parent directory if needed + if let Some(parent) = wt_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Add agent directories to git's info/exclude (invisible to user, not tracked). + // .agent-worktrees/ in the main repo so VS Code doesn't show thousands of untracked files. + crate::util::ensure_git_exclude(project_path, ".agent-worktrees/").await; + + // Create worktree with new branch FROM the user's selected base branch + git_ops::worktree_add(project_path, &wt_path, &branch_name, true, base_branch).await?; + + // .agent/ in the main repo's info/exclude (git only honors info/exclude from + // the shared gitdir, not per-worktree private gitdirs). Content inside worktrees + // is already covered by the .agent-worktrees/ exclusion above. + crate::util::ensure_git_exclude(project_path, ".agent/").await; + + Ok(WorktreeInfo { + worktree_path: wt_path, + branch: branch_name, + session_id: session_id.to_string(), + project_path: project_path.to_path_buf(), + }) +} + +/// Delete a worktree. +pub async fn delete_worktree(info: &WorktreeInfo) -> Result<(), WorktreeError> { + git_ops::worktree_remove(&info.project_path, &info.worktree_path).await?; + Ok(()) +} + +/// Check the state of a worktree for a given session. +pub async fn check_worktree_state( + project_path: &Path, + session_id: &str, +) -> Result { + let wt_path = worktree_path(project_path, session_id); + + if !wt_path.exists() { + return Ok(WorktreeState::Missing); + } + + // Check for uncommitted changes via git status + let status = git_ops::core::status(&wt_path).await?; + if status.staged.is_empty() + && status.unstaged.is_empty() + && status.untracked.is_empty() + { + Ok(WorktreeState::Clean) + } else { + Ok(WorktreeState::Dirty) + } +} + +/// Prune stale worktree entries from git. +pub async fn prune_stale_worktrees(project_path: &Path) -> Result<(), WorktreeError> { + git_ops::worktree_prune(project_path).await?; + Ok(()) +} + +/// Enforce the worktree limit by evicting the oldest agent worktree(s) if at capacity. +async fn enforce_worktree_limit(project_path: &Path) -> Result<(), WorktreeError> { + let agent_wts = list_agent_worktrees(project_path).await?; + + if agent_wts.len() < MAX_AGENT_WORKTREES { + return Ok(()); + } + + // Sort by creation time (oldest first) — already sorted by list_agent_worktrees + let to_evict = agent_wts.len() - MAX_AGENT_WORKTREES + 1; // +1 to make room for the new one + for wt in agent_wts.iter().take(to_evict) { + log::info!( + "[worktree] Evicting oldest agent worktree to stay within limit ({}): {}", + MAX_AGENT_WORKTREES, + wt.worktree_path.display() + ); + if let Err(e) = git_ops::worktree_remove(project_path, &wt.worktree_path).await { + log::warn!("[worktree] Failed to remove worktree {}: {e}", wt.worktree_path.display()); + // Try filesystem removal as fallback + let _ = tokio::fs::remove_dir_all(&wt.worktree_path).await; + } + } + + // Prune again after removal to clean up git metadata + prune_stale_worktrees(project_path).await.ok(); + Ok(()) +} + +/// An existing agent-managed worktree found on disk. +#[derive(Debug)] +struct AgentWorktree { + worktree_path: PathBuf, + created: std::time::SystemTime, +} + +/// List agent-managed worktrees under `.agent-worktrees/`, sorted oldest-first. +async fn list_agent_worktrees(project_path: &Path) -> Result, WorktreeError> { + let agent_wt_root = project_path.join(".agent-worktrees"); + + if !agent_wt_root.exists() { + return Ok(Vec::new()); + } + + // Get git-registered worktrees to cross-reference + let git_worktrees = git_ops::worktree_list(project_path).await.unwrap_or_default(); + let git_paths: std::collections::HashSet = git_worktrees.iter() + .map(|e| e.path.clone()) + .collect(); + + let mut agent_wts = Vec::new(); + let mut entries = tokio::fs::read_dir(&agent_wt_root).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Only count worktrees that git knows about (or that exist on disk) + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + let is_git_registered = git_paths.contains(&canonical) || git_paths.contains(&path); + + // Include if git-registered or if the directory simply exists (could be orphaned) + if is_git_registered || path.exists() { + let created = entry.metadata().await + .and_then(|m| m.created().or_else(|_| m.modified())) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + + agent_wts.push(AgentWorktree { + worktree_path: canonical, + created, + }); + } + } + + // Sort oldest first + agent_wts.sort_by_key(|w| w.created); + Ok(agent_wts) +} + +/// Convert text to a URL-safe slug, limited to `max_len` characters. +fn slugify(text: &str, max_len: usize) -> String { + let slug: String = text + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c + } else { + '-' + } + }) + .collect(); + + // Collapse multiple hyphens + let mut result = String::new(); + let mut last_was_hyphen = false; + for c in slug.chars() { + if c == '-' { + if !last_was_hyphen && !result.is_empty() { + result.push(c); + last_was_hyphen = true; + } + } else { + result.push(c); + last_was_hyphen = false; + } + } + + // Trim trailing hyphens + let result = result.trim_end_matches('-').to_string(); + + // Truncate to max_len + if result.len() > max_len { + result[..max_len].trim_end_matches('-').to_string() + } else { + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use tokio::process::Command; + + async fn init_repo(dir: &Path) { + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .await + .unwrap(); + std::fs::write(dir.join("README.md"), "# Test").unwrap(); + Command::new("git") + .args(["add", "-A"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(dir) + .output() + .await + .unwrap(); + } + + #[test] + fn test_generate_branch_name_format() { + let name = generate_branch_name("Fix the login bug"); + assert!(name.starts_with("agent/fix-the-login-bug-"), "Got: {name}"); + // UUID part should be 8 chars + let parts: Vec<&str> = name.rsplitn(2, '-').collect(); + assert_eq!(parts[0].len(), 8); + } + + #[test] + fn test_slugify_special_chars() { + assert_eq!(slugify("Hello, World! 123", 50), "hello-world-123"); + } + + #[test] + fn test_slugify_length_limit() { + let result = slugify("this is a very long task summary that exceeds the limit", 20); + assert!(result.len() <= 20, "Got len {}: {result}", result.len()); + assert!(!result.ends_with('-')); + } + + #[test] + fn test_worktree_path_format() { + let path = worktree_path(Path::new("/home/user/project"), "abc123def456xyz"); + assert_eq!( + path, + PathBuf::from("/home/user/project/.agent-worktrees/abc123def456xyz") + ); + } + + #[tokio::test] + async fn test_create_and_delete_worktree() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let info = create_worktree(dir.path(), "test-session-id", None, "Fix login bug", None) + .await + .unwrap(); + + assert!(info.worktree_path.exists(), "Worktree should exist"); + assert!(info.branch.starts_with("agent/")); + + // Verify it's a valid git worktree + let status = Command::new("git") + .args(["status"]) + .current_dir(&info.worktree_path) + .output() + .await + .unwrap(); + assert!(status.status.success()); + + // Delete + delete_worktree(&info).await.unwrap(); + assert!(!info.worktree_path.exists(), "Worktree should be gone"); + } + + #[tokio::test] + async fn test_check_worktree_state_clean() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let _info = create_worktree(dir.path(), "clean-session", None, "Clean task", None) + .await + .unwrap(); + + let state = check_worktree_state(dir.path(), "clean-session").await.unwrap(); + assert_eq!(state, WorktreeState::Clean); + } + + #[tokio::test] + async fn test_check_worktree_state_dirty() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let info = create_worktree(dir.path(), "dirty-session", None, "Dirty task", None) + .await + .unwrap(); + + // Create an uncommitted file in the worktree + std::fs::write(info.worktree_path.join("new_file.txt"), "dirty").unwrap(); + + let state = check_worktree_state(dir.path(), "dirty-session").await.unwrap(); + assert_eq!(state, WorktreeState::Dirty); + } + + #[tokio::test] + async fn test_check_worktree_state_missing() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let state = check_worktree_state(dir.path(), "nonexistent-session").await.unwrap(); + assert_eq!(state, WorktreeState::Missing); + } + + #[tokio::test] + async fn test_prune_stale_worktrees() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + // Should not error even with no stale worktrees + prune_stale_worktrees(dir.path()).await.unwrap(); + } + + #[tokio::test] + async fn test_check_worktree_state_returns_error_on_git_failure() { + // Use a separate temp dir (outside any git repo) so git status actually fails + let isolated = tempdir().unwrap(); + let fake_project = isolated.path().join("project"); + std::fs::create_dir_all(&fake_project).unwrap(); + + // Create the worktree path so it exists, but it's not a git repo at all + let fake_wt = fake_project.join(".agent-worktrees").join("broken-session"); + std::fs::create_dir_all(&fake_wt).unwrap(); + std::fs::write(fake_wt.join("not-a-repo.txt"), "hello").unwrap(); + + let result = check_worktree_state(&fake_project, "broken-session").await; + // Should return an error (not Missing), because the path exists but git status fails + assert!(result.is_err(), "Expected error for non-git directory, got: {:?}", result); + } + + #[tokio::test] + async fn test_evicts_oldest_when_limit_reached() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + // Create MAX_AGENT_WORKTREES worktrees + let mut infos = Vec::new(); + for i in 0..MAX_AGENT_WORKTREES { + let info = create_worktree(dir.path(), &format!("evict-{i}"), None, &format!("task {i}"), None) + .await + .unwrap(); + infos.push(info); + // Small delay so filesystem timestamps differ + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + // All should exist + for info in &infos { + assert!(info.worktree_path.exists(), "Worktree {} should exist", info.session_id); + } + + // Create one more — should evict the oldest (evict-0) + let new_info = create_worktree(dir.path(), "evict-new", None, "new task", None) + .await + .unwrap(); + + assert!(new_info.worktree_path.exists(), "New worktree should exist"); + + // The oldest worktree should have been removed + // (canonicalize might differ, so check the session dir name) + let agent_wt_root = dir.path().join(".agent-worktrees"); + assert!(!agent_wt_root.join("evict-0").exists(), "Oldest worktree (evict-0) should have been evicted"); + + // Total count should be at most MAX_AGENT_WORKTREES + let remaining = list_agent_worktrees(dir.path()).await.unwrap(); + assert!( + remaining.len() <= MAX_AGENT_WORKTREES, + "Expected at most {} worktrees, found {}", + MAX_AGENT_WORKTREES, + remaining.len() + ); + } + + #[tokio::test] + async fn test_no_eviction_when_below_limit() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + // Create 2 worktrees (well below limit) + let info1 = create_worktree(dir.path(), "below-1", None, "task 1", None) + .await + .unwrap(); + let info2 = create_worktree(dir.path(), "below-2", None, "task 2", None) + .await + .unwrap(); + let info3 = create_worktree(dir.path(), "below-3", None, "task 3", None) + .await + .unwrap(); + + // All 3 should still exist + assert!(info1.worktree_path.exists()); + assert!(info2.worktree_path.exists()); + assert!(info3.worktree_path.exists()); + } + + #[tokio::test] + async fn test_stale_worktree_pruned_before_limit_check() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + // Create a worktree, then manually delete its directory + let info = create_worktree(dir.path(), "stale-session", None, "stale task", None) + .await + .unwrap(); + std::fs::remove_dir_all(&info.worktree_path).unwrap(); + + // Creating another should succeed (stale entry gets pruned, doesn't count toward limit) + let info2 = create_worktree(dir.path(), "fresh-session", None, "fresh task", None) + .await + .unwrap(); + assert!(info2.worktree_path.exists()); + } +} diff --git a/crates/agent/src/approval.rs b/crates/agent/src/approval.rs new file mode 100644 index 00000000..85128068 --- /dev/null +++ b/crates/agent/src/approval.rs @@ -0,0 +1,145 @@ +use async_trait::async_trait; +use serde_json::Value; + +/// Outcome of an approval request. +#[derive(Debug, Clone, PartialEq)] +pub enum ApprovalDecision { + /// User approved the tool execution. + Approved, + /// User denied the tool execution. + Denied { reason: Option }, +} + +/// Trait for requesting user approval before tool execution. +/// Implementors should emit a UI event and block until the user responds. +/// +/// When `None` is passed as the handler, all tools are auto-approved (current behavior). +#[async_trait] +pub trait ApprovalHandler: Send + Sync { + /// Request approval for a specific tool call. + /// Returns the user's decision. Blocks until the user responds or the session is cancelled. + async fn request_approval( + &self, + tool_name: &str, + tool_call_id: &str, + args: &Value, + args_summary: &str, + ) -> ApprovalDecision; +} + +#[cfg(test)] +pub mod test_util { + use super::*; + use std::sync::{Arc, Mutex}; + + /// Mock handler that auto-approves everything. + pub struct AutoApproveHandler; + + #[async_trait] + impl ApprovalHandler for AutoApproveHandler { + async fn request_approval( + &self, _: &str, _: &str, _: &Value, _: &str, + ) -> ApprovalDecision { + ApprovalDecision::Approved + } + } + + /// Mock handler that denies everything. + pub struct AutoDenyHandler { + pub reason: String, + } + + #[async_trait] + impl ApprovalHandler for AutoDenyHandler { + async fn request_approval( + &self, _: &str, _: &str, _: &Value, _: &str, + ) -> ApprovalDecision { + ApprovalDecision::Denied { + reason: Some(self.reason.clone()), + } + } + } + + /// Mock handler that records calls and returns queued decisions. + pub struct RecordingApprovalHandler { + pub calls: Arc>>, + decisions: Arc>>, + } + + impl RecordingApprovalHandler { + pub fn new() -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + decisions: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn queue_decision(&self, decision: ApprovalDecision) { + self.decisions.lock().unwrap().push(decision); + } + } + + #[async_trait] + impl ApprovalHandler for RecordingApprovalHandler { + async fn request_approval( + &self, tool_name: &str, tool_call_id: &str, _: &Value, _: &str, + ) -> ApprovalDecision { + self.calls + .lock() + .unwrap() + .push(format!("{tool_name}:{tool_call_id}")); + let mut decisions = self.decisions.lock().unwrap(); + assert!( + !decisions.is_empty(), + "RecordingApprovalHandler: no decisions queued for {tool_name}:{tool_call_id}" + ); + decisions.remove(0) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::test_util::*; + use serde_json::json; + + #[tokio::test] + async fn test_auto_approve_handler_approves_everything() { + let handler = AutoApproveHandler; + let decision = handler + .request_approval("bash", "call-1", &json!({}), "Running command") + .await; + assert_eq!(decision, ApprovalDecision::Approved); + } + + #[tokio::test] + async fn test_auto_deny_handler_denies_everything() { + let handler = AutoDenyHandler { + reason: "not allowed".into(), + }; + let decision = handler + .request_approval("read", "call-2", &json!({}), "Reading file") + .await; + assert_eq!( + decision, + ApprovalDecision::Denied { + reason: Some("not allowed".into()) + } + ); + } + + #[tokio::test] + async fn test_recording_handler_records_calls() { + let handler = RecordingApprovalHandler::new(); + handler.queue_decision(ApprovalDecision::Approved); + + let decision = handler + .request_approval("edit", "call-1", &json!({}), "Editing file") + .await; + + assert_eq!(decision, ApprovalDecision::Approved); + let calls = handler.calls.lock().unwrap(); + assert_eq!(*calls, vec!["edit:call-1"]); + } +} diff --git a/crates/agent/src/context_engine.rs b/crates/agent/src/context_engine.rs new file mode 100644 index 00000000..7c1425b7 --- /dev/null +++ b/crates/agent/src/context_engine.rs @@ -0,0 +1,741 @@ +use std::sync::LazyLock; + +use async_trait::async_trait; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Deserialize `null` JSON values as `Default::default()` (e.g., empty Vec). +/// Handles the case where the server returns `"results": null` instead of `[]`. +fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::deserialize(deserializer)?.unwrap_or_default()) +} + +// --------------------------------------------------------------------------- +// Shared HTTP client (module-local, shorter timeouts than LLM client) +// --------------------------------------------------------------------------- + +static HTTP_CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create context engine HTTP client") +}); + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum ContextEngineError { + #[error("Context engine unavailable: {0}")] + Unavailable(String), + + #[error("HTTP error {status}: {body}")] + HttpError { status: u16, body: String }, + + #[error("Deserialization error: {0}")] + DeserializeError(String), +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Configuration for connecting to the context engine. +/// Carried on AgentConfig, used to construct ContextEngineClient. +#[derive(Debug, Clone)] +pub struct ContextEngineConfig { + /// Base URL (e.g., "http://localhost:8080") + pub base_url: String, + /// User ID for data isolation + pub user_id: String, + /// Workspace ID for data isolation + pub workspace_id: u64, + /// Machine ID (UUID from SQLite) + pub machine_id: String, + /// Canonical repo path (main checkout, NOT worktree) + pub repo_path: String, + /// Auth token (X-Auth-Token header). Empty = no auth. + pub auth_token: String, +} + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +// --- Search --- + +#[derive(Debug, Clone, Serialize)] +pub struct SearchRequest { + pub query: String, + pub repo_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + pub user_id: String, + pub workspace_id: u64, + pub machine_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResponse { + pub query: String, + pub total: u32, + #[serde(default, deserialize_with = "deserialize_null_as_default")] + pub results: Vec, + #[serde(default)] + pub indexing: bool, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResultItem { + pub chunk_id: String, + pub content: String, + pub file_path: String, + pub language: String, + pub score: f32, + pub source: String, +} + +// --- Graph --- + +#[derive(Debug, Clone, Serialize)] +pub struct GraphQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + pub repo_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub query_type: Option, + pub user_id: String, + pub workspace_id: u64, + pub machine_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GraphQueryResponse { + pub total: u32, + #[serde(default, deserialize_with = "deserialize_null_as_default")] + pub results: Vec, + #[serde(default)] + pub indexing: bool, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GraphResultItem { + pub name: String, + pub file_path: String, + pub chunk_id: String, + pub depth: u32, + #[serde(default)] + pub direction: Option, +} + +// --- Index Status --- + +#[derive(Debug, Clone, Deserialize)] +pub struct IndexStatusResponse { + pub exists: bool, + pub collection_name: Option, + pub repo_id: Option, + pub empty: bool, +} + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +#[async_trait] +pub trait ContextEngineApi: Send + Sync { + /// Check index status for the configured repo. + async fn index_status(&self) -> Result; + + /// Search code in the indexed repo. + async fn search( + &self, + query: &str, + strategy: Option<&str>, + limit: Option, + ) -> Result; + + /// Query the dependency/blast-radius graph. + async fn graph_query( + &self, + query: Option<&str>, + function_name: Option<&str>, + file_path: Option<&str>, + query_type: Option<&str>, + ) -> Result; +} + +// --------------------------------------------------------------------------- +// HTTP client implementation +// --------------------------------------------------------------------------- + +pub struct ContextEngineClient { + config: ContextEngineConfig, + http: reqwest::Client, +} + +impl ContextEngineClient { + pub fn new(config: ContextEngineConfig) -> Self { + Self { + config, + http: HTTP_CLIENT.clone(), + } + } + + /// Apply auth headers (X-Auth-Token, X-USER-ID, X-Workspace-ID) to a request. + fn apply_auth_headers(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut r = req; + if !self.config.auth_token.is_empty() { + r = r.header("X-Auth-Token", &self.config.auth_token); + } + if !self.config.user_id.is_empty() { + r = r.header("X-USER-ID", &self.config.user_id); + } + r = r.header("X-Workspace-ID", self.config.workspace_id.to_string()); + r + } + + /// Send a GET and map errors. + async fn get(&self, url: &str) -> Result { + let mut req = self.http.get(url); + req = self.apply_auth_headers(req); + let resp = req + .send() + .await + .map_err(|e| ContextEngineError::Unavailable(e.to_string()))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ContextEngineError::HttpError { + status: status.as_u16(), + body, + }); + } + Ok(resp) + } + + /// Send a POST with JSON body and map errors. + async fn post_json( + &self, + url: &str, + body: &T, + ) -> Result { + let mut req = self.http.post(url).json(body); + req = self.apply_auth_headers(req); + let resp = req + .send() + .await + .map_err(|e| ContextEngineError::Unavailable(e.to_string()))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ContextEngineError::HttpError { + status: status.as_u16(), + body, + }); + } + Ok(resp) + } + + /// Deserialize JSON response body. + async fn parse_json( + resp: reqwest::Response, + ) -> Result { + let text = resp + .text() + .await + .map_err(|e| ContextEngineError::Unavailable(e.to_string()))?; + serde_json::from_str(&text).map_err(|e| { + // Truncate at a char boundary to avoid panicking on multi-byte UTF-8 + let mut end = text.len().min(200); + while !text.is_char_boundary(end) { + end -= 1; + } + ContextEngineError::DeserializeError(format!("{e}: {}", &text[..end])) + }) + } +} + +#[async_trait] +impl ContextEngineApi for ContextEngineClient { + async fn index_status(&self) -> Result { + let url = format!( + "{}/api/v1/index/status?repo_path={}&user_id={}&workspace_id={}&machine_id={}", + self.config.base_url, + urlencoding(self.config.repo_path.as_str()), + urlencoding(self.config.user_id.as_str()), + self.config.workspace_id, + urlencoding(self.config.machine_id.as_str()), + ); + let resp = self.get(&url).await?; + Self::parse_json(resp).await + } + + async fn search( + &self, + query: &str, + strategy: Option<&str>, + limit: Option, + ) -> Result { + let url = format!("{}/search", self.config.base_url); + let body = SearchRequest { + query: query.to_string(), + repo_path: self.config.repo_path.clone(), + strategy: strategy.map(|s| s.to_string()), + limit, + user_id: self.config.user_id.clone(), + workspace_id: self.config.workspace_id, + machine_id: self.config.machine_id.clone(), + }; + let resp = self.post_json(&url, &body).await?; + Self::parse_json(resp).await + } + + async fn graph_query( + &self, + query: Option<&str>, + function_name: Option<&str>, + file_path: Option<&str>, + query_type: Option<&str>, + ) -> Result { + let url = format!("{}/graph/query", self.config.base_url); + let body = GraphQueryRequest { + query: query.map(String::from), + function_name: function_name.map(String::from), + file_path: file_path.map(String::from), + repo_path: self.config.repo_path.clone(), + query_type: query_type.map(String::from), + user_id: self.config.user_id.clone(), + workspace_id: self.config.workspace_id, + machine_id: self.config.machine_id.clone(), + }; + let resp = self.post_json(&url, &body).await?; + Self::parse_json(resp).await + } +} + +/// RFC 3986 percent-encoding for query string values. +fn urlencoding(s: &str) -> String { + utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() +} + +// --------------------------------------------------------------------------- +// Mock (NOT behind #[cfg(test)] — Tauri layer needs it) +// --------------------------------------------------------------------------- + +pub struct MockContextEngine { + pub search_response: SearchResponse, + pub graph_response: GraphQueryResponse, + pub index_status_response: IndexStatusResponse, +} + +impl MockContextEngine { + /// Mock that reports as indexed with empty results. + pub fn indexed_empty() -> Self { + Self { + search_response: SearchResponse { + query: String::new(), + total: 0, + results: vec![], + indexing: false, + message: None, + }, + graph_response: GraphQueryResponse { + total: 0, + results: vec![], + indexing: false, + message: None, + }, + index_status_response: IndexStatusResponse { + exists: true, + collection_name: Some("test_collection".to_string()), + repo_id: Some(1), + empty: false, + }, + } + } + + /// Mock that reports as not-yet-indexed (cold start). + pub fn not_indexed() -> Self { + Self { + search_response: SearchResponse { + query: String::new(), + total: 0, + results: vec![], + indexing: true, + message: Some("Repository is being indexed".to_string()), + }, + graph_response: GraphQueryResponse { + total: 0, + results: vec![], + indexing: true, + message: Some("Repository is being indexed".to_string()), + }, + index_status_response: IndexStatusResponse { + exists: false, + collection_name: None, + repo_id: None, + empty: true, + }, + } + } + + /// Mock with preset search results. + pub fn with_search_results(results: Vec) -> Self { + let total = results.len() as u32; + Self { + search_response: SearchResponse { + query: String::new(), + total, + results, + indexing: false, + message: None, + }, + graph_response: GraphQueryResponse { + total: 0, + results: vec![], + indexing: false, + message: None, + }, + index_status_response: IndexStatusResponse { + exists: true, + collection_name: Some("test_collection".to_string()), + repo_id: Some(1), + empty: false, + }, + } + } + + /// Mock with preset graph results. + pub fn with_graph_results(results: Vec) -> Self { + let total = results.len() as u32; + Self { + search_response: SearchResponse { + query: String::new(), + total: 0, + results: vec![], + indexing: false, + message: None, + }, + graph_response: GraphQueryResponse { + total, + results, + indexing: false, + message: None, + }, + index_status_response: IndexStatusResponse { + exists: true, + collection_name: Some("test_collection".to_string()), + repo_id: Some(1), + empty: false, + }, + } + } +} + +#[async_trait] +impl ContextEngineApi for MockContextEngine { + async fn index_status(&self) -> Result { + Ok(self.index_status_response.clone()) + } + + async fn search( + &self, + _query: &str, + _strategy: Option<&str>, + _limit: Option, + ) -> Result { + Ok(self.search_response.clone()) + } + + async fn graph_query( + &self, + _query: Option<&str>, + _function_name: Option<&str>, + _file_path: Option<&str>, + _query_type: Option<&str>, + ) -> Result { + Ok(self.graph_response.clone()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_mock_indexed_empty() { + let mock = MockContextEngine::indexed_empty(); + + let status = mock.index_status().await.unwrap(); + assert!(status.exists); + assert!(!status.empty); + + let search = mock.search("test", None, None).await.unwrap(); + assert_eq!(search.total, 0); + assert!(search.results.is_empty()); + assert!(!search.indexing); + } + + #[tokio::test] + async fn test_mock_not_indexed() { + let mock = MockContextEngine::not_indexed(); + + let status = mock.index_status().await.unwrap(); + assert!(!status.exists); + + let search = mock.search("test", None, None).await.unwrap(); + assert!(search.indexing); + assert!(search.message.is_some()); + } + + #[tokio::test] + async fn test_mock_with_search_results() { + let results = vec![ + SearchResultItem { + chunk_id: "c1".into(), + content: "fn main() {}".into(), + file_path: "src/main.rs".into(), + language: "rust".into(), + score: 0.95, + source: "vector".into(), + }, + SearchResultItem { + chunk_id: "c2".into(), + content: "fn helper() {}".into(), + file_path: "src/lib.rs".into(), + language: "rust".into(), + score: 0.80, + source: "keyword".into(), + }, + ]; + let mock = MockContextEngine::with_search_results(results); + + let resp = mock.search("main", None, None).await.unwrap(); + assert_eq!(resp.total, 2); + assert_eq!(resp.results.len(), 2); + assert_eq!(resp.results[0].file_path, "src/main.rs"); + assert!((resp.results[0].score - 0.95).abs() < f32::EPSILON); + } + + #[tokio::test] + async fn test_mock_with_graph_results() { + let results = vec![GraphResultItem { + name: "process_request".into(), + file_path: "src/handler.rs".into(), + chunk_id: "g1".into(), + depth: 1, + direction: Some("caller".into()), + }]; + let mock = MockContextEngine::with_graph_results(results); + + let resp = mock + .graph_query(None, Some("process_request"), None, Some("blast_radius")) + .await + .unwrap(); + assert_eq!(resp.total, 1); + assert_eq!(resp.results[0].name, "process_request"); + assert_eq!(resp.results[0].direction.as_deref(), Some("caller")); + } + + #[test] + fn test_search_request_serialization() { + let req = SearchRequest { + query: "find auth".into(), + repo_path: "/home/user/repo".into(), + strategy: Some("multi".into()), + limit: Some(10), + user_id: "u1".into(), + workspace_id: 42, + machine_id: "m1".into(), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["query"], "find auth"); + assert_eq!(json["repo_path"], "/home/user/repo"); + assert_eq!(json["strategy"], "multi"); + assert_eq!(json["limit"], 10); + assert_eq!(json["user_id"], "u1"); + assert_eq!(json["workspace_id"], 42); + assert_eq!(json["machine_id"], "m1"); + } + + #[test] + fn test_graph_request_serialization() { + let req = GraphQueryRequest { + query: None, + function_name: Some("do_stuff".into()), + file_path: None, + repo_path: "/repo".into(), + query_type: Some("blast_radius".into()), + user_id: "u1".into(), + workspace_id: 1, + machine_id: "m1".into(), + }; + let json = serde_json::to_value(&req).unwrap(); + // None fields must be omitted + assert!(json.get("query").is_none()); + assert!(json.get("file_path").is_none()); + // Present fields + assert_eq!(json["function_name"], "do_stuff"); + assert_eq!(json["query_type"], "blast_radius"); + assert_eq!(json["repo_path"], "/repo"); + } + + #[test] + fn test_search_response_deserialization() { + let json = r#"{ + "query": "auth handler", + "total": 1, + "results": [{ + "chunk_id": "abc123", + "content": "pub fn authenticate() { ... }", + "file_path": "src/auth.rs", + "language": "rust", + "score": 0.92, + "source": "hybrid" + }], + "indexing": false + }"#; + let resp: SearchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.query, "auth handler"); + assert_eq!(resp.total, 1); + assert_eq!(resp.results[0].chunk_id, "abc123"); + assert!((resp.results[0].score - 0.92).abs() < f32::EPSILON); + assert_eq!(resp.results[0].source, "hybrid"); + assert!(!resp.indexing); + assert!(resp.message.is_none()); + } + + #[test] + fn test_graph_response_deserialization() { + let json = r#"{ + "total": 2, + "results": [ + { + "name": "handle_request", + "file_path": "src/server.rs", + "chunk_id": "g1", + "depth": 0 + }, + { + "name": "parse_body", + "file_path": "src/parser.rs", + "chunk_id": "g2", + "depth": 1, + "direction": "dependency" + } + ] + }"#; + let resp: GraphQueryResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.total, 2); + assert_eq!(resp.results[0].name, "handle_request"); + assert!(resp.results[0].direction.is_none()); + assert_eq!(resp.results[1].direction.as_deref(), Some("dependency")); + assert!(!resp.indexing); + } + + #[test] + fn test_index_status_deserialization() { + let json = r#"{ + "exists": true, + "collection_name": "repo_abc", + "repo_id": 42, + "empty": false + }"#; + let resp: IndexStatusResponse = serde_json::from_str(json).unwrap(); + assert!(resp.exists); + assert_eq!(resp.collection_name.as_deref(), Some("repo_abc")); + assert_eq!(resp.repo_id, Some(42)); + assert!(!resp.empty); + + // Also test minimal response (no optional fields) + let json_minimal = r#"{"exists": false, "empty": true}"#; + let resp2: IndexStatusResponse = serde_json::from_str(json_minimal).unwrap(); + assert!(!resp2.exists); + assert!(resp2.collection_name.is_none()); + assert!(resp2.repo_id.is_none()); + assert!(resp2.empty); + } + + #[test] + fn test_urlencoding() { + // NON_ALPHANUMERIC encodes everything except [A-Za-z0-9] + // Spaces + assert_eq!(urlencoding("hello world"), "hello%20world"); + // Special query-string characters + assert_eq!(urlencoding("a&b=c"), "a%26b%3Dc"); + assert_eq!(urlencoding("a?b#c"), "a%3Fb%23c"); + // Percent + assert_eq!(urlencoding("100%"), "100%25"); + // Plus sign + assert_eq!(urlencoding("a+b"), "a%2Bb"); + // Backslash + colon (Windows paths) + assert_eq!(urlencoding(r"C:\Users\foo"), "C%3A%5CUsers%5Cfoo"); + // Forward slashes are encoded (NON_ALPHANUMERIC) + assert_eq!(urlencoding("/home/user/repo"), "%2Fhome%2Fuser%2Frepo"); + // Brackets — the bug that prompted this fix + assert_eq!(urlencoding("path[0]"), "path%5B0%5D"); + assert_eq!(urlencoding("path{a}"), "path%7Ba%7D"); + // No-op on clean alphanumeric strings + assert_eq!(urlencoding("simple"), "simple"); + } + + #[test] + fn test_search_request_none_fields_omitted() { + let req = SearchRequest { + query: "test".into(), + repo_path: "/repo".into(), + strategy: None, + limit: None, + user_id: "u1".into(), + workspace_id: 1, + machine_id: "m1".into(), + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("strategy").is_none(), "None strategy must be omitted"); + assert!(json.get("limit").is_none(), "None limit must be omitted"); + // Required fields still present + assert_eq!(json["query"], "test"); + } + + #[test] + fn test_deserialize_error_truncation_utf8_safe() { + // Build a string where byte offset 200 falls inside a multi-byte char. + // '€' is 3 bytes (E2 82 AC). Fill 198 ASCII bytes + '€' = 201 bytes. + let mut body = "x".repeat(198); + body.push('€'); // bytes 198..201 + assert_eq!(body.len(), 201); + + // Simulate what parse_json does on deser failure + let mut end = body.len().min(200); + while !body.is_char_boundary(end) { + end -= 1; + } + let snippet = &body[..end]; + // Should truncate to 198 (before the €), not panic + assert_eq!(snippet.len(), 198); + assert!(snippet.ends_with('x')); + } +} diff --git a/crates/agent/src/error.rs b/crates/agent/src/error.rs new file mode 100644 index 00000000..3b8caf04 --- /dev/null +++ b/crates/agent/src/error.rs @@ -0,0 +1,28 @@ +#[derive(Debug, thiserror::Error)] +pub enum AgentError { + #[error("LLM API error (status {status}): {body}")] + LlmApiError { status: u16, body: String }, + + #[error("LLM parse error: {0}")] + LlmParseError(String), + + #[error("Agent cancelled")] + Cancelled, + + #[error("SSE chunk timeout: no data received for {0}s")] + ChunkTimeout(u64), + + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +/// Tool-level error that gets converted to an error result the LLM can see. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct ToolError(pub String); diff --git a/crates/agent/src/frontmatter.rs b/crates/agent/src/frontmatter.rs new file mode 100644 index 00000000..acf88467 --- /dev/null +++ b/crates/agent/src/frontmatter.rs @@ -0,0 +1,99 @@ +//! Shared YAML-frontmatter splitter for Markdown-with-frontmatter files +//! (skills, subagents). Handles BOM, leading whitespace, and the four +//! CRLF/LF permutations plus a Notepad-authored EOF-terminated close +//! delimiter. Typed deserialization and domain-specific validation live in +//! each consumer module — this file only splits raw YAML from raw body. + +#[derive(Debug)] +pub struct FrontmatterRaw<'a> { + pub yaml: &'a str, + pub body: &'a str, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum FrontmatterError { + #[error("missing frontmatter delimiter")] + MissingDelimiter, +} + +pub fn split_frontmatter(raw: &str) -> Result, FrontmatterError> { + let trimmed = raw + .trim_start_matches('\u{feff}') + .trim_start_matches(['\r', '\n']); + let without_open = trimmed + .strip_prefix("---\n") + .or_else(|| trimmed.strip_prefix("---\r\n")) + .ok_or(FrontmatterError::MissingDelimiter)?; + + let (yaml, body) = + split_on_close_delim(without_open).ok_or(FrontmatterError::MissingDelimiter)?; + Ok(FrontmatterRaw { yaml, body }) +} + +fn split_on_close_delim(s: &str) -> Option<(&str, &str)> { + for pat in ["\n---\n", "\r\n---\r\n", "\n---\r\n", "\r\n---\n"] { + if let Some(i) = s.find(pat) { + return Some((&s[..i], &s[i + pat.len()..])); + } + } + // Notepad-authored file ending exactly at the close delimiter (no trailing newline). + if let Some(stripped) = s.strip_suffix("\r\n---") { + return Some((stripped, "")); + } + if let Some(stripped) = s.strip_suffix("\n---") { + return Some((stripped, "")); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_lf() { + let raw = "---\nname: x\n---\nbody\n"; + let fm = split_frontmatter(raw).unwrap(); + assert_eq!(fm.yaml, "name: x"); + assert_eq!(fm.body, "body\n"); + } + + #[test] + fn splits_crlf() { + let raw = "---\r\nname: x\r\n---\r\nbody\r\n"; + let fm = split_frontmatter(raw).unwrap(); + assert_eq!(fm.yaml, "name: x"); + assert_eq!(fm.body, "body\r\n"); + } + + #[test] + fn splits_notepad_no_trailing_newline() { + let raw = "---\r\nname: x\r\n---"; + let fm = split_frontmatter(raw).unwrap(); + assert_eq!(fm.yaml, "name: x"); + assert_eq!(fm.body, ""); + } + + #[test] + fn strips_bom_and_leading_newlines() { + let raw = "\u{feff}\n\n---\nname: x\n---\nbody\n"; + let fm = split_frontmatter(raw).unwrap(); + assert_eq!(fm.yaml, "name: x"); + } + + #[test] + fn rejects_missing_open() { + assert_eq!( + split_frontmatter("no delimiter here").unwrap_err(), + FrontmatterError::MissingDelimiter + ); + } + + #[test] + fn rejects_missing_close() { + assert_eq!( + split_frontmatter("---\nname: x\nbody without close\n").unwrap_err(), + FrontmatterError::MissingDelimiter + ); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs new file mode 100644 index 00000000..7c889250 --- /dev/null +++ b/crates/agent/src/lib.rs @@ -0,0 +1,16 @@ +pub mod approval; +pub mod error; +pub mod frontmatter; +pub mod types; +pub mod util; +pub mod llm; +pub mod tool; +pub mod agent; +pub mod persistence; +pub mod session; +pub mod context_engine; +pub mod skills; +pub mod subagents; + +#[cfg(test)] +pub mod test_util; diff --git a/crates/agent/src/llm/client.rs b/crates/agent/src/llm/client.rs new file mode 100644 index 00000000..66feec58 --- /dev/null +++ b/crates/agent/src/llm/client.rs @@ -0,0 +1,276 @@ +use std::sync::LazyLock; + +use async_trait::async_trait; +use reqwest::header::{HeaderMap, CONTENT_TYPE}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::error::AgentError; +use crate::types::AgentEvent; +use super::sse::parse_sse_stream; +use super::types::*; + +/// Trait for LLM providers — allows mocking in tests. +#[async_trait] +pub trait LlmProvider: Send + Sync { + async fn chat_completion( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + event_tx: &mpsc::Sender, + session_id: &str, + cancel_token: Option<&CancellationToken>, + ) -> Result; +} + +#[async_trait] +impl LlmProvider for LlmClient { + async fn chat_completion( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + event_tx: &mpsc::Sender, + session_id: &str, + cancel_token: Option<&CancellationToken>, + ) -> Result { + LlmClient::chat_completion(self, messages, tools, event_tx, session_id, cancel_token).await + } +} + +/// Shared HTTP client — reuses connection pool across all agent sessions. +/// `reqwest::Client` is `Arc`-based internally, so clone is cheap. +/// 30s connect timeout prevents indefinite hang if the API server is unreachable. +/// No total timeout — streaming responses can run indefinitely; idle detection +/// is handled per-chunk in the SSE parser (see sse.rs CHUNK_IDLE_TIMEOUT). +static SHARED_HTTP_CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client") +}); + +/// Configuration for the LLM HTTP client. +#[derive(Debug, Clone)] +pub struct LlmClientConfig { + /// Base URL for the API, e.g. "https://api.openai.com/v1" + pub base_url: String, + /// Model ID, e.g. "claude-sonnet-4-6" + pub model: String, + /// Sampling temperature (0-2) + pub temperature: Option, + /// Upper bound on output tokens + pub max_completion_tokens: Option, + /// Optional auth headers forwarded with each request (e.g. X-Auth-Token, X-USER-ID, X-Workspace-ID). + /// Injected by the host adapter. + pub auth_headers: Vec<(String, String)>, + /// Extended thinking config (e.g., {"type": "enabled", "budget_tokens": 10000}). + pub thinking: Option, + /// Skip cache_control / prompt_cache_key — set for one-shot calls like compaction where writes never read back. + pub disable_cache_control: bool, +} + +/// HTTP client for OpenAI-compatible /v1/chat/completions endpoint. +pub struct LlmClient { + config: LlmClientConfig, + http: reqwest::Client, +} + +impl LlmClient { + pub fn new(config: LlmClientConfig) -> Self { + Self { + config, + http: SHARED_HTTP_CLIENT.clone(), + } + } + + /// Send a streaming chat completion request and return the assembled response. + /// + /// Emits `AgentEvent::TextDelta` via `event_tx` as text tokens arrive. + pub async fn chat_completion( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + event_tx: &mpsc::Sender, + session_id: &str, + cancel_token: Option<&CancellationToken>, + ) -> Result { + let url = format!("{}/chat/completions", self.config.base_url.trim_end_matches('/')); + + let tools_option = if tools.is_empty() { + None + } else { + Some(tools.to_vec()) + }; + + // Sanitize: strip orphaned tool_result messages whose tool_use was lost + let mut sanitized_messages = { + let mut valid_ids = std::collections::HashSet::new(); + for msg in messages { + if let Some(ref tcs) = msg.tool_calls { + for tc in tcs { + valid_ids.insert(tc.id.as_str()); + } + } + } + let mut msgs: Vec = Vec::with_capacity(messages.len()); + for msg in messages { + if msg.role == "tool" { + if let Some(ref id) = msg.tool_call_id { + if !valid_ids.contains(id.as_str()) { + log::warn!("[LLM] Stripping orphaned tool_result: {}", id); + continue; + } + } + } + msgs.push(msg.clone()); + } + msgs + }; + + // Anthropic caching: hybrid strategy: + // - explicit cache_control on last tool (caches tool definitions) + // - explicit cache_control on system[0] (the static body, set by prompt.rs) + // - explicit cache_control on system[1] (skills list, set by prompt.rs + // when a registry is active) + // - top-level cache_control: ephemeral for automatic conversation-level + // advancement (Anthropic moves the breakpoint to the last cacheable + // block each turn, and uses the 20-block lookback to find prior writes) + // + // NOTE: Anthropic caps cache_control markers at 4 per request. With a + // skill registry active we hit the cap exactly. Adding a 5th cached + // prefix source (memory, CLAUDE.md, etc.) requires dropping one of: + // tools[last], system[0], system[1], or the top-level breakpoint. + // + // OpenAI caching: automatic on their side; we only pass a prompt_cache_key + // routing hint for consistent machine affinity. + let is_anthropic = self.config.model.starts_with("claude-"); + let cache_enabled = !self.config.disable_cache_control; + + let prompt_cache_key = if is_anthropic || !cache_enabled { + None + } else { + Some(session_id.to_string()) + }; + + // Top-level auto cache_control: Anthropic places a breakpoint on the last + // cacheable block each turn, caching the conversation prefix. Combined with + // our explicit markers on tools[last] + system[0], this gives the biggest + // possible write (6K+ tokens vs ~3.8K with explicit-only). Experimentally + // doesn't affect the ~60% hit rate we see for Sonnet 4.6 (which appears to + // stem from Anthropic-side cache routing non-determinism), but when it DOES + // hit, the savings are ~60% larger. Keep enabled. + let top_level_cache_control = if is_anthropic && cache_enabled { + Some(CacheControl::ephemeral()) + } else { + None + }; + + // Mark the last tool with cache_control so Anthropic caches the full + // tool-definitions prefix. Only mutate for Claude — OpenAI ignores it + // anyway, but keeping the field absent on the OpenAI wire is cleaner. + let tools_option = if let Some(mut t) = tools_option { + if is_anthropic && cache_enabled { + if let Some(last) = t.last_mut() { + last.cache_control = Some(CacheControl::ephemeral()); + } + } + Some(t) + } else { + None + }; + + // Strip cache_control from message content blocks for non-Claude models OR when + // caching is disabled. prompt.rs always sets cache_control on system block 0. + if !is_anthropic || !cache_enabled { + for msg in &mut sanitized_messages { + if let Some(MessageContent::Blocks(ref mut blocks)) = msg.content { + for block in blocks.iter_mut() { + if let ContentBlock::Text { ref mut cache_control, .. } = block { + *cache_control = None; + } + } + } + } + } + + let request_body = ChatCompletionRequest { + model: self.config.model.clone(), + messages: sanitized_messages, + stream: true, + stream_options: Some(StreamOptions { + include_usage: true, + }), + tools: tools_option, + tool_choice: if tools.is_empty() { + None + } else { + Some("auto".to_string()) + }, + parallel_tool_calls: if tools.is_empty() { None } else { Some(true) }, + max_completion_tokens: self.config.max_completion_tokens, + temperature: self.config.temperature, + thinking: self.config.thinking.clone(), + prompt_cache_key, + cache_control: top_level_cache_control, + }; + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + for (key, value) in &self.config.auth_headers { + match (key.parse::(), value.parse::()) { + (Ok(name), Ok(val)) => { headers.insert(name, val); } + _ => log::warn!("[LLM] Skipping invalid auth header key={key:?} — bad name or non-ASCII value"), + } + } + + // Estimate request size for debugging + let msg_count = messages.len(); + let approx_chars: usize = messages.iter().map(|m| { + m.content.as_ref().map(|c| c.text().len()).unwrap_or(0) + + m.tool_calls.as_ref().map(|tc| tc.iter().map(|t| t.function.arguments.len()).sum::()).unwrap_or(0) + }).sum(); + log::info!( + "[LLM] POST {} — model={}, messages={}, ~{}chars, tools={}", + url, self.config.model, msg_count, approx_chars, tools.len() + ); + + // At debug level, log the serialized outbound body. Useful when diagnosing + // cache_control breakpoints (expect `"cache_control":{"type":"ephemeral","ttl":"1h"}` + // at system[0], system[1] when skills active, tools[last], and request-level). + if log::log_enabled!(log::Level::Debug) { + match serde_json::to_string(&request_body) { + Ok(json) => log::debug!("[LLM] request body: {json}"), + Err(e) => log::debug!("[LLM] request body serialize failed: {e}"), + } + } + + let response = match self + .http + .post(&url) + .headers(headers) + .json(&request_body) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + log::error!("[LLM] HTTP request failed: {e}"); + return Err(e.into()); + } + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + log::error!("[LLM] API error {}: {}", status, &body[..body.len().min(500)]); + return Err(AgentError::LlmApiError { + status: status.as_u16(), + body, + }); + } + + log::info!("[LLM] Streaming response started (status {})", status); + let byte_stream = response.bytes_stream(); + parse_sse_stream(byte_stream, event_tx, session_id, cancel_token).await + } +} diff --git a/crates/agent/src/llm/mod.rs b/crates/agent/src/llm/mod.rs new file mode 100644 index 00000000..e27eec06 --- /dev/null +++ b/crates/agent/src/llm/mod.rs @@ -0,0 +1,6 @@ +pub mod types; +pub mod sse; +pub mod client; + +pub use client::{LlmClient, LlmClientConfig, LlmProvider}; +pub use types::{ChatMessage, ToolDefinition, ToolCall, LlmResponse, Usage, MessageContent, ContentBlock, ImageUrlContent}; diff --git a/crates/agent/src/llm/sse.rs b/crates/agent/src/llm/sse.rs new file mode 100644 index 00000000..50ea9789 --- /dev/null +++ b/crates/agent/src/llm/sse.rs @@ -0,0 +1,860 @@ +use std::collections::HashMap; +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; + +use crate::error::AgentError; +use crate::types::AgentEvent; +use super::types::*; + +/// Maximum SSE buffer size (1 MB). If a single line exceeds this, the stream +/// is considered malformed / adversarial and we bail rather than eating memory. +const MAX_BUFFER_SIZE: usize = 1024 * 1024; + +/// Maximum time to wait for the next SSE chunk before considering the stream stalled. +/// Anthropic sends keepalive pings every ~15-30s during extended thinking, so 60s +/// provides ample margin while still catching genuinely dead connections. +const CHUNK_IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Accumulator for a single tool call being assembled from streaming chunks. +#[derive(Debug)] +struct ToolCallAccumulator { + id: String, + name: String, + arguments: String, +} + +/// Parse an SSE byte stream from the OpenAI chat completions API into an LlmResponse. +/// +/// Emits TextDelta and ThinkingDelta events as tokens arrive. Tool calls are accumulated +/// internally and returned in the final LlmResponse. +pub async fn parse_sse_stream( + mut stream: impl futures::Stream> + Unpin, + event_tx: &mpsc::Sender, + session_id: &str, + cancel_token: Option<&CancellationToken>, +) -> Result { + let mut buffer = String::new(); + let mut accumulated_text = String::new(); + let mut accumulated_thinking = String::new(); + let mut tool_accumulators: HashMap = HashMap::new(); + let mut usage: Option = None; + let mut finish_reason: Option = None; + + loop { + // Race the next SSE chunk against cancellation and an idle timeout. + // The idle timeout (CHUNK_IDLE_TIMEOUT) resets on each chunk, so active + // streams are never killed — only stalled ones. + let chunk_result = if let Some(token) = cancel_token { + tokio::select! { + biased; // check cancel first for faster response + _ = token.cancelled() => { + log::info!("[SSE] Stream cancelled by user"); + return Err(AgentError::Cancelled); + } + timed = timeout(CHUNK_IDLE_TIMEOUT, stream.next()) => match timed { + Ok(Some(result)) => result, + Ok(None) => break, // stream ended + Err(_) => { + log::error!("[SSE] No chunk received for {}s — aborting", CHUNK_IDLE_TIMEOUT.as_secs()); + return Err(AgentError::ChunkTimeout(CHUNK_IDLE_TIMEOUT.as_secs())); + } + } + } + } else { + match timeout(CHUNK_IDLE_TIMEOUT, stream.next()).await { + Ok(Some(result)) => result, + Ok(None) => break, + Err(_) => { + log::error!("[SSE] No chunk received for {}s — aborting", CHUNK_IDLE_TIMEOUT.as_secs()); + return Err(AgentError::ChunkTimeout(CHUNK_IDLE_TIMEOUT.as_secs())); + } + } + }; + let chunk_bytes = chunk_result.map_err(AgentError::HttpError)?; + buffer.push_str(&String::from_utf8_lossy(&chunk_bytes)); + + // Guard against unbounded buffer growth (malformed / adversarial stream) + if buffer.len() > MAX_BUFFER_SIZE { + return Err(AgentError::LlmParseError(format!( + "SSE buffer exceeded {} bytes without a newline — aborting", + MAX_BUFFER_SIZE + ))); + } + + // Process complete lines from the buffer + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); + buffer.replace_range(..=newline_pos, ""); + + // Skip empty lines and SSE comments + if line.is_empty() || line.starts_with(':') { + continue; + } + + // Must be a data: line + let data = if let Some(data) = line.strip_prefix("data: ") { + data.trim() + } else if let Some(data) = line.strip_prefix("data:") { + data.trim() + } else { + continue; + }; + + // End of stream + if data == "[DONE]" { + return Ok(build_response( + accumulated_text, + accumulated_thinking, + tool_accumulators, + usage, + finish_reason, + )); + } + + // Parse the JSON chunk + let chunk: ChatCompletionChunk = match serde_json::from_str(data) { + Ok(c) => c, + Err(e) => { + log::warn!("Failed to parse SSE chunk: {e} — data: {data}"); + continue; + } + }; + + // Handle usage-only final chunk (choices is empty) + if chunk.choices.is_empty() { + if chunk.usage.is_some() { + usage = chunk.usage; + } + continue; + } + + let choice = &chunk.choices[0]; + + // Capture finish_reason + if choice.finish_reason.is_some() { + finish_reason = choice.finish_reason.clone(); + } + + // Accumulate thinking text + if let Some(ref thinking) = choice.delta.thinking { + if !thinking.is_empty() { + accumulated_thinking.push_str(thinking); + let _ = event_tx + .send(AgentEvent::ThinkingDelta { + session_id: session_id.to_string(), + delta: thinking.clone(), + }) + .await; + } + } + + // Accumulate text content + if let Some(ref content) = choice.delta.content { + if !content.is_empty() { + accumulated_text.push_str(content); + let _ = event_tx + .send(AgentEvent::TextDelta { + session_id: session_id.to_string(), + delta: content.clone(), + }) + .await; + } + } + + // Accumulate tool calls + if let Some(ref tool_calls) = choice.delta.tool_calls { + for tc_chunk in tool_calls { + let acc = tool_accumulators + .entry(tc_chunk.index) + .or_insert_with(|| ToolCallAccumulator { + id: String::new(), + name: String::new(), + arguments: String::new(), + }); + + if let Some(ref id) = tc_chunk.id { + if !id.is_empty() { + acc.id = id.clone(); + } + } + + if let Some(ref func) = tc_chunk.function { + if let Some(ref name) = func.name { + if !name.is_empty() { + acc.name = name.clone(); + } + } + if let Some(ref args) = func.arguments { + acc.arguments.push_str(args); + } + } + } + } + + // Capture usage from chunks that also have choices + if chunk.usage.is_some() { + usage = chunk.usage; + } + } + } + + // Stream ended without [DONE] — still return what we have + Ok(build_response( + accumulated_text, + accumulated_thinking, + tool_accumulators, + usage, + finish_reason, + )) +} + +fn build_response( + accumulated_text: String, + accumulated_thinking: String, + tool_accumulators: HashMap, + usage: Option, + finish_reason: Option, +) -> LlmResponse { + let content = if accumulated_text.is_empty() { + None + } else { + Some(accumulated_text) + }; + + let thinking = if accumulated_thinking.is_empty() { + None + } else { + Some(accumulated_thinking) + }; + + // Sort tool calls by index for deterministic ordering + let mut tool_entries: Vec<(u32, ToolCallAccumulator)> = tool_accumulators.into_iter().collect(); + tool_entries.sort_by_key(|(idx, _)| *idx); + + let tool_calls: Vec = tool_entries + .into_iter() + .filter(|(_, acc)| { + if acc.id.is_empty() || acc.name.is_empty() { + log::warn!( + "[SSE] Dropping malformed tool call: id={:?}, name={:?} — gateway never sent required fields", + acc.id, acc.name + ); + false + } else { + true + } + }) + .map(|(_, acc)| ToolCall { + id: acc.id, + type_: "function".to_string(), + function: FunctionCall { + name: acc.name, + arguments: acc.arguments, + }, + }) + .collect(); + + LlmResponse { + content, + tool_calls, + usage, + finish_reason, + thinking, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::stream; + use tokio::sync::mpsc; + + /// Helper to create an SSE byte stream from raw SSE text lines. + fn sse_stream(lines: &str) -> impl futures::Stream> + Unpin { + let bytes = Bytes::from(lines.to_string()); + Box::pin(stream::once(async move { Ok(bytes) })) + } + + /// Helper to create an SSE stream from multiple chunks (simulates TCP fragmentation). + fn sse_stream_chunks(chunks: Vec<&str>) -> impl futures::Stream> + Unpin { + let chunks: Vec> = chunks + .into_iter() + .map(|s| Ok(Bytes::from(s.to_string()))) + .collect(); + Box::pin(stream::iter(chunks)) + } + + #[tokio::test] + async fn test_text_only_stream() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, mut rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("Hello world")); + assert!(response.tool_calls.is_empty()); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + assert!(response.thinking.is_none()); + + // Check that TextDelta events were emitted + let mut deltas = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let AgentEvent::TextDelta { delta, .. } = event { + deltas.push(delta); + } + } + assert_eq!(deltas, vec!["Hello", " world"]); + } + + #[tokio::test] + async fn test_tool_call_stream() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"file\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Path\\\":\\\"test.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert!(response.content.is_none()); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].id, "call_abc"); + assert_eq!(response.tool_calls[0].function.name, "read"); + assert_eq!( + response.tool_calls[0].function.arguments, + "{\"filePath\":\"test.rs\"}" + ); + assert_eq!(response.finish_reason.as_deref(), Some("tool_calls")); + } + + #[tokio::test] + async fn test_multiple_tool_calls() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Reading files.\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"/a.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"/b.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("Reading files.")); + assert_eq!(response.tool_calls.len(), 2); + assert_eq!(response.tool_calls[0].function.name, "read"); + assert_eq!(response.tool_calls[1].function.name, "read"); + assert_eq!(response.tool_calls[0].id, "call_1"); + assert_eq!(response.tool_calls[1].id, "call_2"); + } + + #[tokio::test] + async fn test_usage_in_final_chunk() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("Hi")); + let usage = response.usage.unwrap(); + assert_eq!(usage.prompt_tokens, 10); + assert_eq!(usage.completion_tokens, 5); + assert_eq!(usage.total_tokens, 15); + } + + #[tokio::test] + async fn test_split_json_across_tcp_chunks() { + // JSON split across two TCP chunks + let chunks = vec![ + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel", + "lo\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + ]; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream_chunks(chunks); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("Hello")); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + } + + #[tokio::test] + async fn test_tool_args_split_across_tcp_chunks() { + // TCP chunk boundary falls right in the middle of a tool-call SSE data line, + // so the JSON for the arguments straddles two network reads. + let chunks = vec![ + // Chunk 1: header + start of first argument chunk SSE line (cut mid-JSON) + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ + data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_x\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"file\"}}]},\"finish_reason\":null}]}\n\n\ + data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Pa", + // Chunk 2: rest of the argument line + finish + DONE + "th\\\":\\\"src/main.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ + data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ + data: [DONE]\n\n", + ]; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream_chunks(chunks); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].id, "call_x"); + assert_eq!(response.tool_calls[0].function.name, "read"); + // The arguments were split across two SSE events AND across a TCP boundary + assert_eq!( + response.tool_calls[0].function.arguments, + "{\"filePath\":\"src/main.rs\"}" + ); + } + + #[tokio::test] + async fn test_sse_buffer_overflow_rejected() { + // Simulate a malformed stream: one huge chunk with no newline + let giant_chunk = "x".repeat(1024 * 1024 + 1); + let stream = sse_stream(&giant_chunk); + let (tx, _rx) = mpsc::channel(32); + let result = parse_sse_stream(stream, &tx, "test-session", None).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("SSE buffer exceeded")); + } + + #[tokio::test] + async fn test_sse_comment_lines_ignored() { + let sse_data = "\ +: this is a comment\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n\ +: another comment\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("ok")); + } + + #[tokio::test] + async fn test_stream_ends_without_done() { + // Stream sends text chunks but ends abruptly — no [DONE] marker. + // The parser should still return whatever was accumulated. + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" response\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.content.as_deref(), Some("partial response")); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + } + + #[tokio::test] + async fn test_stream_ends_without_done_with_tool_calls() { + // Stream sends a tool call and then ends without [DONE]. + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"a.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].function.name, "read"); + assert_eq!(response.finish_reason.as_deref(), Some("tool_calls")); + } + + // ════════════════════════════════════════════ + // §1: SSE Parser Robustness Tests + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_tool_call_chunk_missing_index() { + // Tool call chunk without "index" field — should default to 0 + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"call_no_idx\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"test.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].id, "call_no_idx"); + assert_eq!(response.tool_calls[0].function.name, "read"); + } + + #[tokio::test] + async fn test_tool_call_empty_name_not_overwritten() { + // Gateway sends name in first chunk, then empty name in subsequent chunks + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"file\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"Path\\\":\\\"test.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].function.name, "read"); + } + + #[tokio::test] + async fn test_tool_call_empty_id_not_overwritten() { + // Gateway sends id in first chunk, then empty id in subsequent chunks + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_real\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"file\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"function\":{\"arguments\":\"Path\\\":\\\"test.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].id, "call_real"); + } + + #[tokio::test] + async fn test_finish_reason_tool_use() { + // Anthropic-style finish_reason via gateway + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"a.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_use\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.finish_reason.as_deref(), Some("tool_use")); + assert_eq!(response.tool_calls.len(), 1); + } + + #[tokio::test] + async fn test_finish_reason_end_turn() { + // Anthropic-style finish_reason via gateway + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"end_turn\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.finish_reason.as_deref(), Some("end_turn")); + assert_eq!(response.content.as_deref(), Some("done")); + } + + // ════════════════════════════════════════════ + // §3: Thinking Tests + // ════════════════════════════════════════════ + + #[tokio::test] + async fn test_thinking_delta_stream() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"thinking\":\"Let me solve\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"thinking\":\" this step\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"thinking\":\" by step\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"x = 6\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, mut rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.thinking.as_deref(), Some("Let me solve this step by step")); + assert_eq!(response.content.as_deref(), Some("x = 6")); + + // Check ThinkingDelta events + let mut thinking_deltas = Vec::new(); + let mut text_deltas = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AgentEvent::ThinkingDelta { delta, .. } => thinking_deltas.push(delta), + AgentEvent::TextDelta { delta, .. } => text_deltas.push(delta), + _ => {} + } + } + assert_eq!(thinking_deltas, vec!["Let me solve", " this step", " by step"]); + assert_eq!(text_deltas, vec!["x = 6"]); + } + + #[tokio::test] + async fn test_thinking_then_tool_call_stream() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"thinking\":\"I should read the file first\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"filePath\\\":\\\"a.rs\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert_eq!(response.thinking.as_deref(), Some("I should read the file first")); + assert_eq!(response.tool_calls.len(), 1); + assert!(response.content.is_none()); + } + + #[tokio::test] + async fn test_thinking_ignored_when_absent() { + let sse_data = "\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let (tx, _rx) = mpsc::channel(32); + let stream = sse_stream(sse_data); + let response = parse_sse_stream(stream, &tx, "test-session", None).await.unwrap(); + + assert!(response.thinking.is_none()); + assert_eq!(response.content.as_deref(), Some("Hello")); + } + + // ════════════════════════════════════════════ + // §4: Image / MessageContent Tests + // ════════════════════════════════════════════ + + #[test] + fn test_message_content_text_serialization() { + let content = MessageContent::Text("hello".to_string()); + let json = serde_json::to_string(&content).unwrap(); + assert_eq!(json, "\"hello\""); + } + + #[test] + fn test_message_content_blocks_serialization() { + let content = MessageContent::Blocks(vec![ + ContentBlock::Text { text: "Look at this:".to_string(), cache_control: None }, + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "data:image/png;base64,abc123".to_string(), + detail: Some("auto".to_string()), + }, + }, + ]); + let json = serde_json::to_string(&content).unwrap(); + assert!(json.starts_with('[')); + assert!(json.contains("\"type\":\"text\"")); + assert!(json.contains("\"type\":\"image_url\"")); + } + + #[test] + fn test_message_content_deserialization_both() { + // String form + let text: MessageContent = serde_json::from_str("\"hello\"").unwrap(); + assert_eq!(text.text(), "hello"); + + // Array form + let blocks: MessageContent = serde_json::from_str( + r#"[{"type":"text","text":"world"},{"type":"image_url","image_url":{"url":"data:image/png;base64,x"}}]"# + ).unwrap(); + assert_eq!(blocks.text(), "world"); + assert!(blocks.has_images()); + } + + #[test] + fn test_message_content_text_helper() { + assert_eq!(MessageContent::Text("hello".into()).text(), "hello"); + assert_eq!( + MessageContent::Blocks(vec![ + ContentBlock::ImageUrl { + image_url: ImageUrlContent { url: "x".into(), detail: None }, + }, + ContentBlock::Text { text: "found".into(), cache_control: None }, + ]).text(), + "found" + ); + // No text block → empty string + assert_eq!( + MessageContent::Blocks(vec![ + ContentBlock::ImageUrl { + image_url: ImageUrlContent { url: "x".into(), detail: None }, + }, + ]).text(), + "" + ); + } + + #[test] + fn test_message_content_has_images() { + assert!(!MessageContent::Text("hello".into()).has_images()); + assert!(MessageContent::Blocks(vec![ + ContentBlock::ImageUrl { + image_url: ImageUrlContent { url: "x".into(), detail: None }, + }, + ]).has_images()); + assert!(!MessageContent::Blocks(vec![ + ContentBlock::Text { text: "no images".into(), cache_control: None }, + ]).has_images()); + } + + #[test] + fn test_user_with_images_constructor() { + let msg = ChatMessage::user_with_images(vec![ + ContentBlock::Text { text: "Look:".into(), cache_control: None }, + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "data:image/png;base64,abc".into(), + detail: Some("auto".into()), + }, + }, + ]); + assert_eq!(msg.role, "user"); + assert!(msg.content.as_ref().unwrap().has_images()); + } + + #[test] + fn test_backward_compat_old_json() { + // Old-format JSON with content as a bare string should deserialize correctly + let json = r#"{"role":"user","content":"hello old world"}"#; + let msg: ChatMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.content.as_ref().unwrap().text(), "hello old world"); + assert!(msg.thinking.is_none()); + } + + #[test] + fn test_chat_message_thinking_roundtrip() { + let msg = ChatMessage::assistant( + Some("answer".into()), + None, + Some("I thought about it".into()), + ); + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"thinking\":\"I thought about it\"")); + + let deserialized: ChatMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.thinking.as_deref(), Some("I thought about it")); + assert_eq!(deserialized.content.as_ref().unwrap().text(), "answer"); + } + + #[test] + fn test_chat_message_no_thinking_roundtrip() { + let msg = ChatMessage::assistant(Some("answer".into()), None, None); + let json = serde_json::to_string(&msg).unwrap(); + assert!(!json.contains("thinking")); + } + + /// Creates a stream backed by an mpsc channel. Returns (sender, stream). + /// The stream stays open until the sender is dropped. + fn channel_stream() -> ( + tokio::sync::mpsc::Sender>, + std::pin::Pin> + Unpin + Send>>, + ) { + let (tx, mut rx) = tokio::sync::mpsc::channel::>(16); + let stream = futures::stream::poll_fn(move |cx| rx.poll_recv(cx)); + (tx, Box::pin(stream)) + } + + #[tokio::test(start_paused = true)] + async fn test_chunk_idle_timeout_fires_on_stalled_stream() { + // Stream sends one valid chunk then stalls forever. + // With tokio time paused, the 60s idle timeout advances instantly. + let (stream_tx, stream) = channel_stream(); + + let (event_tx, _event_rx) = mpsc::channel(32); + + // Send one chunk (no [DONE] — stream stays open) + stream_tx.send(Ok(Bytes::from( + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n" + ))).await.unwrap(); + + // Don't send anything else — let the idle timeout fire + let result = parse_sse_stream(stream, &event_tx, "test-session", None).await; + assert!( + matches!(result, Err(AgentError::ChunkTimeout(60))), + "expected ChunkTimeout(60), got: {result:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_chunk_idle_timeout_does_not_fire_when_data_flows() { + // Stream sends chunks with delays shorter than the idle timeout. + // All chunks arrive "in time", so the stream completes normally. + let (stream_tx, stream) = channel_stream(); + + let (event_tx, _event_rx) = mpsc::channel(32); + + // Spawn a task that sends chunks with 30s gaps (well under the 60s timeout) + tokio::spawn(async move { + stream_tx.send(Ok(Bytes::from( + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n" + ))).await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + + stream_tx.send(Ok(Bytes::from( + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n" + ))).await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + + stream_tx.send(Ok(Bytes::from( + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n" + ))).await.unwrap(); + }); + + let result = parse_sse_stream(stream, &event_tx, "test-session", None).await; + let response = result.expect("stream should complete successfully"); + assert_eq!(response.content.as_deref(), Some("Hello world")); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + } + + #[test] + fn test_request_with_thinking_config() { + let req = ChatCompletionRequest { + model: "test".into(), + messages: vec![], + stream: true, + stream_options: None, + tools: None, + tool_choice: None, + parallel_tool_calls: None, + max_completion_tokens: None, + temperature: None, + thinking: Some(serde_json::json!({"type": "enabled", "budget_tokens": 10000})), + prompt_cache_key: None, + cache_control: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"thinking\"")); + assert!(json.contains("budget_tokens")); + } +} diff --git a/crates/agent/src/llm/types.rs b/crates/agent/src/llm/types.rs new file mode 100644 index 00000000..727b7c58 --- /dev/null +++ b/crates/agent/src/llm/types.rs @@ -0,0 +1,397 @@ +use serde::{Deserialize, Serialize}; + +// ── Prompt caching ── + +/// Marks a block/tool/request as a prompt-cache breakpoint for Anthropic. +/// OpenAI ignores this field (their caching is automatic). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheControl { + #[serde(rename = "type")] + pub type_: String, // "ephemeral" + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, // "5m" (default) or "1h" +} + +impl CacheControl { + /// Ephemeral cache with 1h TTL. Anthropic's 5m default writes cheaper (1.25× base) + /// but expires fast; 1h writes cost 2× base and survives typical user idle gaps. + pub fn ephemeral() -> Self { + Self { type_: "ephemeral".into(), ttl: Some("1h".into()) } + } +} + +// ── Request types ── + +#[derive(Debug, Serialize)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Extended thinking config (e.g., {"type": "enabled", "budget_tokens": 10000}). + /// Gateway forwards to Anthropic, maps to reasoning for OpenAI. + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + /// OpenAI prompt-cache routing hint. Pins identical-prefix requests to the + /// same cache machine for higher hit rate. Set to session id for OpenAI-family + /// models; omitted for Anthropic (they use explicit cache_control markers). + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + /// Top-level cache_control enables Anthropic's automatic conversation-level + /// breakpoint (advances to the last cacheable block each turn). Set only + /// for claude-* models. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_control: Option, +} + +#[derive(Debug, Serialize)] +pub struct StreamOptions { + pub include_usage: bool, +} + +// ── Content block types for multi-modal messages ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ContentBlock { + #[serde(rename = "text")] + Text { + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + + #[serde(rename = "image_url")] + ImageUrl { image_url: ImageUrlContent }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageUrlContent { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Message content — either a plain string or an array of content blocks. +/// Uses `#[serde(untagged)]` so `"hello"` → `Text("hello")` and `[{...}]` → `Blocks(...)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + Text(String), + Blocks(Vec), +} + +impl MessageContent { + /// Extract the text content. For blocks, returns the first text block's content. + pub fn text(&self) -> &str { + match self { + MessageContent::Text(s) => s, + MessageContent::Blocks(blocks) => { + blocks.iter().find_map(|b| match b { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }).unwrap_or("") + } + } + } + + pub fn has_images(&self) -> bool { + match self { + MessageContent::Text(_) => false, + MessageContent::Blocks(blocks) => blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. })), + } + } +} + +impl Default for MessageContent { + fn default() -> Self { + MessageContent::Text(String::new()) + } +} + +// ── Message types (flat struct for serde compatibility) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Thinking text from Anthropic extended thinking (simple string). + /// Preserved for multi-turn round-tripping — gateway reconstructs + /// Anthropic's structured thinking blocks from this string. + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, +} + +impl ChatMessage { + pub fn system(content: impl Into) -> Self { + Self { + role: "system".into(), + content: Some(MessageContent::Text(content.into())), + tool_calls: None, + tool_call_id: None, + name: None, + thinking: None, + } + } + + pub fn user(content: impl Into) -> Self { + Self { + role: "user".into(), + content: Some(MessageContent::Text(content.into())), + tool_calls: None, + tool_call_id: None, + name: None, + thinking: None, + } + } + + pub fn user_with_images(blocks: Vec) -> Self { + Self { + role: "user".into(), + content: Some(MessageContent::Blocks(blocks)), + tool_calls: None, + tool_call_id: None, + name: None, + thinking: None, + } + } + + pub fn assistant( + content: Option, + tool_calls: Option>, + thinking: Option, + ) -> Self { + Self { + role: "assistant".into(), + content: content.map(MessageContent::Text), + tool_calls, + tool_call_id: None, + name: None, + thinking, + } + } + + pub fn tool_result(tool_call_id: impl Into, content: impl Into) -> Self { + Self { + role: "tool".into(), + content: Some(MessageContent::Text(content.into())), + tool_calls: None, + tool_call_id: Some(tool_call_id.into()), + name: None, + thinking: None, + } + } +} + +// ── Tool definition ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDefinition { + #[serde(rename = "type")] + pub type_: String, + pub function: FunctionDefinition, + /// When set on the LAST tool in a request, Anthropic caches the full + /// tool-definitions prefix. Placed at the tool wrapper level (not inside + /// FunctionDefinition) to match Anthropic's native tool-block shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_control: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionDefinition { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +// ── Tool call (in assistant response) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub type_: String, + pub function: FunctionCall, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +// ── Stream chunk types ── + +#[derive(Debug, Deserialize)] +pub struct ChatCompletionChunk { + pub id: String, + pub choices: Vec, + pub usage: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChunkChoice { + pub index: u32, + pub delta: ChunkDelta, + pub finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChunkDelta { + pub role: Option, + pub content: Option, + pub tool_calls: Option>, + /// Thinking text delta from Anthropic via gateway. + pub thinking: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ToolCallChunk { + #[serde(default)] + pub index: u32, + pub id: Option, + #[serde(rename = "type")] + pub type_: Option, + pub function: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FunctionCallChunk { + pub name: Option, + pub arguments: Option, +} + +// ── Usage ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, + /// Cache token breakdown when prompt caching is active. + /// Populated for both OpenAI (`prompt_tokens_details.cached_tokens`) and + /// Anthropic (mapped from `cache_read_input_tokens` + `cache_creation_input_tokens` + /// by the gateway). Absent for providers/responses that don't emit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_tokens_details: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PromptTokensDetails { + /// Tokens served from cache this request (read tier). + /// OpenAI: `cached_tokens`. Anthropic: `cache_read_input_tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, + /// Tokens written to cache this request (write tier). + /// OpenAI: not distinguished. Anthropic: `cache_creation_input_tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_tokens: Option, +} + +// ── Assembled LLM response ── + +#[derive(Debug)] +pub struct LlmResponse { + pub content: Option, + pub tool_calls: Vec, + pub usage: Option, + pub finish_reason: Option, + /// Accumulated thinking text from this response. + pub thinking: Option, +} + +#[cfg(test)] +mod cache_control_tests { + use super::*; + + #[test] + fn test_cache_control_serializes_compactly() { + // ephemeral() emits {"type":"ephemeral","ttl":"1h"} with no null ttl. + let cc = CacheControl::ephemeral(); + let json = serde_json::to_string(&cc).unwrap(); + assert_eq!(json, r#"{"type":"ephemeral","ttl":"1h"}"#); + } + + #[test] + fn test_cache_control_with_ttl_serializes_ttl() { + let cc = CacheControl { type_: "ephemeral".into(), ttl: Some("1h".into()) }; + let json = serde_json::to_string(&cc).unwrap(); + assert!(json.contains(r#""ttl":"1h""#)); + } + + #[test] + fn test_content_block_text_without_cache_control_omits_field() { + let block = ContentBlock::Text { text: "hi".into(), cache_control: None }; + let json = serde_json::to_string(&block).unwrap(); + assert!(!json.contains("cache_control"), "absent field must not serialize: {json}"); + } + + #[test] + fn test_content_block_text_with_cache_control_serializes_nested() { + let block = ContentBlock::Text { + text: "hi".into(), + cache_control: Some(CacheControl::ephemeral()), + }; + let json = serde_json::to_string(&block).unwrap(); + assert_eq!( + json, + r#"{"type":"text","text":"hi","cache_control":{"type":"ephemeral","ttl":"1h"}}"# + ); + } + + #[test] + fn test_tool_definition_omits_cache_control_when_none() { + let tool = ToolDefinition { + type_: "function".into(), + function: FunctionDefinition { + name: "read".into(), + description: None, + parameters: None, + }, + cache_control: None, + }; + let json = serde_json::to_string(&tool).unwrap(); + assert!(!json.contains("cache_control")); + } + + #[test] + fn test_tool_definition_emits_cache_control_at_wrapper_level() { + let tool = ToolDefinition { + type_: "function".into(), + function: FunctionDefinition { + name: "read".into(), + description: None, + parameters: None, + }, + cache_control: Some(CacheControl::ephemeral()), + }; + let json = serde_json::to_string(&tool).unwrap(); + // cache_control must sit alongside `type` and `function`, not inside `function`. + assert!(json.contains(r#""cache_control":{"type":"ephemeral","ttl":"1h"}"#)); + // Crude structural check that cache_control is NOT inside the function object. + let function_idx = json.find("\"function\"").unwrap(); + let cc_idx = json.find("\"cache_control\"").unwrap(); + assert!(cc_idx > function_idx, "cache_control should appear after function in wire order"); + } +} + diff --git a/crates/agent/src/persistence.rs b/crates/agent/src/persistence.rs new file mode 100644 index 00000000..b2a730d8 --- /dev/null +++ b/crates/agent/src/persistence.rs @@ -0,0 +1,322 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; + +/// Role of the message sender from the LLM perspective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageRole { + User, + Assistant, + Tool, + System, +} + +/// Classification of message content. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageType { + Text, + SessionInit, + Compaction, + ToolCall, + ToolResult, + CompletionSummary, +} + +/// Who originated the message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Sender { + HumanUser, + Agent, +} + +pub fn str_to_role(s: &str) -> MessageRole { + match s { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "tool" => MessageRole::Tool, + "system" => MessageRole::System, + _ => { + log::warn!("Unknown message role: '{s}', defaulting to User"); + MessageRole::User + } + } +} + +pub fn str_to_type(s: &str) -> MessageType { + match s { + "text" => MessageType::Text, + "session_init" => MessageType::SessionInit, + "compaction" => MessageType::Compaction, + "tool_call" => MessageType::ToolCall, + "tool_result" => MessageType::ToolResult, + "completion_summary" => MessageType::CompletionSummary, + _ => { + log::warn!("Unknown message type: '{s}', defaulting to Text"); + MessageType::Text + } + } +} + +/// Persistence envelope — separate from ChatMessage (LLM wire format). +/// Wraps both human-readable content and the full LLM wire-format message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentMessage { + /// Human-readable content for display in chat UI. + pub content: String, + /// Full OpenAI wire-format message (ChatMessage serialized). + pub llm_message: serde_json::Value, + /// Operational metadata JSON. + pub metadata: serde_json::Value, + /// Role from the LLM perspective. + pub role: MessageRole, + /// Classification of this message. + pub message_type: MessageType, + /// Who originated this message. + pub sender: Sender, + /// Whether to also post this to the main DM channel. + pub also_send_to_channel: bool, + /// Which agent loop iteration (turn) produced this message. + /// Set by the agent loop from its iteration counter. + pub turn_count: Option, +} + +/// Result of a successful persist operation. +#[derive(Debug, Clone)] +pub struct PersistResult { + pub id: String, +} + +/// Errors from persistence operations. +#[derive(Debug, thiserror::Error)] +pub enum PersistError { + #[error("Storage error: {0}")] + Storage(String), + #[error("Not found: {0}")] + NotFound(String), +} + +/// Trait for persisting agent messages to external storage. +#[async_trait] +pub trait MessagePersister: Send + Sync { + /// Persist a single message, optionally associated with a thread. + async fn persist_message( + &self, + message: &AgentMessage, + thread_id: Option<&str>, + ) -> Result; + + /// Load all messages for a coding session (thread). + async fn load_session_context( + &self, + thread_id: &str, + ) -> Result, PersistError>; + + /// Load ask-mode context (messages not associated with any thread). + async fn load_ask_context(&self) -> Result, PersistError>; +} + +/// Builds a child persister that stamps `parent_thread_id` on every insert. +/// Implemented by the Tauri layer (SQLite-backed) so `spawn_subagent` stays +/// Tauri-agnostic. A child AgentLoop receives the `Arc` +/// returned from `for_subagent` and writes to it as normal. +pub trait PersisterFactory: Send + Sync { + fn for_subagent(&self, parent_thread_id: &str) -> Arc; +} + +/// In-memory mock persister for testing. +pub struct MockPersister { + messages: Arc, AgentMessage)>>>, +} + +impl MockPersister { + pub fn new() -> Self { + Self { + messages: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Access all stored (thread_id, message) pairs for test assertions. + pub fn messages(&self) -> Vec<(Option, AgentMessage)> { + self.messages.lock().unwrap().clone() + } +} + +impl Default for MockPersister { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl MessagePersister for MockPersister { + async fn persist_message( + &self, + message: &AgentMessage, + thread_id: Option<&str>, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + self.messages + .lock() + .unwrap() + .push((thread_id.map(String::from), message.clone())); + Ok(PersistResult { id }) + } + + async fn load_session_context( + &self, + thread_id: &str, + ) -> Result, PersistError> { + let msgs = self + .messages + .lock() + .unwrap() + .iter() + .filter(|(tid, _)| tid.as_deref() == Some(thread_id)) + .map(|(_, msg)| msg.clone()) + .collect(); + Ok(msgs) + } + + async fn load_ask_context(&self) -> Result, PersistError> { + let msgs = self + .messages + .lock() + .unwrap() + .iter() + .filter(|(tid, _)| tid.is_none()) + .map(|(_, msg)| msg.clone()) + .collect(); + Ok(msgs) + } +} + +/// Discards every write and returns empty contexts. Used as a SessionManager +/// default when production callers always pass a per-call `persister_override` +/// — guarantees the default is never silently exercised in production. +pub struct NoopPersister; + +#[async_trait] +impl MessagePersister for NoopPersister { + async fn persist_message( + &self, + _message: &AgentMessage, + _thread_id: Option<&str>, + ) -> Result { + Ok(PersistResult { + id: String::new(), + }) + } + + async fn load_session_context( + &self, + _thread_id: &str, + ) -> Result, PersistError> { + Ok(Vec::new()) + } + + async fn load_ask_context(&self) -> Result, PersistError> { + Ok(Vec::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_message(content: &str, role: MessageRole, msg_type: MessageType) -> AgentMessage { + AgentMessage { + content: content.to_string(), + llm_message: json!({"role": "user", "content": content}), + metadata: json!({}), + role, + message_type: msg_type, + sender: Sender::HumanUser, + also_send_to_channel: false, + turn_count: None, + } + } + + #[tokio::test] + async fn test_persist_and_load_round_trip() { + let persister = MockPersister::new(); + + let msg = make_message("hello", MessageRole::User, MessageType::Text); + let result = persister.persist_message(&msg, None).await.unwrap(); + assert!(!result.id.is_empty()); + + let ask_msgs = persister.load_ask_context().await.unwrap(); + assert_eq!(ask_msgs.len(), 1); + assert_eq!(ask_msgs[0].content, "hello"); + } + + #[tokio::test] + async fn test_multiple_threads_isolated() { + let persister = MockPersister::new(); + + let msg_a = make_message("thread-a msg", MessageRole::User, MessageType::Text); + let msg_b = make_message("thread-b msg", MessageRole::User, MessageType::Text); + + persister + .persist_message(&msg_a, Some("thread-a")) + .await + .unwrap(); + persister + .persist_message(&msg_b, Some("thread-b")) + .await + .unwrap(); + + let a_msgs = persister.load_session_context("thread-a").await.unwrap(); + assert_eq!(a_msgs.len(), 1); + assert_eq!(a_msgs[0].content, "thread-a msg"); + + let b_msgs = persister.load_session_context("thread-b").await.unwrap(); + assert_eq!(b_msgs.len(), 1); + assert_eq!(b_msgs[0].content, "thread-b msg"); + } + + #[tokio::test] + async fn test_ask_context_excludes_thread_messages() { + let persister = MockPersister::new(); + + let ask_msg = make_message("ask msg", MessageRole::User, MessageType::Text); + let thread_msg = make_message("thread msg", MessageRole::User, MessageType::Text); + + persister.persist_message(&ask_msg, None).await.unwrap(); + persister + .persist_message(&thread_msg, Some("thread-1")) + .await + .unwrap(); + + let ask_msgs = persister.load_ask_context().await.unwrap(); + assert_eq!(ask_msgs.len(), 1); + assert_eq!(ask_msgs[0].content, "ask msg"); + } + + #[tokio::test] + async fn test_compaction_records_found_by_type() { + let persister = MockPersister::new(); + + let text_msg = make_message("regular", MessageRole::User, MessageType::Text); + let compact_msg = make_message("summary", MessageRole::System, MessageType::Compaction); + + persister + .persist_message(&text_msg, Some("thread-1")) + .await + .unwrap(); + persister + .persist_message(&compact_msg, Some("thread-1")) + .await + .unwrap(); + + let msgs = persister.load_session_context("thread-1").await.unwrap(); + assert_eq!(msgs.len(), 2); + + let compaction_msgs: Vec<_> = msgs + .iter() + .filter(|m| m.message_type == MessageType::Compaction) + .collect(); + assert_eq!(compaction_msgs.len(), 1); + assert_eq!(compaction_msgs[0].content, "summary"); + } +} diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs new file mode 100644 index 00000000..d95624a0 --- /dev/null +++ b/crates/agent/src/session.rs @@ -0,0 +1,719 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::{mpsc, RwLock}; +use tokio_util::sync::CancellationToken; + +use crate::agent::config::AgentConfig; +use crate::agent::loop_::AgentLoop; +use crate::approval::ApprovalHandler; +use crate::error::AgentError; +use crate::llm::client::{LlmClient, LlmProvider}; +use crate::llm::types::ChatMessage; +use crate::persistence::MessagePersister; +use crate::tool::{ToolMode, ToolRegistry}; +use crate::types::AgentEvent; + +pub type SessionId = String; + +/// Status of a session. +#[derive(Debug, Clone, serde::Serialize)] +pub enum SessionStatus { + Active, + Completed, + Error(String), +} + +/// Summary info for listing sessions. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SessionSummary { + pub id: SessionId, + pub status: SessionStatus, + pub mode: ToolMode, + pub project_path: Option, + pub branch: Option, +} + +/// Internal handle for a running session. +struct SessionHandle { + cancel_token: CancellationToken, + status: SessionStatus, + mode: ToolMode, + project_path: Option, + branch: Option, +} + +/// Manages multiple concurrent agent sessions. +pub struct SessionManager { + sessions: Arc>>, + persister: Arc, +} + +impl SessionManager { + pub fn new(persister: Arc) -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + persister, + } + } + + /// Start an ask-mode session. Returns a receiver for events. + /// `initial_context` carries prior ask-mode messages for conversation continuity. + /// Always treated as a resume (messages already exist in SQLite). + /// `persister_override`: when `Some`, the session uses this per-call persister + /// instead of the manager's default. Production callers always supply a + /// per-session persister with the project_path frozen at construction (H1). + #[allow(clippy::too_many_arguments)] + pub async fn start_ask_session( + &self, + session_id: SessionId, + config: AgentConfig, + message: ChatMessage, + initial_context: Option>, + initial_token_count: Option, + approval_handler: Option>, + persister_override: Option>, + ) -> Result, AgentError> { + // Use the mode from config (Ask or Plan) rather than hardcoding Ask + let mode = config.mode; + self.start_session_inner( + session_id, + config, + message, + mode, + None, + None, + initial_context, + None, + Some(String::new()), // persist with thread_id="" so load_ask_messages finds them + initial_token_count, + approval_handler, + None, // no turn offset for ask mode + true, // is_resume: ask mode loads its own prior messages, don't re-persist + persister_override, + ) + .await + } + + /// Start a coding session. Returns a receiver for events. + /// `initial_context` carries the sliding window of ask-mode messages (with their original roles). + /// `persist_thread_id` overrides the persistence key (defaults to session_id if None). + /// `is_resume`: true when resuming an existing thread (don't re-persist context), + /// false when creating a new thread from ask mode (persist as completion_summary). + /// `persister_override`: see `start_ask_session`. + #[allow(clippy::too_many_arguments)] + pub async fn start_coding_session( + &self, + session_id: SessionId, + config: AgentConfig, + message: ChatMessage, + worktree_path: Option, + branch: Option, + initial_context: Option>, + persist_thread_id: Option, + initial_token_count: Option, + approval_handler: Option>, + turn_offset: Option, + is_resume: bool, + persister_override: Option>, + ) -> Result, AgentError> { + self.start_session_inner( + session_id, + config, + message, + ToolMode::Coding, + worktree_path, + branch, + initial_context, + None, + persist_thread_id, + initial_token_count, + approval_handler, + turn_offset, + is_resume, + persister_override, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn start_session_inner( + &self, + session_id: SessionId, + config: AgentConfig, + message: ChatMessage, + mode: ToolMode, + project_path: Option, + branch: Option, + initial_context: Option>, + provider: Option>, + persist_thread_id: Option, + initial_token_count: Option, + approval_handler: Option>, + turn_offset: Option, + is_resume: bool, + persister_override: Option>, + ) -> Result, AgentError> { + let (event_tx, event_rx) = mpsc::channel(256); + let error_tx = event_tx.clone(); // Clone for the watcher to emit errors + let cancel_token = CancellationToken::new(); + let context_engine_arg = config.context_engine.as_ref().map(|engine| { + let repo_path = config.context_engine_repo_path.clone().unwrap_or_else(|| { + log::warn!( + "context_engine_repo_path not set; falling back to working_dir. \ + Worktree overlay will be disabled." + ); + config.working_dir.clone() + }); + (engine.clone(), repo_path) + }); + let mut registry = ToolRegistry::for_mode(mode, context_engine_arg, config.skills.clone()); + if let (Some(sub_reg), Some(inherit)) = (config.subagents.clone(), config.subagent_inheritance.clone()) { + registry.register_spawn_subagent(sub_reg, inherit); + } + let persister = persister_override.unwrap_or_else(|| Arc::clone(&self.persister)); + let thread_id = Some(persist_thread_id.unwrap_or_else(|| session_id.clone())); + + let client: Box = match provider { + Some(p) => p, + None => Box::new(LlmClient::new(config.llm.clone())), + }; + + let handle = { + let cancel_token = cancel_token.clone(); + let sid = session_id.clone(); + tokio::spawn(async move { + let mut agent_loop = AgentLoop::with_provider( + config, + client, + registry, + cancel_token, + event_tx, + sid.clone(), + ); + agent_loop = agent_loop.with_persister(persister, thread_id); + if let Some(h) = approval_handler { + agent_loop = agent_loop.with_approval_handler(h); + } + // Seed prior context if provided + if let Some(ref context_msgs) = initial_context { + log::info!( + "[Session {}] Seeding {} prior context messages (is_resume={})", + sid, context_msgs.len(), is_resume + ); + } + if let Some(context_msgs) = initial_context { + if is_resume { + // Same-scope resume: messages already exist in SQLite, just load into buffer + agent_loop = agent_loop.with_resumed_context(context_msgs); + } else { + // Cross-scope transfer (ask→coding): persist copies as completion_summary + agent_loop = agent_loop.with_initial_context(context_msgs); + } + } + // Seed persisted token count so compaction fires correctly on first turn + if let Some(tokens) = initial_token_count { + log::info!("[Session {}] Seeding persisted token count: {}", sid, tokens); + agent_loop = agent_loop.with_initial_token_count(tokens); + } + // Seed turn offset for globally unique checkpoint numbering + if let Some(offset) = turn_offset { + if offset > 0 { + log::info!("[Session {}] Seeding turn offset: {}", sid, offset); + agent_loop = agent_loop.with_turn_offset(offset); + } + } + log::info!("[Session {}] Starting agent loop with {} total messages", sid, agent_loop.message_count()); + agent_loop.run(message).await + }) + }; + + // Store session handle with Active status + let session_handle = SessionHandle { + cancel_token, + status: SessionStatus::Active, + mode, + project_path, + branch, + }; + + self.sessions + .write() + .await + .insert(session_id.clone(), session_handle); + + // Spawn a watcher that updates status when the task completes + // and emits error events so the frontend can exit the "thinking" state. + { + let sessions = Arc::clone(&self.sessions); + let sid = session_id.clone(); + tokio::spawn(async move { + let final_status = match handle.await { + Ok(Ok(ref result)) => { + log::info!("[Session {sid}] Completed: {result:?}"); + SessionStatus::Completed + } + Ok(Err(AgentError::Cancelled)) => { + log::info!("[Session {sid}] Cancelled"); + SessionStatus::Error("Cancelled".into()) + } + Ok(Err(ref e)) => { + log::error!("[Session {sid}] Failed: {e}"); + // Emit error event so the frontend exits "thinking" state + log::info!("[Session {sid}] Sending Error+Done events via error_tx..."); + match error_tx.send(AgentEvent::Error { + session_id: sid.clone(), + message: e.to_string(), + retrying: false, + }).await { + Ok(()) => log::info!("[Session {sid}] Error event sent successfully"), + Err(e) => log::error!("[Session {sid}] Failed to send Error event: {e}"), + } + match error_tx.send(AgentEvent::Done { + session_id: sid.clone(), + summary: None, + }).await { + Ok(()) => log::info!("[Session {sid}] Done event sent successfully"), + Err(e) => log::error!("[Session {sid}] Failed to send Done event: {e}"), + } + SessionStatus::Error(e.to_string()) + } + Err(ref e) => { + log::error!("[Session {sid}] Task panicked: {e}"); + let _ = error_tx.send(AgentEvent::Error { + session_id: sid.clone(), + message: format!("Agent task panicked: {e}"), + retrying: false, + }).await; + let _ = error_tx.send(AgentEvent::Done { + session_id: sid.clone(), + summary: None, + }).await; + SessionStatus::Error(format!("Task panicked: {e}")) + } + }; + let mut map = sessions.write().await; + if let Some(h) = map.get_mut(&sid) { + h.status = final_status; + } + }); + } + + Ok(event_rx) + } + + /// Start a session with a custom LLM provider (for testing with mocks). + #[cfg(test)] + pub async fn start_ask_session_with_provider( + &self, + session_id: SessionId, + config: AgentConfig, + message: ChatMessage, + provider: Box, + initial_context: Option>, + ) -> Result, AgentError> { + self.start_session_inner( + session_id, + config, + message, + ToolMode::Ask, + None, + None, + initial_context, + Some(provider), + None, + None, + None, + None, // no turn offset for test ask sessions + true, // is_resume: test ask sessions treat context as already-persisted + None, // tests use the manager's default persister (MockPersister) + ) + .await + } + + /// Start a coding session with a custom LLM provider (for testing with mocks). + #[cfg(test)] + pub async fn start_coding_session_with_provider( + &self, + session_id: SessionId, + config: AgentConfig, + message: ChatMessage, + initial_context: Option>, + provider: Box, + persist_thread_id: Option, + ) -> Result, AgentError> { + self.start_session_inner( + session_id, + config, + message, + ToolMode::Coding, + None, + None, + initial_context, + Some(provider), + persist_thread_id, + None, + None, + None, // no turn offset for test coding sessions + false, // is_resume=false: test coding sessions use with_initial_context (cross-scope default) + None, // tests use the manager's default persister (MockPersister) + ) + .await + } + + /// Cancel a running session. + pub async fn cancel_session(&self, session_id: &str) { + let sessions = self.sessions.read().await; + if let Some(handle) = sessions.get(session_id) { + handle.cancel_token.cancel(); + log::info!("[session {session_id}] cancel_token fired"); + // Status will be updated by the watcher when the task exits + } else { + log::debug!("[session {session_id}] cancel requested but session not found"); + } + } + + /// Get the status of a session. + pub async fn session_status(&self, session_id: &str) -> Option { + let sessions = self.sessions.read().await; + sessions.get(session_id).map(|h| h.status.clone()) + } + + /// List all sessions. + pub async fn list_sessions(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions + .iter() + .map(|(id, handle)| SessionSummary { + id: id.clone(), + status: handle.status.clone(), + mode: handle.mode, + project_path: handle.project_path.clone(), + branch: handle.branch.clone(), + }) + .collect() + } + + /// Remove a session from the manager, cancelling it first if still running. + pub async fn remove_session(&self, session_id: &str) { + let mut sessions = self.sessions.write().await; + if let Some(handle) = sessions.get(session_id) { + handle.cancel_token.cancel(); + } + sessions.remove(session_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::config::RetryConfig; + use crate::llm::LlmClientConfig; + use crate::llm::types::ChatMessage; + use crate::persistence::MockPersister; + use crate::test_util::{MockLlm, text_response}; + + fn test_config() -> AgentConfig { + let mut config = AgentConfig::new( + LlmClientConfig { + base_url: "http://unused".into(), + model: "unused".into(), + temperature: None, + max_completion_tokens: None, + auth_headers: vec![], + thinking: None, + disable_cache_control: false, + }, + PathBuf::from("/tmp"), + ); + config.retry_config = RetryConfig { + max_retries: 0, + initial_delay: std::time::Duration::from_millis(1), + multiplier: 1.0, + max_delay: std::time::Duration::from_millis(10), + }; + config + } + + #[tokio::test] + async fn test_start_ask_session_emits_events() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + let mock = MockLlm::new(vec![Ok(text_response("Hello!"))]); + + let mut rx = manager + .start_ask_session_with_provider( + "session-1".into(), + test_config(), + ChatMessage::user("Hi"), + Box::new(mock), + None, + ) + .await + .unwrap(); + + // Collect events + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + + assert!(events.iter().any(|e| matches!(e, AgentEvent::Done { .. }))); + } + + #[tokio::test] + async fn test_cancel_session() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + + let fast_mock = MockLlm::new(vec![Ok(text_response("done"))]); + let _rx = manager + .start_ask_session_with_provider( + "session-cancel".into(), + test_config(), + ChatMessage::user("test"), + Box::new(fast_mock), + None, + ) + .await + .unwrap(); + + manager.cancel_session("session-cancel").await; + + // Yield to let the watcher task update the status + tokio::task::yield_now().await; + tokio::task::yield_now().await; + + let status = manager.session_status("session-cancel").await; + assert!(matches!(status, Some(SessionStatus::Error(_)) | Some(SessionStatus::Completed))); + } + + #[tokio::test] + async fn test_list_sessions() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + + let mock1 = MockLlm::new(vec![Ok(text_response("a"))]); + let mock2 = MockLlm::new(vec![Ok(text_response("b"))]); + + let _rx1 = manager + .start_ask_session_with_provider( + "s1".into(), + test_config(), + ChatMessage::user("test1"), + Box::new(mock1), + None, + ) + .await + .unwrap(); + + let _rx2 = manager + .start_ask_session_with_provider( + "s2".into(), + test_config(), + ChatMessage::user("test2"), + Box::new(mock2), + None, + ) + .await + .unwrap(); + + let list = manager.list_sessions().await; + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn test_session_not_found_error() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + + let status = manager.session_status("nonexistent").await; + assert!(status.is_none()); + } + + #[tokio::test] + async fn test_remove_session() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + + let mock = MockLlm::new(vec![Ok(text_response("done"))]); + let _rx = manager + .start_ask_session_with_provider( + "to-remove".into(), + test_config(), + ChatMessage::user("test"), + Box::new(mock), + None, + ) + .await + .unwrap(); + + manager.remove_session("to-remove").await; + assert!(manager.session_status("to-remove").await.is_none()); + } + + #[tokio::test] + async fn test_session_status_updates_to_completed() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(persister); + + let mock = MockLlm::new(vec![Ok(text_response("done"))]); + let mut rx = manager + .start_ask_session_with_provider( + "complete-test".into(), + test_config(), + ChatMessage::user("test"), + Box::new(mock), + None, + ) + .await + .unwrap(); + + // Drain all events — the session completes when the channel closes + while rx.recv().await.is_some() {} + + // Give the watcher task a moment to update the status + tokio::task::yield_now().await; + tokio::task::yield_now().await; + + let status = manager.session_status("complete-test").await; + assert!( + matches!(status, Some(SessionStatus::Completed)), + "Expected Completed, got: {:?}", + status + ); + } + + #[tokio::test] + async fn test_start_coding_session_with_initial_context() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(Arc::clone(&persister) as Arc); + + let mock = MockLlm::new(vec![Ok(text_response("I see prior context!"))]); + let context = vec![ + ChatMessage::user("What does main.rs do?"), + ChatMessage::assistant(Some("It starts the server.".into()), None, None), + ]; + + let mut rx = manager + .start_coding_session_with_provider( + "coding-ctx".into(), + test_config(), + ChatMessage::user("Now fix the bug in main.rs"), + Some(context), + Box::new(mock), + None, + ) + .await + .unwrap(); + + // Drain events + while rx.recv().await.is_some() {} + + // Give watcher + persistence worker time to finish + tokio::task::yield_now().await; + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Verify session completed + let status = manager.session_status("coding-ctx").await; + assert!( + matches!(status, Some(SessionStatus::Completed)), + "Expected Completed, got: {:?}", + status + ); + + // Verify initial context was persisted (user + assistant from sliding window) + let msgs = persister.messages(); + let contents: Vec<&str> = msgs.iter().map(|(_, m)| m.content.as_str()).collect(); + assert!( + contents.contains(&"What does main.rs do?"), + "Initial context user message should be persisted. Got: {:?}", + contents + ); + assert!( + contents.contains(&"It starts the server."), + "Initial context assistant message should be persisted. Got: {:?}", + contents + ); + } + + #[tokio::test] + async fn test_persist_thread_id_routes_correctly() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(Arc::clone(&persister) as Arc); + + let mock = MockLlm::new(vec![Ok(text_response("Done!"))]); + + // Start a coding session with a custom persist_thread_id + let mut rx = manager + .start_coding_session_with_provider( + "session-uuid-123".into(), + test_config(), + ChatMessage::user("Fix the bug"), + None, + Box::new(mock), + Some("real-thread-id".into()), // This should be the persistence key + ) + .await + .unwrap(); + + // Drain events + while rx.recv().await.is_some() {} + + // Give persistence worker time to finish + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Messages should be stored under "real-thread-id", not "session-uuid-123" + let msgs = persister.messages(); + let thread_ids: Vec> = msgs.iter().map(|(tid, _)| tid.as_deref()).collect(); + + assert!( + !thread_ids.is_empty(), + "Should have persisted messages" + ); + assert!( + thread_ids.iter().all(|tid| *tid == Some("real-thread-id")), + "All messages should be stored under 'real-thread-id', got: {:?}", + thread_ids + ); + assert!( + !thread_ids.iter().any(|tid| *tid == Some("session-uuid-123")), + "No messages should be stored under the session UUID" + ); + } + + #[tokio::test] + async fn test_persist_thread_id_defaults_to_session_id() { + let persister = Arc::new(MockPersister::new()); + let manager = SessionManager::new(Arc::clone(&persister) as Arc); + + let mock = MockLlm::new(vec![Ok(text_response("Done!"))]); + + // Start without persist_thread_id (None) — should use session_id + let mut rx = manager + .start_coding_session_with_provider( + "my-session".into(), + test_config(), + ChatMessage::user("Hello"), + None, + Box::new(mock), + None, // No custom persist_thread_id + ) + .await + .unwrap(); + + while rx.recv().await.is_some() {} + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let msgs = persister.messages(); + let thread_ids: Vec> = msgs.iter().map(|(tid, _)| tid.as_deref()).collect(); + + assert!( + thread_ids.iter().all(|tid| *tid == Some("my-session")), + "Messages should default to session_id for persistence, got: {:?}", + thread_ids + ); + } +} diff --git a/crates/agent/src/skills/mod.rs b/crates/agent/src/skills/mod.rs new file mode 100644 index 00000000..f8b47625 --- /dev/null +++ b/crates/agent/src/skills/mod.rs @@ -0,0 +1,13 @@ +pub mod parse; +pub mod registry; +pub mod tool; + +pub use parse::{parse_skill_md, ParsedSkill, SkillParseError}; +pub use registry::{Origin, Skill, SkillRegistry}; +pub use tool::SkillTool; + +/// Skills embedded in the binary at compile time. Sourced from +/// `src-tauri/agent/default-skills/`. In v1 the directory is empty +/// (just a `.gitkeep`). Iterating child dirs yields zero entries. +pub static DEFAULT_SKILLS: include_dir::Dir<'_> = + include_dir::include_dir!("$CARGO_MANIFEST_DIR/default-skills"); diff --git a/crates/agent/src/skills/parse.rs b/crates/agent/src/skills/parse.rs new file mode 100644 index 00000000..ad349f95 --- /dev/null +++ b/crates/agent/src/skills/parse.rs @@ -0,0 +1,152 @@ +use std::sync::OnceLock; + +use regex::Regex; +use serde::Deserialize; + +use crate::frontmatter::{split_frontmatter, FrontmatterError}; + +const NAME_MAX: usize = 64; +const DESC_MAX: usize = 1024; + +fn name_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + // TODO(skills): pattern permits all-hyphen names like `-`, `---`. Pure UX + // wart — a skill named `---` renders as "- ---: description" in the list + // but still looks up correctly. Add an alphanumeric requirement if we care. + RE.get_or_init(|| Regex::new(r"^[a-z0-9-]{1,64}$").expect("static regex")) +} + +/// Parsed contents of a SKILL.md file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedSkill { + pub name: String, + pub description: String, + pub body: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SkillParseError { + #[error("missing frontmatter delimiter")] + MissingFrontmatter, + #[error("invalid YAML frontmatter: {0}")] + InvalidYaml(String), + #[error("invalid skill name `{0}` (must match ^[a-z0-9-]{{1,64}}$)")] + InvalidName(String), + #[error("description must be 1-{} characters", DESC_MAX)] + InvalidDescription, +} + +#[derive(Deserialize)] +struct Frontmatter { + name: String, + description: String, +} + +/// Parse a SKILL.md file: extract YAML frontmatter between `---` delimiters, +/// validate `name` and `description`, return the body as the remaining prose. +pub fn parse_skill_md(raw: &str) -> Result { + let fm_raw = split_frontmatter(raw).map_err(|e| match e { + FrontmatterError::MissingDelimiter => SkillParseError::MissingFrontmatter, + })?; + + let fm: Frontmatter = serde_yml::from_str(fm_raw.yaml) + .map_err(|e| SkillParseError::InvalidYaml(e.to_string()))?; + + if fm.name.is_empty() || fm.name.len() > NAME_MAX || !name_regex().is_match(&fm.name) { + return Err(SkillParseError::InvalidName(fm.name)); + } + if fm.description.is_empty() || fm.description.len() > DESC_MAX { + return Err(SkillParseError::InvalidDescription); + } + + Ok(ParsedSkill { + name: fm.name, + description: fm.description, + body: fm_raw.body.trim_start_matches(['\r', '\n']).to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_skill() { + let raw = "---\nname: hello-skill\ndescription: A greeting.\n---\nBody here\n"; + let parsed = parse_skill_md(raw).unwrap(); + assert_eq!(parsed.name, "hello-skill"); + assert_eq!(parsed.description, "A greeting."); + assert_eq!(parsed.body, "Body here\n"); + } + + #[test] + fn parses_crlf() { + let raw = "---\r\nname: x\r\ndescription: y\r\n---\r\nbody\r\n"; + let parsed = parse_skill_md(raw).unwrap(); + assert_eq!(parsed.name, "x"); + assert_eq!(parsed.body, "body\r\n"); + } + + #[test] + fn parses_crlf_file_without_trailing_newline() { + // Windows notepad-authored file ending exactly at the close delimiter. + let raw = "---\r\nname: x\r\ndescription: y\r\n---"; + let parsed = parse_skill_md(raw).unwrap(); + assert_eq!(parsed.name, "x"); + assert_eq!(parsed.body, ""); + } + + #[test] + fn rejects_missing_frontmatter() { + assert!(matches!( + parse_skill_md("no delimiter here"), + Err(SkillParseError::MissingFrontmatter) + )); + } + + #[test] + fn rejects_bad_name_with_uppercase() { + let raw = "---\nname: Hello\ndescription: x\n---\nbody"; + assert!(matches!( + parse_skill_md(raw), + Err(SkillParseError::InvalidName(_)) + )); + } + + #[test] + fn rejects_bad_name_with_underscore() { + let raw = "---\nname: bad_name\ndescription: x\n---\nbody"; + assert!(matches!( + parse_skill_md(raw), + Err(SkillParseError::InvalidName(_)) + )); + } + + #[test] + fn rejects_empty_description() { + let raw = "---\nname: ok\ndescription: ''\n---\nbody"; + assert!(matches!( + parse_skill_md(raw), + Err(SkillParseError::InvalidDescription) + )); + } + + #[test] + fn rejects_name_too_long() { + let name: String = "a".repeat(65); + let raw = format!("---\nname: {name}\ndescription: x\n---\nbody"); + assert!(matches!( + parse_skill_md(&raw), + Err(SkillParseError::InvalidName(_)) + )); + } + + #[test] + fn rejects_invalid_yaml() { + let raw = "---\nname: [not-a-string\ndescription: x\n---\nbody"; + assert!(matches!( + parse_skill_md(raw), + Err(SkillParseError::InvalidYaml(_)) + )); + } +} diff --git a/crates/agent/src/skills/registry.rs b/crates/agent/src/skills/registry.rs new file mode 100644 index 00000000..d93ab679 --- /dev/null +++ b/crates/agent/src/skills/registry.rs @@ -0,0 +1,329 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use super::parse::{parse_skill_md, ParsedSkill}; + +/// Where a skill was discovered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Origin { + /// Embedded in the binary at compile time. + Default, + /// User-global app data directory. + Global, + /// Project-scoped (`/.agent/skills/`). + Project, +} + +/// A fully loaded skill ready to be injected into the prompt list. +#[derive(Debug, Clone)] +pub struct Skill { + pub name: String, + pub description: String, + pub body: String, + pub origin: Origin, + /// Filesystem path for display. For embedded defaults this is a virtual + /// `defaults://` URI. + pub path: PathBuf, +} + +/// Approximation of Anthropic's token count: chars/4. Matches the agent's +/// existing `estimate_token_count` heuristic. +const MAX_BODY_TOKENS: usize = 20_000; + +/// A raw input to the registry: the SKILL.md contents plus the display path. +#[derive(Debug, Clone)] +pub struct SkillInput { + pub raw: String, + pub path: PathBuf, +} + +/// The in-memory skill registry built once per agent spawn. +pub struct SkillRegistry { + skills: HashMap, +} + +impl SkillRegistry { + /// Build a registry from the three tiers. Later tiers override earlier + /// ones on name collision (project > global > default). Malformed skills + /// are skipped with a warning log and never block startup. Skills whose + /// name appears in `disabled` are filtered out. + pub fn new( + default: Vec, + global: Vec, + project: Vec, + disabled: &HashSet, + ) -> Self { + let mut skills: HashMap = HashMap::new(); + + for (origin, inputs) in [ + (Origin::Default, default), + (Origin::Global, global), + (Origin::Project, project), + ] { + for input in inputs { + match load_one(input, origin) { + Ok(skill) => { + skills.insert(skill.name.clone(), skill); + } + Err(msg) => { + log::warn!("[skills] skipped skill: {msg}"); + } + } + } + } + + skills.retain(|name, _| !disabled.contains(name)); + + Self { skills } + } + + pub fn is_empty(&self) -> bool { + self.skills.is_empty() + } + + pub fn len(&self) -> usize { + self.skills.len() + } + + pub fn get(&self, name: &str) -> Option<&Skill> { + self.skills.get(name) + } + + /// Iterator over `(name, description)` pairs, sorted by name for + /// deterministic prompt output. + pub fn list_for_prompt(&self) -> Vec<(&str, &str)> { + let mut out: Vec<_> = self + .skills + .values() + .map(|s| (s.name.as_str(), s.description.as_str())) + .collect(); + out.sort_by_key(|(n, _)| *n); + out + } + + /// All loaded skills, sorted by name. + pub fn all(&self) -> Vec<&Skill> { + let mut out: Vec<_> = self.skills.values().collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out + } + + /// Available skill names, sorted — used in error messages when the LLM + /// calls the `skill` tool with an unknown name. + pub fn names(&self) -> Vec { + let mut out: Vec = self.skills.keys().cloned().collect(); + out.sort(); + out + } +} + +fn load_one(input: SkillInput, origin: Origin) -> Result { + let parsed: ParsedSkill = parse_skill_md(&input.raw) + .map_err(|e| format!("{}: parse error: {e}", input.path.display()))?; + + let approx_tokens = parsed.body.len() / 4; + if approx_tokens > MAX_BODY_TOKENS { + return Err(format!( + "{}: body too large ({} > {} tokens)", + input.path.display(), + approx_tokens, + MAX_BODY_TOKENS + )); + } + + Ok(Skill { + name: parsed.name, + description: parsed.description, + body: parsed.body, + origin, + path: input.path, + }) +} + +/// Helper for Tauri-side discovery: walk an immediate-children directory and +/// load each `/SKILL.md` into a `SkillInput`. Missing root is fine. +/// +/// Symlinks are skipped at BOTH levels: +/// - the child directory entry itself (prevents `skills/evil -> /etc`) +/// - the SKILL.md file inside it (prevents `skills/evil/SKILL.md -> ~/.ssh/id_rsa`) +/// +/// Both matter: a malicious repo cloned locally could plant either form in +/// `/.agent/skills/`, and without the file-level check the contents would +/// be read and injected into the system prompt (leaking to the LLM provider). +pub fn read_tier_from_fs(root: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return out; + }; + for entry in entries.flatten() { + if entry + .file_type() + .map(|ft| ft.is_symlink()) + .unwrap_or(false) + { + log::warn!("[skills] skipping symlink dir entry: {}", entry.path().display()); + continue; + } + let path = entry.path(); + let skill_md = path.join("SKILL.md"); + // `symlink_metadata` does NOT follow symlinks, so we can reject the file + // before `read_to_string` (which DOES follow) slurps anything outside. + match std::fs::symlink_metadata(&skill_md) { + Ok(meta) if meta.file_type().is_symlink() => { + log::warn!( + "[skills] skipping symlinked SKILL.md: {}", + skill_md.display() + ); + continue; + } + Ok(_) => {} + Err(_) => continue, // missing / unreadable — nothing to load + } + if let Ok(raw) = std::fs::read_to_string(&skill_md) { + out.push(SkillInput { raw, path }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk(raw: &str, path: &str) -> SkillInput { + SkillInput { + raw: raw.to_string(), + path: PathBuf::from(path), + } + } + + #[test] + fn empty_registry() { + let reg = SkillRegistry::new(vec![], vec![], vec![], &HashSet::new()); + assert!(reg.is_empty()); + } + + #[test] + fn loads_and_sorts() { + let a = mk("---\nname: bravo\ndescription: B.\n---\nbody b", "/b"); + let b = mk("---\nname: alpha\ndescription: A.\n---\nbody a", "/a"); + let reg = SkillRegistry::new(vec![], vec![a, b], vec![], &HashSet::new()); + let list = reg.list_for_prompt(); + assert_eq!(list, vec![("alpha", "A."), ("bravo", "B.")]); + } + + #[test] + fn project_overrides_global_overrides_default() { + let d = mk("---\nname: s\ndescription: default.\n---\nfrom default", "/d"); + let g = mk("---\nname: s\ndescription: global.\n---\nfrom global", "/g"); + let p = mk("---\nname: s\ndescription: project.\n---\nfrom project", "/p"); + let reg = SkillRegistry::new(vec![d], vec![g], vec![p], &HashSet::new()); + let skill = reg.get("s").unwrap(); + assert_eq!(skill.origin, Origin::Project); + assert_eq!(skill.description, "project."); + assert!(skill.body.contains("from project")); + } + + #[test] + fn global_overrides_default() { + let d = mk("---\nname: s\ndescription: default.\n---\nfrom default", "/d"); + let g = mk("---\nname: s\ndescription: global.\n---\nfrom global", "/g"); + let reg = SkillRegistry::new(vec![d], vec![g], vec![], &HashSet::new()); + assert_eq!(reg.get("s").unwrap().origin, Origin::Global); + } + + #[test] + fn malformed_skipped_others_loaded() { + let bad = mk("no frontmatter here", "/bad"); + let good = mk("---\nname: good\ndescription: ok.\n---\nbody", "/good"); + let reg = SkillRegistry::new(vec![], vec![bad, good], vec![], &HashSet::new()); + assert_eq!(reg.len(), 1); + assert!(reg.get("good").is_some()); + } + + #[test] + fn oversized_rejected() { + let huge_body = "x".repeat(20_001 * 4 + 10); + let raw = format!("---\nname: huge\ndescription: big.\n---\n{huge_body}"); + let reg = SkillRegistry::new(vec![], vec![mk(&raw, "/h")], vec![], &HashSet::new()); + assert!(reg.is_empty()); + } + + #[test] + fn disabled_filtered() { + let a = mk("---\nname: on-skill\ndescription: A.\n---\nbody", "/a"); + let b = mk("---\nname: off-skill\ndescription: B.\n---\nbody", "/b"); + let mut disabled = HashSet::new(); + disabled.insert("off-skill".to_string()); + let reg = SkillRegistry::new(vec![], vec![a, b], vec![], &disabled); + assert!(reg.get("on-skill").is_some()); + assert!(reg.get("off-skill").is_none()); + } + + // ── read_tier_from_fs symlink guards ── + + #[cfg(unix)] + #[test] + fn read_tier_skips_symlinked_skill_md_file() { + // Sets up: tmp/ + // good/SKILL.md (real file, valid frontmatter) + // evil/SKILL.md (symlink to secret.txt outside the tier root) + // secret.txt + let tmp = tempfile::tempdir().unwrap(); + let tier = tmp.path(); + + // Good skill — should be loaded. + let good_dir = tier.join("good"); + std::fs::create_dir(&good_dir).unwrap(); + std::fs::write( + good_dir.join("SKILL.md"), + "---\nname: good\ndescription: Valid.\n---\nbody", + ) + .unwrap(); + + // Secret lives outside the tier root; point a symlink at it. + let secret = tmp.path().join("secret.txt"); + std::fs::write(&secret, "SENSITIVE").unwrap(); + let evil_dir = tier.join("evil"); + std::fs::create_dir(&evil_dir).unwrap(); + std::os::unix::fs::symlink(&secret, evil_dir.join("SKILL.md")).unwrap(); + + let inputs = read_tier_from_fs(tier); + // Only `good` should have been loaded; `evil` rejected by the symlink + // guard on the SKILL.md file. + assert_eq!(inputs.len(), 1); + assert!(inputs[0].raw.contains("name: good")); + assert!(!inputs.iter().any(|i| i.raw.contains("SENSITIVE"))); + } + + #[cfg(unix)] + #[test] + fn read_tier_skips_symlinked_skill_dir() { + let tmp = tempfile::tempdir().unwrap(); + let tier = tmp.path(); + + // A real skill dir to confirm the happy path still works. + let good_dir = tier.join("good"); + std::fs::create_dir(&good_dir).unwrap(); + std::fs::write( + good_dir.join("SKILL.md"), + "---\nname: good\ndescription: Valid.\n---\nbody", + ) + .unwrap(); + + // A dir symlink pointing to a SEPARATE tempdir's skill — the + // directory-level guard should reject the symlink. + let outside_tmp = tempfile::tempdir().unwrap(); + std::fs::write( + outside_tmp.path().join("SKILL.md"), + "---\nname: evil\ndescription: Elsewhere.\n---\nbody", + ) + .unwrap(); + std::os::unix::fs::symlink(outside_tmp.path(), tier.join("evil")).unwrap(); + + let inputs = read_tier_from_fs(tier); + assert_eq!(inputs.len(), 1); + assert!(inputs[0].raw.contains("name: good")); + } +} diff --git a/crates/agent/src/skills/tool.rs b/crates/agent/src/skills/tool.rs new file mode 100644 index 00000000..a3bdb39e --- /dev/null +++ b/crates/agent/src/skills/tool.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::tool::{Tool, ToolContext, ToolResult}; +use crate::types::AgentEvent; + +use super::registry::SkillRegistry; + +/// Tool that loads a skill body into the conversation on demand. +pub struct SkillTool { + registry: Arc, +} + +impl SkillTool { + pub fn new(registry: Arc) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for SkillTool { + fn name(&self) -> &str { + "skill" + } + + fn description(&self) -> &str { + "Load a skill's full instructions into the conversation. The skills list at the top of the \ + system prompt shows available names and descriptions. Call this when a task matches a \ + skill and you need the full body to follow its instructions." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the Available Skills list." + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: name".into()))?; + + log::info!("[skills] SkillTool::execute requested name={:?}", name); + + let Some(skill) = self.registry.get(name) else { + let available = self.registry.names().join(", "); + log::warn!( + "[skills] SkillTool::execute unknown name={:?}, available=[{}]", + name, + available + ); + return Ok(ToolResult::error(format!( + "Unknown skill `{name}`. Available skills: [{available}]" + ))); + }; + + log::info!( + "[skills] SkillTool::execute HIT name={} body_len={} origin={:?} — emitting SkillLoaded", + skill.name, + skill.body.len(), + skill.origin + ); + + let _ = ctx + .event_tx + .send(AgentEvent::SkillLoaded { + session_id: ctx.session_id.clone(), + skill_name: skill.name.clone(), + }) + .await; + + // Escape any literal in the body so it doesn't prematurely + // close the wrapper tag when the LLM parses the tool result. Accidental + // collisions happen when a skill documents the skill system itself. + // + // TODO(skills): case-sensitive exact match — variants like `` + // or `< /skill_content>` slip through. Low risk given the tag name is + // deliberately obscure, but worth revisiting if a meta-skill trips on it. + let escaped_body = skill.body.replace("", "<\\/skill_content>"); + + let output = format!( + "\n{}\n", + skill.name, escaped_body + ); + Ok(ToolResult::success(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skills::registry::SkillInput; + use std::collections::HashSet; + use std::path::PathBuf; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + fn mk_registry() -> Arc { + let s = SkillInput { + raw: "---\nname: hello\ndescription: A greeting skill.\n---\nBe a pirate.\n" + .to_string(), + path: PathBuf::from("/hello"), + }; + Arc::new(SkillRegistry::new( + vec![], + vec![s], + vec![], + &HashSet::new(), + )) + } + + #[tokio::test] + async fn returns_error_for_unknown_name() { + let tool = SkillTool::new(mk_registry()); + let (tx, _rx) = mpsc::channel(8); + let ctx = ToolContext { + working_dir: PathBuf::from("."), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "sess".into(), + tool_call_id: "tc_1".into(), + }; + let result = tool + .execute(json!({"name": "nope"}), &ctx) + .await + .expect("execute ok"); + assert!(result.is_error); + assert!(result.output.contains("Unknown skill")); + assert!(result.output.contains("hello")); + } + + #[tokio::test] + async fn loads_body_and_emits_event() { + let tool = SkillTool::new(mk_registry()); + let (tx, mut rx) = mpsc::channel(8); + let ctx = ToolContext { + working_dir: PathBuf::from("."), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "sess".into(), + tool_call_id: "tc_1".into(), + }; + let result = tool + .execute(json!({"name": "hello"}), &ctx) + .await + .expect("execute ok"); + assert!(!result.is_error); + assert!(result.output.contains("")); + assert!(result.output.contains("Be a pirate.")); + + let event = rx.recv().await.expect("event emitted"); + match event { + AgentEvent::SkillLoaded { session_id, skill_name } => { + assert_eq!(session_id, "sess"); + assert_eq!(skill_name, "hello"); + } + other => panic!("expected SkillLoaded, got {other:?}"), + } + } + + #[tokio::test] + async fn escapes_close_tag_in_body() { + let s = SkillInput { + raw: "---\nname: meta\ndescription: A skill that mentions in its body.\n---\nWhen you see in code, leave it alone.\n".to_string(), + path: PathBuf::from("/meta"), + }; + let registry = Arc::new(SkillRegistry::new( + vec![], + vec![s], + vec![], + &HashSet::new(), + )); + let tool = SkillTool::new(registry); + let (tx, _rx) = mpsc::channel(8); + let ctx = ToolContext { + working_dir: PathBuf::from("."), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "sess".into(), + tool_call_id: "tc_1".into(), + }; + let result = tool + .execute(json!({"name": "meta"}), &ctx) + .await + .expect("execute ok"); + assert!(!result.is_error); + // The wrapping appears exactly once, at the end. + assert_eq!(result.output.matches("").count(), 1); + // The body's original literal was escaped. + assert!(result.output.contains("<\\/skill_content>")); + } + + #[tokio::test] + async fn rejects_missing_name_arg() { + let tool = SkillTool::new(mk_registry()); + let (tx, _rx) = mpsc::channel(8); + let ctx = ToolContext { + working_dir: PathBuf::from("."), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "sess".into(), + tool_call_id: "tc_1".into(), + }; + let err = tool.execute(json!({}), &ctx).await.unwrap_err(); + assert!(err.0.contains("Missing")); + } +} diff --git a/crates/agent/src/subagents/mod.rs b/crates/agent/src/subagents/mod.rs new file mode 100644 index 00000000..837dfa7d --- /dev/null +++ b/crates/agent/src/subagents/mod.rs @@ -0,0 +1,14 @@ +pub mod parse; +pub mod registry; +pub mod tool; +pub mod write_lock; + +pub use parse::{parse_subagent_md, ParsedSubagent, SubagentParseError}; +pub use registry::{read_tier_from_fs, Origin, Subagent, SubagentInput, SubagentRegistry}; +pub use tool::{ApprovalHandlerFactory, SpawnSubagentTool, SubagentInheritance}; +pub use write_lock::WriteLockRegistry; + +/// Subagents embedded in the binary at compile time. Sourced from +/// `src-tauri/agent/default-subagents/`. Empty in v1 (just a `.gitkeep`). +pub static DEFAULT_SUBAGENTS: include_dir::Dir<'_> = + include_dir::include_dir!("$CARGO_MANIFEST_DIR/default-subagents"); diff --git a/crates/agent/src/subagents/parse.rs b/crates/agent/src/subagents/parse.rs new file mode 100644 index 00000000..390bc7ec --- /dev/null +++ b/crates/agent/src/subagents/parse.rs @@ -0,0 +1,173 @@ +use std::sync::OnceLock; + +use regex::Regex; +use serde::Deserialize; + +use crate::frontmatter::{split_frontmatter, FrontmatterError}; + +const NAME_MAX: usize = 64; +// Larger than skills' 1024 — the description is the primary LLM-facing signal +// for which subagent to pick, so authors need room to explain capabilities. +const DESC_MAX: usize = 2048; + +fn name_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[a-z0-9-]{1,64}$").expect("static regex")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedSubagent { + pub name: String, + pub description: String, + pub body: String, + pub allowed_tools: Option>, + pub model: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum SubagentParseError { + #[error("missing frontmatter delimiter")] + MissingFrontmatter, + #[error("invalid YAML frontmatter: {0}")] + InvalidYaml(String), + #[error("invalid subagent name `{0}` (must match ^[a-z0-9-]{{1,64}}$)")] + InvalidName(String), + #[error("description must be 1-{} characters", DESC_MAX)] + InvalidDescription, + #[error("allowed-tools must be a list of strings")] + InvalidAllowedTools, + #[error("model must be a non-empty string")] + InvalidModel, +} + +#[derive(Deserialize)] +struct Frontmatter { + name: String, + description: String, + #[serde(rename = "allowed-tools", default)] + allowed_tools: Option>, + #[serde(default)] + model: Option, +} + +pub fn parse_subagent_md(raw: &str) -> Result { + let fm_raw = split_frontmatter(raw).map_err(|e| match e { + FrontmatterError::MissingDelimiter => SubagentParseError::MissingFrontmatter, + })?; + + let fm: Frontmatter = serde_yml::from_str(fm_raw.yaml) + .map_err(|e| SubagentParseError::InvalidYaml(e.to_string()))?; + + if fm.name.is_empty() || fm.name.len() > NAME_MAX || !name_regex().is_match(&fm.name) { + return Err(SubagentParseError::InvalidName(fm.name)); + } + if fm.description.is_empty() || fm.description.len() > DESC_MAX { + return Err(SubagentParseError::InvalidDescription); + } + if let Some(tools) = &fm.allowed_tools { + if tools.iter().any(|t| t.is_empty()) { + return Err(SubagentParseError::InvalidAllowedTools); + } + } + if let Some(m) = &fm.model { + if m.is_empty() { + return Err(SubagentParseError::InvalidModel); + } + } + + Ok(ParsedSubagent { + name: fm.name, + description: fm.description, + body: fm_raw.body.trim_start_matches(['\r', '\n']).to_string(), + allowed_tools: fm.allowed_tools, + model: fm.model, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal() { + let raw = "---\nname: reviewer\ndescription: Reviews code.\n---\nBody."; + let p = parse_subagent_md(raw).unwrap(); + assert_eq!(p.name, "reviewer"); + assert_eq!(p.description, "Reviews code."); + assert_eq!(p.body, "Body."); + assert!(p.allowed_tools.is_none()); + assert!(p.model.is_none()); + } + + #[test] + fn parses_full_frontmatter() { + let raw = "---\nname: git-expert\ndescription: Git ops.\nallowed-tools:\n - read\n - bash\nmodel: claude-sonnet-4-6\n---\nBody."; + let p = parse_subagent_md(raw).unwrap(); + assert_eq!( + p.allowed_tools.as_deref(), + Some(["read".to_string(), "bash".to_string()].as_slice()) + ); + assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-6")); + } + + #[test] + fn rejects_missing_frontmatter() { + assert!(matches!( + parse_subagent_md("no delimiter"), + Err(SubagentParseError::MissingFrontmatter) + )); + } + + #[test] + fn rejects_bad_name() { + let raw = "---\nname: Bad_Name\ndescription: x\n---\n"; + assert!(matches!( + parse_subagent_md(raw), + Err(SubagentParseError::InvalidName(_)) + )); + } + + #[test] + fn rejects_oversized_description() { + let desc = "x".repeat(DESC_MAX + 1); + let raw = format!("---\nname: ok\ndescription: {desc}\n---\nbody"); + assert!(matches!( + parse_subagent_md(&raw), + Err(SubagentParseError::InvalidDescription) + )); + } + + #[test] + fn rejects_empty_allowed_tools_entry() { + let raw = "---\nname: ok\ndescription: d\nallowed-tools:\n - ''\n---\nb"; + assert!(matches!( + parse_subagent_md(raw), + Err(SubagentParseError::InvalidAllowedTools) + )); + } + + #[test] + fn rejects_empty_model() { + let raw = "---\nname: ok\ndescription: d\nmodel: ''\n---\nb"; + assert!(matches!( + parse_subagent_md(raw), + Err(SubagentParseError::InvalidModel) + )); + } + + #[test] + fn rejects_invalid_yaml() { + let raw = "---\nname: [not-a-string\ndescription: x\n---\nbody"; + assert!(matches!( + parse_subagent_md(raw), + Err(SubagentParseError::InvalidYaml(_)) + )); + } + + #[test] + fn description_2048_boundary() { + let desc = "x".repeat(DESC_MAX); + let raw = format!("---\nname: ok\ndescription: {desc}\n---\n"); + assert!(parse_subagent_md(&raw).is_ok()); + } +} diff --git a/crates/agent/src/subagents/registry.rs b/crates/agent/src/subagents/registry.rs new file mode 100644 index 00000000..d8495d7a --- /dev/null +++ b/crates/agent/src/subagents/registry.rs @@ -0,0 +1,307 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use super::parse::{parse_subagent_md, ParsedSubagent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Origin { + Default, + Global, + Project, +} + +#[derive(Debug, Clone)] +pub struct Subagent { + pub name: String, + pub description: String, + pub body: String, + pub allowed_tools: Option>, + pub model: Option, + pub origin: Origin, + pub path: PathBuf, +} + +const MAX_BODY_TOKENS: usize = 20_000; + +#[derive(Debug, Clone)] +pub struct SubagentInput { + pub raw: String, + pub path: PathBuf, +} + +pub struct SubagentRegistry { + agents: HashMap, +} + +impl SubagentRegistry { + pub fn new( + default: Vec, + global: Vec, + project: Vec, + disabled: &HashSet, + ) -> Self { + let mut agents: HashMap = HashMap::new(); + + for (origin, inputs) in [ + (Origin::Default, default), + (Origin::Global, global), + (Origin::Project, project), + ] { + for input in inputs { + match load_one(input, origin) { + Ok(agent) => { + agents.insert(agent.name.clone(), agent); + } + Err(msg) => { + log::warn!("[subagents] skipped: {msg}"); + } + } + } + } + + agents.retain(|name, _| !disabled.contains(name)); + + Self { agents } + } + + pub fn is_empty(&self) -> bool { + self.agents.is_empty() + } + + pub fn len(&self) -> usize { + self.agents.len() + } + + pub fn get(&self, name: &str) -> Option<&Subagent> { + self.agents.get(name) + } + + /// `(name, description)` sorted — for the `# Available Subagents` prompt block + /// and the `spawn_subagent` tool description. + pub fn list_for_prompt(&self) -> Vec<(&str, &str)> { + let mut out: Vec<_> = self + .agents + .values() + .map(|a| (a.name.as_str(), a.description.as_str())) + .collect(); + out.sort_by_key(|(n, _)| *n); + out + } + + pub fn all(&self) -> Vec<&Subagent> { + let mut out: Vec<_> = self.agents.values().collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out + } + + pub fn names(&self) -> Vec { + let mut out: Vec = self.agents.keys().cloned().collect(); + out.sort(); + out + } +} + +fn load_one(input: SubagentInput, origin: Origin) -> Result { + let parsed: ParsedSubagent = parse_subagent_md(&input.raw) + .map_err(|e| format!("{}: parse error: {e}", input.path.display()))?; + + let approx_tokens = parsed.body.len() / 4; + if approx_tokens > MAX_BODY_TOKENS { + return Err(format!( + "{}: body too large ({} > {} tokens)", + input.path.display(), + approx_tokens, + MAX_BODY_TOKENS + )); + } + + Ok(Subagent { + name: parsed.name, + description: parsed.description, + body: parsed.body, + allowed_tools: parsed.allowed_tools, + model: parsed.model, + origin, + path: input.path, + }) +} + +/// Walk `//.md` discovering subagent definitions. +/// Each child dir contributes at most one subagent named after its own `.md` +/// file inside it (e.g. `git-expert/git-expert.md`). Symlink guards mirror skills. +pub fn read_tier_from_fs(root: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return out; + }; + for entry in entries.flatten() { + if entry + .file_type() + .map(|ft| ft.is_symlink()) + .unwrap_or(false) + { + log::warn!( + "[subagents] skipping symlink dir entry: {}", + entry.path().display() + ); + continue; + } + let path = entry.path(); + let Some(dir_name) = path.file_name().and_then(|n| n.to_str()).map(String::from) else { + continue; + }; + let agent_md = path.join(format!("{dir_name}.md")); + match std::fs::symlink_metadata(&agent_md) { + Ok(meta) if meta.file_type().is_symlink() => { + log::warn!( + "[subagents] skipping symlinked definition: {}", + agent_md.display() + ); + continue; + } + Ok(_) => {} + Err(_) => continue, + } + if let Ok(raw) = std::fs::read_to_string(&agent_md) { + out.push(SubagentInput { raw, path: agent_md }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk(raw: &str, path: &str) -> SubagentInput { + SubagentInput { + raw: raw.to_string(), + path: PathBuf::from(path), + } + } + + #[test] + fn empty_registry() { + let reg = SubagentRegistry::new(vec![], vec![], vec![], &HashSet::new()); + assert!(reg.is_empty()); + } + + #[test] + fn loads_and_sorts() { + let a = mk("---\nname: bravo\ndescription: B.\n---\nb", "/b"); + let b = mk("---\nname: alpha\ndescription: A.\n---\na", "/a"); + let reg = SubagentRegistry::new(vec![], vec![a, b], vec![], &HashSet::new()); + assert_eq!( + reg.list_for_prompt(), + vec![("alpha", "A."), ("bravo", "B.")] + ); + } + + #[test] + fn project_overrides_global_overrides_default() { + let d = mk("---\nname: s\ndescription: default.\n---\nd", "/d"); + let g = mk("---\nname: s\ndescription: global.\n---\ng", "/g"); + let p = mk("---\nname: s\ndescription: project.\n---\np", "/p"); + let reg = SubagentRegistry::new(vec![d], vec![g], vec![p], &HashSet::new()); + let a = reg.get("s").unwrap(); + assert_eq!(a.origin, Origin::Project); + assert_eq!(a.description, "project."); + } + + #[test] + fn malformed_skipped_others_loaded() { + let bad = mk("no frontmatter", "/bad"); + let good = mk("---\nname: good\ndescription: ok.\n---\nb", "/good"); + let reg = SubagentRegistry::new(vec![], vec![bad, good], vec![], &HashSet::new()); + assert_eq!(reg.len(), 1); + assert!(reg.get("good").is_some()); + } + + #[test] + fn oversized_rejected() { + let huge_body = "x".repeat(MAX_BODY_TOKENS * 4 + 10); + let raw = format!("---\nname: huge\ndescription: big.\n---\n{huge_body}"); + let reg = SubagentRegistry::new(vec![], vec![mk(&raw, "/h")], vec![], &HashSet::new()); + assert!(reg.is_empty()); + } + + #[test] + fn disabled_filtered() { + let a = mk("---\nname: on\ndescription: A.\n---\nb", "/a"); + let b = mk("---\nname: off\ndescription: B.\n---\nb", "/b"); + let mut disabled = HashSet::new(); + disabled.insert("off".to_string()); + let reg = SubagentRegistry::new(vec![], vec![a, b], vec![], &disabled); + assert!(reg.get("on").is_some()); + assert!(reg.get("off").is_none()); + } + + #[test] + fn carries_allowed_tools_and_model() { + let raw = "---\nname: r\ndescription: d\nallowed-tools: [read, grep]\nmodel: claude-sonnet-4-6\n---\nb"; + let reg = SubagentRegistry::new(vec![], vec![mk(raw, "/r")], vec![], &HashSet::new()); + let a = reg.get("r").unwrap(); + assert_eq!( + a.allowed_tools.as_deref(), + Some(["read".to_string(), "grep".to_string()].as_slice()) + ); + assert_eq!(a.model.as_deref(), Some("claude-sonnet-4-6")); + } + + // ── symlink guards (mirror skills) ── + + #[cfg(unix)] + #[test] + fn read_tier_skips_symlinked_definition_file() { + let tmp = tempfile::tempdir().unwrap(); + let tier = tmp.path(); + + let good_dir = tier.join("good"); + std::fs::create_dir(&good_dir).unwrap(); + std::fs::write( + good_dir.join("good.md"), + "---\nname: good\ndescription: Valid.\n---\nbody", + ) + .unwrap(); + + let secret = tmp.path().join("secret.txt"); + std::fs::write(&secret, "SENSITIVE").unwrap(); + let evil_dir = tier.join("evil"); + std::fs::create_dir(&evil_dir).unwrap(); + std::os::unix::fs::symlink(&secret, evil_dir.join("evil.md")).unwrap(); + + let inputs = read_tier_from_fs(tier); + assert_eq!(inputs.len(), 1); + assert!(inputs[0].raw.contains("name: good")); + assert!(!inputs.iter().any(|i| i.raw.contains("SENSITIVE"))); + } + + #[cfg(unix)] + #[test] + fn read_tier_skips_symlinked_agent_dir() { + let tmp = tempfile::tempdir().unwrap(); + let tier = tmp.path(); + + let good_dir = tier.join("good"); + std::fs::create_dir(&good_dir).unwrap(); + std::fs::write( + good_dir.join("good.md"), + "---\nname: good\ndescription: Valid.\n---\nb", + ) + .unwrap(); + + let outside_tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(outside_tmp.path().join("evil")).unwrap(); + std::fs::write( + outside_tmp.path().join("evil").join("evil.md"), + "---\nname: evil\ndescription: Elsewhere.\n---\nb", + ) + .unwrap(); + std::os::unix::fs::symlink(outside_tmp.path().join("evil"), tier.join("evil")).unwrap(); + + let inputs = read_tier_from_fs(tier); + assert_eq!(inputs.len(), 1); + assert!(inputs[0].raw.contains("name: good")); + } +} diff --git a/crates/agent/src/subagents/tool.rs b/crates/agent/src/subagents/tool.rs new file mode 100644 index 00000000..a2534c60 --- /dev/null +++ b/crates/agent/src/subagents/tool.rs @@ -0,0 +1,432 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::agent::config::{CompactionConfig, RetryConfig}; +use crate::agent::prompt::SystemBlock; +use crate::agent::{AgentConfig, AgentLoop}; +use crate::approval::ApprovalHandler; +use crate::context_engine::ContextEngineApi; +use crate::error::{AgentError, ToolError}; +use crate::llm::types::{CacheControl, ChatMessage}; +use crate::llm::{LlmClient, LlmClientConfig, LlmProvider}; +use crate::persistence::PersisterFactory; +use crate::tool::{Tool, ToolContext, ToolMode, ToolRegistry, ToolResult}; +use crate::types::{AgentEvent, AgentResult}; + +use super::registry::SubagentRegistry; +use super::write_lock::WriteLockRegistry; + +/// Builds a child `ApprovalHandler` that tags every request with the +/// originating subagent name. Tauri-side impl wraps the existing +/// TauriApprovalHandler; tests use a no-op implementation. +pub trait ApprovalHandlerFactory: Send + Sync { + fn for_subagent(&self, subagent_name: &str) -> Arc; +} + +/// Everything `SpawnSubagentTool` needs to build a child AgentLoop while +/// staying agnostic of the Tauri layer. +pub struct SubagentInheritance { + pub llm_client_config: LlmClientConfig, + pub retry_config: RetryConfig, + pub compaction_config: CompactionConfig, + pub compaction_llm: Option, + pub max_iterations: u32, + pub context_engine: Option>, + pub context_engine_repo_path: Option, + pub persister_factory: Option>, + pub approval_handler_factory: Option>, + pub parent_thread_id: Option, + pub write_lock_registry: Arc, +} + +pub struct SpawnSubagentTool { + registry: Arc, + inherit: Arc, + description: String, +} + +/// Tools that make a subagent "write-capable" and thus force it to +/// serialize on the per-worktree mutex. Mirrors Coding-mode's mutating set +/// from `tool::mod::ToolRegistry::for_mode`. +const WRITE_CAPABLE_TOOLS: &[&str] = &[ + "write", + "edit", + "bash", + "apply_patch", + "git", + "create_pr", +]; + +impl SpawnSubagentTool { + pub fn new(registry: Arc, inherit: Arc) -> Self { + let description = build_description(®istry); + Self { + registry, + inherit, + description, + } + } +} + +fn build_description(registry: &SubagentRegistry) -> String { + let mut s = String::from( + "Dispatch a specialized subagent. The child runs as a nested AgentLoop, \ + sees only your `prompt` (no parent history), and returns a final \ + summary as this tool's result. Child shares the parent's working \ + directory. Multiple calls in one assistant turn run concurrently \ + unless the subagent is write-capable (write-capable children serialize \ + per worktree).\n\nAvailable subagents:\n", + ); + for (name, description) in registry.list_for_prompt() { + s.push_str(&format!("- {name}: {description}\n")); + } + s +} + +fn is_write_capable(effective_tools: &[String]) -> bool { + effective_tools + .iter() + .any(|t| WRITE_CAPABLE_TOOLS.contains(&t.as_str())) +} + +/// Build a human-readable one-line preview of the prompt for the chip label. +/// Collapses whitespace/newlines to single spaces and hard-truncates at +/// `max_chars` (byte-safe — relies on char boundaries). +fn truncate_prompt_preview(prompt: &str, max_chars: usize) -> String { + let collapsed: String = prompt.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max_chars { + return collapsed; + } + let mut out: String = collapsed.chars().take(max_chars).collect(); + out.push('…'); + out +} + +#[async_trait] +impl Tool for SpawnSubagentTool { + fn name(&self) -> &str { + "spawn_subagent" + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name", "prompt"], + "properties": { + "name": { + "type": "string", + "description": "Subagent name from the available list" + }, + "prompt": { + "type": "string", + "description": "Full task description for the subagent" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("spawn_subagent: missing `name`".into()))? + .to_string(); + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("spawn_subagent: missing `prompt`".into()))? + .to_string(); + log::info!( + "[subagents::spawn] called: name={} parent_session={} parent_tool_call_id={} prompt_chars={}", + name, + ctx.session_id, + ctx.tool_call_id, + prompt.len() + ); + + let Some(definition) = self.registry.get(&name) else { + log::warn!( + "[subagents::spawn] unknown subagent `{}` requested. available: {:?}", + name, + self.registry.names() + ); + return Ok(ToolResult::error(format!( + "unknown subagent `{}`. available: {}", + name, + self.registry.names().join(", ") + ))); + }; + let definition = definition.clone(); + + let child_session_id = Uuid::new_v4().to_string(); + let child_thread_id = format!("{}-sub-{}", ctx.session_id, &child_session_id[..8]); + log::info!( + "[subagents::spawn] resolved definition: name={} model_override={:?} allowed_tools={:?} child_session={} child_thread={}", + definition.name, + definition.model, + definition.allowed_tools, + child_session_id, + child_thread_id, + ); + + // Build child's Coding-mode tool registry, then apply `allowed-tools` filter. + // Depth-1 is enforced by not passing any subagents registry into the child. + let context_engine_arg = self + .inherit + .context_engine + .as_ref() + .and_then(|e| { + self.inherit + .context_engine_repo_path + .as_ref() + .map(|p| (e.clone(), p.clone())) + }); + let mut child_registry = + ToolRegistry::for_mode(ToolMode::Coding, context_engine_arg, None); + if let Some(allowed) = &definition.allowed_tools { + let allowed_set: HashSet = allowed.iter().cloned().collect(); + child_registry.retain(|tool_name| allowed_set.contains(tool_name)); + } + + let effective_tools = child_registry.names(); + let write_capable = is_write_capable(&effective_tools); + log::info!( + "[subagents::spawn] child registry built: tools={:?} write_capable={} (working_dir={:?})", + effective_tools, + write_capable, + ctx.working_dir, + ); + + // Child LLM: clone parent's client config, apply per-subagent model override. + let mut child_llm = self.inherit.llm_client_config.clone(); + if let Some(m) = &definition.model { + child_llm.model = m.clone(); + } + + // Child system prompt: subagent body cached + a minimal env footer. + let env_block_text = format!( + "# Environment\n- Working directory: {}\n- Depth: 1 (spawned by parent)\n- Date: {}\n", + ctx.working_dir.display(), + chrono::Local::now().format("%Y-%m-%d"), + ); + let system_prompt = vec![ + SystemBlock { + text: definition.body.clone(), + cache_control: Some(CacheControl::ephemeral()), + }, + SystemBlock { + text: env_block_text, + cache_control: None, + }, + ]; + + let child_config = AgentConfig { + llm: child_llm, + working_dir: ctx.working_dir.clone(), + mode: ToolMode::Coding, + max_iterations: self.inherit.max_iterations, + system_prompt: Some(system_prompt), + retry_config: self.inherit.retry_config.clone(), + compaction_config: self.inherit.compaction_config.clone(), + compaction_llm: self.inherit.compaction_llm.clone(), + context_engine: self.inherit.context_engine.clone(), + context_engine_repo_path: self.inherit.context_engine_repo_path.clone(), + skills: None, + subagents: None, + subagent_inheritance: None, // depth-1 enforced + }; + + // Child event channel + drain. Per DEC-1 (in docs/subagent-bugs-and-fixes.md), + // the parent UI does NOT mirror the child's stream — the child is + // represented as a single tool-call chip via SubagentStart/End. We still + // need a live receiver so the child's sends don't back up or error, but + // the events are discarded after counting. The child's final summary + // reaches the parent via `ToolResult::success(summary)`, which becomes + // the tool_result entry in the parent's LLM message history. + let (child_tx, mut child_rx) = mpsc::channel::(256); + let drain_child_session = child_session_id.clone(); + // Collect modified_files emitted by the child's TurnCompleted events so + // we can marshal them up to the parent's TurnCompleted via the returned + // ToolResult. Without this the parent reports modified_files=[] for any + // turn whose only tool call was spawn_subagent — UI per-turn file lists + // under-report changes. + let collected_modified_files = Arc::new(tokio::sync::Mutex::new(Vec::::new())); + let collected_for_drain = Arc::clone(&collected_modified_files); + let drain_handle = tokio::spawn(async move { + let mut count = 0usize; + while let Some(ev) = child_rx.recv().await { + count += 1; + if let AgentEvent::TurnCompleted { modified_files, .. } = &ev { + if !modified_files.is_empty() { + collected_for_drain.lock().await.extend(modified_files.iter().cloned()); + } + } + } + log::info!( + "[subagents::drain] child_session={} drained {} event(s) (not relayed to parent UI per DEC-1)", + drain_child_session, count, + ); + }); + + let prompt_preview = truncate_prompt_preview(&prompt, 120); + let _ = ctx + .event_tx + .send(AgentEvent::SubagentStart { + session_id: ctx.session_id.clone(), + parent_tool_call_id: ctx.tool_call_id.clone(), + child_session_id: child_session_id.clone(), + subagent_name: name.clone(), + prompt_preview, + }) + .await; + + // Write-capable subagents serialize per-working_dir. Lock is held for + // the full child run; read-only subagents bypass. The wait is cancel- + // aware — if the parent cancels while this child is still queued, we + // short-circuit instead of waiting for the current holder to finish. + let _write_guard = if write_capable { + let lock = self.inherit.write_lock_registry.get_or_create(&ctx.working_dir); + let wait_start = std::time::Instant::now(); + let guard = tokio::select! { + g = lock.lock_owned() => { + let waited = wait_start.elapsed(); + log::info!( + "[subagents::lock] write-capable guard acquired for {:?} (waited {}ms) child_session={}", + ctx.working_dir, waited.as_millis(), child_session_id, + ); + g + } + _ = ctx.cancel_token.cancelled() => { + log::info!( + "[subagents::lock] cancelled while waiting for write-mutex (waited {}ms) child_session={}", + wait_start.elapsed().as_millis(), child_session_id, + ); + let _ = ctx.event_tx.send(AgentEvent::SubagentEnd { + session_id: ctx.session_id.clone(), + parent_tool_call_id: ctx.tool_call_id.clone(), + child_session_id: child_session_id.clone(), + success: false, + summary: "subagent cancelled".to_string(), + }).await; + return Ok(ToolResult::error("subagent cancelled")); + } + }; + Some(guard) + } else { + log::info!( + "[subagents::lock] read-only subagent, bypassing write mutex (child_session={})", + child_session_id, + ); + None + }; + + let child_cancel = ctx.cancel_token.child_token(); + + let child_persister = self.inherit.persister_factory.as_ref().map(|f| { + let parent_tid = self.inherit.parent_thread_id.as_deref().unwrap_or(""); + log::info!( + "[subagents::spawn] attaching child persister parent_thread_id={:?} child_thread_id={}", + parent_tid, child_thread_id, + ); + f.for_subagent(parent_tid) + }); + if self.inherit.persister_factory.is_none() { + log::warn!( + "[subagents::spawn] no persister_factory inherited — child messages will NOT be persisted" + ); + } + + let child_approval = self + .inherit + .approval_handler_factory + .as_ref() + .map(|f| { + log::info!( + "[subagents::spawn] attaching tagged approval handler for subagent={}", + name + ); + f.for_subagent(&name) + }); + + let provider: Box = Box::new(LlmClient::new(child_config.llm.clone())); + let mut child_loop = AgentLoop::with_provider( + child_config, + provider, + child_registry, + child_cancel, + child_tx, + child_session_id.clone(), + ); + if let Some(p) = child_persister { + child_loop = child_loop.with_persister(p, Some(child_thread_id)); + } + if let Some(h) = child_approval { + child_loop = child_loop.with_approval_handler(h); + } + + log::info!( + "[subagents::spawn] child AgentLoop starting: subagent={} child_session={}", + name, child_session_id, + ); + let run_start = std::time::Instant::now(); + let run_result = child_loop.run(ChatMessage::user(prompt)).await; + let run_elapsed = run_start.elapsed(); + + // Drop the child loop so its event_tx sender closes; this lets the + // drain task observe recv()→None and terminate. Awaiting the drain + // before we return ensures the drain log and any future cleanup run + // to completion inside the write guard's scope, so the next queued + // subagent doesn't start before this one has fully unwound. + drop(child_loop); + let _ = drain_handle.await; + + let (success, summary) = match run_result { + Ok(AgentResult::Done { summary }) => (true, summary), + Ok(other) => (false, format!("subagent yielded unexpectedly: {other:?}")), + Err(AgentError::Cancelled) => (false, "subagent cancelled".to_string()), + Err(e) => (false, format!("subagent failed: {e}")), + }; + log::info!( + "[subagents::spawn] child AgentLoop finished: subagent={} success={} elapsed={}ms summary_chars={}", + name, success, run_elapsed.as_millis(), summary.len(), + ); + + let _ = ctx + .event_tx + .send(AgentEvent::SubagentEnd { + session_id: ctx.session_id.clone(), + parent_tool_call_id: ctx.tool_call_id.clone(), + child_session_id, + success, + summary: summary.clone(), + }) + .await; + + // Deduplicate child's modified_files and attach to the returned + // ToolResult so the parent's next TurnCompleted reflects the child's + // filesystem changes too. + let modified_files: Vec = { + let mut v = collected_modified_files.lock().await.clone(); + v.sort(); + v.dedup(); + v + }; + + Ok(ToolResult { + output: summary, + is_error: !success, + yield_data: None, + modified_files, + }) + } +} diff --git a/crates/agent/src/subagents/write_lock.rs b/crates/agent/src/subagents/write_lock.rs new file mode 100644 index 00000000..29a27b9a --- /dev/null +++ b/crates/agent/src/subagents/write_lock.rs @@ -0,0 +1,100 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Per-working_dir mutex registry for write-capable subagents. Two +/// concurrent write-capable children against the same worktree serialize; +/// read-only children bypass the registry entirely. +/// +/// The outer Mutex is a cheap `std::sync::Mutex` because it only guards +/// the HashMap lookup/insert. The actual lock held across a child run is +/// a `tokio::sync::Mutex<()>` so `.lock().await` can be held across +/// `child_loop.run(...).await`. +#[derive(Default)] +pub struct WriteLockRegistry { + locks: Mutex>>>, +} + +impl WriteLockRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn get_or_create(&self, working_dir: &Path) -> Arc> { + let mut guard = self.locks.lock().expect("write_lock registry poisoned"); + let existed = guard.contains_key(working_dir); + let lock = guard + .entry(working_dir.to_path_buf()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + if !existed { + log::info!( + "[subagents::lock] created new write-mutex for {:?} (registry now holds {} entries)", + working_dir, + guard.len() + ); + } + lock + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_path_returns_same_arc() { + let reg = WriteLockRegistry::new(); + let a = reg.get_or_create(Path::new("/tmp/repo-a")); + let b = reg.get_or_create(Path::new("/tmp/repo-a")); + assert!(Arc::ptr_eq(&a, &b)); + } + + #[test] + fn different_paths_return_different_arcs() { + let reg = WriteLockRegistry::new(); + let a = reg.get_or_create(Path::new("/tmp/repo-a")); + let b = reg.get_or_create(Path::new("/tmp/repo-b")); + assert!(!Arc::ptr_eq(&a, &b)); + } + + #[tokio::test] + async fn serializes_same_path() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let reg = Arc::new(WriteLockRegistry::new()); + let counter = Arc::new(AtomicUsize::new(0)); + let max_concurrent = Arc::new(AtomicUsize::new(0)); + + let mut tasks = Vec::new(); + for _ in 0..4 { + let reg = reg.clone(); + let counter = counter.clone(); + let max_concurrent = max_concurrent.clone(); + tasks.push(tokio::spawn(async move { + let lock = reg.get_or_create(Path::new("/tmp/same")); + let _guard = lock.lock().await; + let n = counter.fetch_add(1, Ordering::SeqCst) + 1; + let mut m = max_concurrent.load(Ordering::SeqCst); + while n > m { + match max_concurrent.compare_exchange( + m, + n, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => break, + Err(cur) => m = cur, + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + counter.fetch_sub(1, Ordering::SeqCst); + })); + } + for t in tasks { + t.await.unwrap(); + } + assert_eq!(max_concurrent.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/agent/src/test_util.rs b/crates/agent/src/test_util.rs new file mode 100644 index 00000000..73e06c60 --- /dev/null +++ b/crates/agent/src/test_util.rs @@ -0,0 +1,67 @@ +use std::collections::VecDeque; +use std::sync::Mutex; + +use tokio::sync::mpsc; + +use crate::error::AgentError; +use crate::llm::client::LlmProvider; +use crate::llm::types::{ChatMessage, LlmResponse, ToolDefinition}; +use crate::types::AgentEvent; + +/// Mock LLM that returns pre-queued responses in order. +pub struct MockLlm { + pub responses: Mutex>>, +} + +impl MockLlm { + pub fn new(responses: Vec>) -> Self { + Self { + responses: Mutex::new(VecDeque::from(responses)), + } + } +} + +#[async_trait::async_trait] +impl LlmProvider for MockLlm { + async fn chat_completion( + &self, + _messages: &[ChatMessage], + _tools: &[ToolDefinition], + _event_tx: &mpsc::Sender, + _session_id: &str, + _cancel_token: Option<&tokio_util::sync::CancellationToken>, + ) -> Result { + self.responses + .lock() + .unwrap() + .pop_front() + .expect("MockLlm: no more responses queued") + } +} + +/// Create a simple text-only LLM response. +pub fn text_response(text: &str) -> LlmResponse { + LlmResponse { + content: Some(text.to_string()), + tool_calls: vec![], + usage: None, + finish_reason: Some("stop".to_string()), + thinking: None, + } +} + +/// Create a text response with specific token usage (for compaction tests). +pub fn text_response_with_usage(text: &str, total_tokens: u32) -> LlmResponse { + LlmResponse { + content: Some(text.to_string()), + tool_calls: vec![], + usage: Some(crate::llm::types::Usage { + prompt_tokens: total_tokens.saturating_sub(10), + completion_tokens: 10, + total_tokens, + prompt_tokens_details: None, + }), + finish_reason: Some("stop".to_string()), + thinking: None, + } +} diff --git a/crates/agent/src/tool/apply_patch.rs b/crates/agent/src/tool/apply_patch.rs new file mode 100644 index 00000000..ddbac6e5 --- /dev/null +++ b/crates/agent/src/tool/apply_patch.rs @@ -0,0 +1,545 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::util::{is_path_within_working_dir, resolve_path}; +use super::{Tool, ToolContext, ToolResult}; + +pub struct ApplyPatchTool; + +#[async_trait] +impl Tool for ApplyPatchTool { + fn name(&self) -> &str { + "apply_patch" + } + + fn description(&self) -> &str { + "Apply a unified diff patch to one or more files. Use for coordinated multi-file changes. \ + Supports creating, modifying, and deleting files. Prefer `edit` for single-file changes." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["patch"], + "properties": { + "patch": { + "type": "string", + "description": "The patch content in unified diff format" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let patch_str = args + .get("patch") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: patch".into()))?; + + if patch_str.trim().is_empty() { + return Ok(ToolResult::error("Patch content is empty.")); + } + + let file_patches = match parse_unified_diff(patch_str) { + Ok(patches) => { + log::info!("[v1.0] apply_patch: parsed {} file patches", patches.len()); + patches + } + Err(e) => return Ok(ToolResult::error(format!("Failed to parse patch: {e}"))), + }; + + if file_patches.is_empty() { + return Ok(ToolResult::error( + "No file patches found in the input. Expected unified diff format with --- and +++ headers.", + )); + } + + let mut modified = Vec::new(); + let mut created = Vec::new(); + let mut deleted = Vec::new(); + + // ── Phase 1: Validate all patches and compute new contents in memory ── + // This ensures we don't leave files in a partial state if a later patch fails. + enum Staged { + Write { content: String }, + Delete, + CreateDirs { content: String }, + } + let mut staged: Vec<(std::path::PathBuf, String, PatchOp, Staged)> = Vec::new(); + + for fp in &file_patches { + let path = resolve_path(&ctx.working_dir, &fp.path); + + if !is_path_within_working_dir(&ctx.working_dir, &path) { + return Ok(ToolResult::error(format!( + "Path '{}' is outside the working directory. Cannot apply patch.", + fp.path + ))); + } + + match fp.operation { + PatchOp::Delete => { + staged.push((path, fp.path.clone(), PatchOp::Delete, Staged::Delete)); + } + PatchOp::Create => { + let new_content = apply_hunks_to_empty(&fp.hunks)?; + staged.push((path, fp.path.clone(), PatchOp::Create, Staged::CreateDirs { content: new_content })); + } + PatchOp::Modify => { + let content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| ToolError(format!("Failed to read {}: {e}", fp.path)))?; + let new_content = match apply_hunks(&content, &fp.hunks) { + Ok(c) => c, + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to apply patch to {}: {e}", + fp.path + ))); + } + }; + staged.push((path, fp.path.clone(), PatchOp::Modify, Staged::Write { content: new_content })); + } + } + } + + // ── Phase 2: Apply all validated changes to disk ── + for (path, name, _op, action) in &staged { + match action { + Staged::Delete => { + if path.exists() { + tokio::fs::remove_file(path) + .await + .map_err(|e| ToolError(format!("Failed to delete {}: {e}", name)))?; + } + deleted.push(name.clone()); + } + Staged::CreateDirs { content } => { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| ToolError(format!("Failed to create dirs: {e}")))?; + } + tokio::fs::write(path, content) + .await + .map_err(|e| ToolError(format!("Failed to write {}: {e}", name)))?; + created.push(name.clone()); + } + Staged::Write { content } => { + tokio::fs::write(path, content) + .await + .map_err(|e| ToolError(format!("Failed to write {}: {e}", name)))?; + modified.push(name.clone()); + } + } + } + + let mut parts = Vec::new(); + if !modified.is_empty() { + parts.push(format!("modified {} file(s) ({})", modified.len(), modified.join(", "))); + } + if !created.is_empty() { + parts.push(format!("created {} file(s) ({})", created.len(), created.join(", "))); + } + if !deleted.is_empty() { + parts.push(format!("deleted {} file(s) ({})", deleted.len(), deleted.join(", "))); + } + + let mut all_files: Vec = Vec::new(); + all_files.extend(modified.iter().cloned()); + all_files.extend(created.iter().cloned()); + all_files.extend(deleted.iter().cloned()); + + let mut result = ToolResult::success(format!("Applied patch: {}", parts.join(", "))); + result.modified_files = all_files; + Ok(result) + } +} + +// ── Patch parsing types ── + +#[derive(Debug)] +enum PatchOp { + Create, + Modify, + Delete, +} + +#[derive(Debug)] +struct FilePatch { + path: String, + operation: PatchOp, + hunks: Vec, +} + +#[derive(Debug)] +struct Hunk { + old_start: usize, + lines: Vec, +} + +#[derive(Debug)] +enum HunkLine { + Context(()), + Remove(()), // content stored for potential future context matching + Add(String), +} + +// ── Unified diff parser ── + +fn parse_unified_diff(patch: &str) -> Result, String> { + let lines: Vec<&str> = patch.lines().collect(); + let mut file_patches = Vec::new(); + let mut i = 0; + + while i < lines.len() { + // Find --- header + if lines[i].starts_with("--- ") { + if i + 1 >= lines.len() || !lines[i + 1].starts_with("+++ ") { + i += 1; + continue; + } + + let old_path = parse_file_path(lines[i], "--- "); + let new_path = parse_file_path(lines[i + 1], "+++ "); + i += 2; + + let operation = if old_path == "/dev/null" { + PatchOp::Create + } else if new_path == "/dev/null" { + PatchOp::Delete + } else { + PatchOp::Modify + }; + + let path = match &operation { + PatchOp::Create => new_path.clone(), + _ => old_path.clone(), + }; + + // Parse hunks + let mut hunks = Vec::new(); + while i < lines.len() && !lines[i].starts_with("--- ") && !lines[i].starts_with("diff ") { + if lines[i].starts_with("@@ ") { + let (old_start, hunk_lines, next_i) = parse_hunk(&lines, i)?; + hunks.push(Hunk { + old_start, + lines: hunk_lines, + }); + i = next_i; + } else { + i += 1; + } + } + + file_patches.push(FilePatch { + path, + operation, + hunks, + }); + } else { + i += 1; + } + } + + Ok(file_patches) +} + +fn parse_file_path(line: &str, prefix: &str) -> String { + let raw = line.strip_prefix(prefix).unwrap_or(line); + // Strip a/ or b/ prefix (common in git diffs) + let stripped = if raw.starts_with("a/") || raw.starts_with("b/") { + &raw[2..] + } else { + raw + }; + stripped.to_string() +} + +fn parse_hunk(lines: &[&str], start: usize) -> Result<(usize, Vec, usize), String> { + let header = lines[start]; + // Parse @@ -old_start[,old_count] +new_start[,new_count] @@ + let parts: Vec<&str> = header.split("@@").collect(); + if parts.len() < 3 { + return Err(format!("Invalid hunk header: {header}")); + } + let range_part = parts[1].trim(); + let old_range = range_part.split(' ').next().unwrap_or("-1"); + let old_start: usize = old_range + .trim_start_matches('-') + .split(',') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + + let mut hunk_lines = Vec::new(); + let mut i = start + 1; + + while i < lines.len() { + let line = lines[i]; + if line.starts_with("@@ ") || line.starts_with("--- ") || line.starts_with("diff ") { + break; + } + if line.starts_with('-') { + hunk_lines.push(HunkLine::Remove(())); + } else if let Some(content) = line.strip_prefix('+') { + hunk_lines.push(HunkLine::Add(content.to_string())); + } else if line.starts_with(' ') { + hunk_lines.push(HunkLine::Context(())); + } else if line == "\\ No newline at end of file" { + // Skip this marker + } else { + // Treat unrecognized lines as context (bare lines without prefix) + hunk_lines.push(HunkLine::Context(())); + } + i += 1; + } + + Ok((old_start, hunk_lines, i)) +} + +// ── Hunk application ── + +fn apply_hunks(content: &str, hunks: &[Hunk]) -> Result { + let mut lines: Vec = content.lines().map(String::from).collect(); + let mut offset: isize = 0; + + for hunk in hunks { + let raw = hunk.old_start as isize - 1 + offset; + if raw < 0 { + return Err(format!( + "Hunk at old_start={} produces negative offset ({}) — patch is invalid", + hunk.old_start, raw + )); + } + let start = raw as usize; + let mut pos = start; + let mut removals = 0; + let mut additions = 0; + + for hline in &hunk.lines { + match hline { + HunkLine::Context(_) => { + pos += 1; + } + HunkLine::Remove(_) => { + if pos < lines.len() { + lines.remove(pos); + removals += 1; + } + } + HunkLine::Add(text) => { + if pos <= lines.len() { + lines.insert(pos, text.clone()); + pos += 1; + additions += 1; + } + } + } + } + + offset += additions as isize - removals as isize; + } + + // Preserve trailing newline if original had one + let mut result = lines.join("\n"); + if content.ends_with('\n') && !result.ends_with('\n') { + result.push('\n'); + } + Ok(result) +} + +fn apply_hunks_to_empty(hunks: &[Hunk]) -> Result { + let mut lines: Vec = Vec::new(); + for hunk in hunks { + for hline in &hunk.lines { + if let HunkLine::Add(text) = hline { + lines.push(text.clone()); + } + } + } + let mut result = lines.join("\n"); + if !result.is_empty() { + result.push('\n'); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_ctx(dir: &std::path::Path) -> ToolContext { + ToolContext::test_context(dir) + } + + #[tokio::test] + async fn test_apply_single_file_modify() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "line 1\nline 2\nline 3\n").unwrap(); + + let patch = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,3 @@ + line 1 +-line 2 ++line 2 modified + line 3 +"; + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": patch}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(!result.is_error, "Error: {}", result.output); + let content = std::fs::read_to_string(tmp.path().join("test.txt")).unwrap(); + assert!(content.contains("line 2 modified")); + assert!(!content.contains("\nline 2\n")); + } + + #[tokio::test] + async fn test_apply_new_file() { + let tmp = tempdir().unwrap(); + + let patch = "\ +--- /dev/null ++++ b/new_file.txt +@@ -0,0 +1,3 @@ ++line 1 ++line 2 ++line 3 +"; + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": patch}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(!result.is_error, "Error: {}", result.output); + assert!(result.output.contains("created")); + let content = std::fs::read_to_string(tmp.path().join("new_file.txt")).unwrap(); + assert!(content.contains("line 1")); + assert!(content.contains("line 3")); + } + + #[tokio::test] + async fn test_apply_delete_file() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("delete_me.txt"), "content\n").unwrap(); + + let patch = "\ +--- a/delete_me.txt ++++ /dev/null +@@ -1 +0,0 @@ +-content +"; + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": patch}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(!result.is_error, "Error: {}", result.output); + assert!(result.output.contains("deleted")); + assert!(!tmp.path().join("delete_me.txt").exists()); + } + + #[tokio::test] + async fn test_apply_multi_file() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "aaa\n").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "bbb\n").unwrap(); + + let patch = "\ +--- a/a.txt ++++ b/a.txt +@@ -1 +1 @@ +-aaa ++aaa modified +--- a/b.txt ++++ b/b.txt +@@ -1 +1 @@ +-bbb ++bbb modified +"; + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": patch}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(!result.is_error, "Error: {}", result.output); + assert!(std::fs::read_to_string(tmp.path().join("a.txt")).unwrap().contains("aaa modified")); + assert!(std::fs::read_to_string(tmp.path().join("b.txt")).unwrap().contains("bbb modified")); + } + + #[tokio::test] + async fn test_apply_empty_patch() { + let tmp = tempdir().unwrap(); + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": ""}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("empty")); + } + + #[tokio::test] + async fn test_apply_invalid_format() { + let tmp = tempdir().unwrap(); + let tool = ApplyPatchTool; + let result = tool + .execute( + json!({"patch": "this is not a patch\njust random text\n"}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + assert!(result.is_error); + } + + #[tokio::test] + async fn test_apply_path_outside_working_dir() { + let tmp = tempdir().unwrap(); + let patch = "\ +--- a/../../../etc/passwd ++++ b/../../../etc/passwd +@@ -1 +1 @@ +-root ++hacked +"; + let tool = ApplyPatchTool; + let result = tool + .execute(json!({"patch": patch}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("outside")); + } + + // ── Parser unit tests ── + + #[test] + fn test_parse_file_path_strips_prefix() { + assert_eq!(parse_file_path("--- a/src/main.rs", "--- "), "src/main.rs"); + assert_eq!(parse_file_path("+++ b/src/main.rs", "+++ "), "src/main.rs"); + assert_eq!(parse_file_path("--- /dev/null", "--- "), "/dev/null"); + } + + #[test] + fn test_parse_unified_diff_basic() { + let patch = "--- a/test.txt\n+++ b/test.txt\n@@ -1,2 +1,2 @@\n-old\n+new\n context\n"; + let result = parse_unified_diff(patch).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "test.txt"); + assert_eq!(result[0].hunks.len(), 1); + } +} diff --git a/crates/agent/src/tool/ask_user.rs b/crates/agent/src/tool/ask_user.rs new file mode 100644 index 00000000..a6f6d579 --- /dev/null +++ b/crates/agent/src/tool/ask_user.rs @@ -0,0 +1,145 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; + +/// Tool that lets the agent ask the user a clarifying question. +/// +/// Uses the yield pattern (same as start_session): sets `yield_data` on the result, +/// which causes the agent loop to yield `AgentResult::AskUser`. The caller collects +/// the user's answer and re-invokes `run()` with the answer as a new user message. +pub struct AskUserTool; + +#[async_trait] +impl Tool for AskUserTool { + fn name(&self) -> &str { + "ask_user" + } + + fn description(&self) -> &str { + "Ask the user a clarifying question. Use when you need more information to proceed. \ + Optionally provide a list of choices for the user to pick from." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["question"], + "properties": { + "question": { + "type": "string", + "description": "The question to ask the user" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of choices for the user to pick from" + } + } + }) + } + + async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result { + let question = args + .get("question") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if question.is_empty() { + return Ok(ToolResult::error("Question must not be empty.")); + } + + let options: Option> = args.get("options").and_then(|v| { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(String::from)) + .collect() + }) + }); + + let yield_data = json!({ + "yield_type": "ask_user", + "question": question, + "options": options, + }); + + Ok(ToolResult { + output: format!("Question asked: {question}"), + is_error: false, + yield_data: Some(yield_data), + modified_files: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx() -> ToolContext { + ToolContext::test_context(std::path::Path::new("/tmp")) + } + + #[tokio::test] + async fn test_ask_user_basic() { + let tool = AskUserTool; + let result = tool + .execute(json!({"question": "Which database should we use?"}), &test_ctx()) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Which database should we use?")); + assert!(result.yield_data.is_some()); + + let data = result.yield_data.unwrap(); + assert_eq!(data["yield_type"], "ask_user"); + assert_eq!(data["question"], "Which database should we use?"); + assert!(data["options"].is_null()); + } + + #[tokio::test] + async fn test_ask_user_with_options() { + let tool = AskUserTool; + let result = tool + .execute( + json!({ + "question": "Which approach?", + "options": ["Option A", "Option B", "Option C"] + }), + &test_ctx(), + ) + .await + .unwrap(); + + assert!(!result.is_error); + let data = result.yield_data.unwrap(); + assert_eq!(data["yield_type"], "ask_user"); + let opts = data["options"].as_array().unwrap(); + assert_eq!(opts.len(), 3); + assert_eq!(opts[0], "Option A"); + } + + #[tokio::test] + async fn test_ask_user_empty_question() { + let tool = AskUserTool; + let result = tool + .execute(json!({"question": ""}), &test_ctx()) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.yield_data.is_none()); + } + + #[tokio::test] + async fn test_ask_user_missing_question() { + let tool = AskUserTool; + let result = tool.execute(json!({}), &test_ctx()).await.unwrap(); + + assert!(result.is_error); + assert!(result.yield_data.is_none()); + } +} diff --git a/crates/agent/src/tool/bash.rs b/crates/agent/src/tool/bash.rs new file mode 100644 index 00000000..afdfc4c9 --- /dev/null +++ b/crates/agent/src/tool/bash.rs @@ -0,0 +1,315 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use crate::error::ToolError; +use crate::types::AgentEvent; +use crate::util::truncate_output; +use super::{Tool, ToolContext, ToolResult}; + +const DEFAULT_TIMEOUT_MS: u64 = 120_000; +const MAX_OUTPUT_LINES: usize = 2000; +const MAX_OUTPUT_BYTES: usize = 50 * 1024; + +pub struct BashTool; + +#[async_trait] +impl Tool for BashTool { + fn name(&self) -> &str { + "bash" + } + + fn description(&self) -> &str { + "Execute a bash command. The command runs in a shell with the working directory set to \ + the project root. Returns stdout and stderr combined with the exit code." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["command", "description"], + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute" + }, + "description": { + "type": "string", + "description": "A short 5-10 word description of what this command does" + }, + "timeout": { + "type": "integer", + "description": "Timeout in milliseconds (default 120000)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let command = args + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: command".into()))?; + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Running command"); + + let timeout_ms = args + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_MS); + + // Emit status event + let _ = ctx.event_tx.send(AgentEvent::ToolStatus { + session_id: ctx.session_id.clone(), + tool_call_id: ctx.tool_call_id.clone(), + status: format!("Running: {description}"), + }).await; + + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(&ctx.working_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + git_ops::no_window::no_window_tokio(&mut cmd); + let child = cmd + .spawn() + .map_err(|e| ToolError(format!("Failed to spawn command: {e}")))?; + + let timeout_duration = std::time::Duration::from_millis(timeout_ms); + + // wait_with_output takes ownership, so timeout/cancel rely on kill_on_drop + // when the future is dropped by tokio::select! + let result = tokio::select! { + result = child.wait_with_output() => { + match result { + Ok(output) => Ok(output), + Err(e) => Err(ToolError(format!("Command execution error: {e}"))), + } + } + _ = tokio::time::sleep(timeout_duration) => { + // child is dropped here → kill_on_drop kills it + Err(ToolError(format!("Command timed out after {timeout_ms}ms"))) + } + _ = ctx.cancel_token.cancelled() => { + // child is dropped here → kill_on_drop kills it + Err(ToolError("Command cancelled".into())) + } + }; + + match result { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let exit_code = output.status.code().unwrap_or(-1); + + let mut combined = String::new(); + if !stdout.is_empty() { + combined.push_str(&stdout); + } + if !stderr.is_empty() { + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str("STDERR:\n"); + combined.push_str(&stderr); + } + + // Truncate output + let combined = truncate_output(&combined, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES); + + let output_text = format!("Exit code: {exit_code}\n{combined}"); + + Ok(ToolResult { + output: output_text, + is_error: exit_code != 0, + yield_data: None, + modified_files: Vec::new(), + }) + } + Err(e) => Ok(ToolResult::error(e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_simple_echo() { + let dir = tempdir().unwrap(); + let tool = BashTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "command": "echo hello world", "description": "echo test" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Exit code: 0")); + assert!(result.output.contains("hello world")); + } + + #[tokio::test] + async fn test_stderr_capture() { + let dir = tempdir().unwrap(); + let tool = BashTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "command": "echo err >&2", "description": "stderr test" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("STDERR:")); + assert!(result.output.contains("err")); + } + + #[tokio::test] + async fn test_nonzero_exit() { + let dir = tempdir().unwrap(); + let tool = BashTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "command": "exit 42", "description": "exit code test" }), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("Exit code: 42")); + } + + #[tokio::test] + async fn test_timeout() { + let dir = tempdir().unwrap(); + let tool = BashTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "command": "sleep 10", "description": "sleep test", "timeout": 100 }), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("timed out")); + } + + #[tokio::test] + async fn test_cancellation() { + let dir = tempdir().unwrap(); + let (tx, _rx) = mpsc::channel(32); + let cancel_token = CancellationToken::new(); + + let ctx = ToolContext { + working_dir: dir.path().to_path_buf(), + cancel_token: cancel_token.clone(), + event_tx: tx, + session_id: "test".into(), + tool_call_id: "tc_1".into(), + }; + + let tool = BashTool; + + // Cancel after a brief delay + let cancel = cancel_token.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + cancel.cancel(); + }); + + let result = tool + .execute( + json!({ "command": "sleep 10", "description": "cancel test" }), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("cancelled")); + } + + #[tokio::test] + async fn test_working_dir() { + let dir = tempdir().unwrap(); + let tool = BashTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "command": "pwd", "description": "pwd test" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + // The output should contain the temp dir path + // On macOS, tempdir is under /private/var or /var, and pwd resolves symlinks + // so we just check it's in the output somewhere + assert!(result.output.contains("Exit code: 0")); + } + + // ── truncate_output tests (delegates to shared util::truncate_output) ── + + #[test] + fn test_truncate_output_small_input_unchanged() { + let input = "line 1\nline 2\nline 3"; + let result = truncate_output(input, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES); + assert_eq!(result, input); + } + + #[test] + fn test_truncate_output_empty_input() { + assert_eq!(truncate_output("", MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES), ""); + } + + #[test] + fn test_truncate_output_exceeds_line_limit() { + let lines: String = (0..2500).map(|i| format!("line {i}\n")).collect(); + let result = truncate_output(&lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES); + + assert!(result.contains("truncated")); + assert!(result.contains("2000 of 2500 lines")); + } + + #[test] + fn test_truncate_output_exceeds_byte_limit() { + let lines: String = (0..1200).map(|i| format!("this is line number {:04} with some padding text\n", i)).collect(); + assert!(lines.len() > MAX_OUTPUT_BYTES); + + let result = truncate_output(&lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES); + assert!(result.contains("truncated")); + assert!(result.len() <= MAX_OUTPUT_BYTES + 100); + } + + #[test] + fn test_truncate_output_exactly_at_line_limit() { + let lines: String = (0..2000).map(|_| "x\n").collect(); + let result = truncate_output(&lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES); + assert!(!result.contains("truncated")); + } +} diff --git a/crates/agent/src/tool/codebase_graph.rs b/crates/agent/src/tool/codebase_graph.rs new file mode 100644 index 00000000..fc742e2f --- /dev/null +++ b/crates/agent/src/tool/codebase_graph.rs @@ -0,0 +1,423 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::context_engine::ContextEngineApi; +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; + +pub struct CodebaseGraphTool { + context_engine: Arc, +} + +impl CodebaseGraphTool { + pub fn new(context_engine: Arc) -> Self { + Self { context_engine } + } +} + +#[async_trait] +impl Tool for CodebaseGraphTool { + fn name(&self) -> &str { + "codebase_graph" + } + + fn description(&self) -> &str { + "Get the dependency graph for a function or symbol. Returns who calls it \ + (blast radius / upstream) and what it calls (dependencies / downstream). \ + USE THIS BEFORE modifying a function to understand what code depends on it and what \ + might break. Essential for refactoring, deprecations, and impact analysis. \ + Requires at least one of: query, function_name, or file_path. \ + If the graph is still being built or unavailable, this tool will tell you — fall back \ + to grep/glob for textual searches in that case." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language query about code dependencies (e.g., 'what calls the auth middleware')" + }, + "function_name": { + "type": "string", + "description": "Specific function/method name to look up directly in the graph" + }, + "file_path": { + "type": "string", + "description": "File path to disambiguate function lookup when name is not unique" + }, + "query_type": { + "type": "string", + "enum": ["blast_radius", "dependencies"], + "description": "Type of graph query. 'blast_radius' finds upstream callers. 'dependencies' finds downstream callees. Omit for both." + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let query = args.get("query").and_then(|v| v.as_str()); + let function_name = args.get("function_name").and_then(|v| v.as_str()); + let file_path = args.get("file_path").and_then(|v| v.as_str()); + let query_type = args.get("query_type").and_then(|v| v.as_str()); + + // Validate at least one lookup key is provided + if query.is_none() && function_name.is_none() { + return Ok(ToolResult::error( + "Either 'query' or 'function_name' must be provided. Use 'query' for \ + natural language ('what calls the auth handler') or 'function_name' for \ + direct lookup ('ValidateToken')." + )); + } + + let _ = ctx; // ToolContext not needed for graph queries (no worktree overlay) + + log::info!( + "[codebase_graph] query={:?}, function_name={:?}, file_path={:?}, query_type={:?}", + query, function_name, file_path, query_type + ); + + let start = std::time::Instant::now(); + let response = match self + .context_engine + .graph_query(query, function_name, file_path, query_type) + .await + { + Ok(resp) => resp, + Err(e) => { + log::warn!("[codebase_graph] Context engine error after {:?}: {e}", start.elapsed()); + return Ok(ToolResult::error(format!( + "Context engine is unavailable. Use grep to find function references \ + manually. Error: {e}" + ))); + } + }; + let elapsed = start.elapsed(); + + if response.indexing { + log::info!("[codebase_graph] Graph not ready (took {:?})", elapsed); + return Ok(ToolResult::success( + "The codebase dependency graph is being built. Use grep to find function \ + references manually while indexing completes." + )); + } + + if response.results.is_empty() { + log::info!("[codebase_graph] 0 results (took {:?})", elapsed); + return Ok(ToolResult::success( + "No dependency information found for this query. The function may not be \ + indexed, or it has no callers/dependencies in the graph." + )); + } + + let callers_count = response.results.iter().filter(|r| r.direction.as_deref() == Some("caller")).count(); + let deps_count = response.results.iter().filter(|r| r.direction.as_deref() == Some("dependency")).count(); + log::info!( + "[codebase_graph] {} results (callers={}, deps={}, took {:?}). Top: {}", + response.results.len(), + callers_count, + deps_count, + elapsed, + response.results.iter().take(5).map(|r| format!("{}({})", r.name, r.file_path)).collect::>().join(", ") + ); + + // Build the lookup label for the header + let lookup_label = if let Some(fname) = function_name { + fname.to_string() + } else if let Some(q) = query { + q.to_string() + } else { + "query".to_string() + }; + + // Partition results by direction + let callers: Vec<_> = response + .results + .iter() + .filter(|r| r.direction.as_deref() == Some("caller")) + .collect(); + let dependencies: Vec<_> = response + .results + .iter() + .filter(|r| r.direction.as_deref() == Some("dependency")) + .collect(); + // Results with no direction or unknown direction + let other: Vec<_> = response + .results + .iter() + .filter(|r| { + r.direction.as_deref() != Some("caller") + && r.direction.as_deref() != Some("dependency") + }) + .collect(); + + let mut output = format!( + "Dependency graph for \"{}\" — {} results:\n\n", + lookup_label, + response.results.len() + ); + + let show_callers = query_type.is_none() || query_type == Some("blast_radius"); + let show_deps = query_type.is_none() || query_type == Some("dependencies"); + + if show_callers && !callers.is_empty() { + output.push_str("## Blast Radius (upstream callers)\n\n"); + for (i, item) in callers.iter().enumerate() { + output.push_str(&format!( + "{}. {} ({}, depth: {})\n", + i + 1, + item.name, + item.file_path, + item.depth, + )); + } + output.push('\n'); + } + + if show_deps && !dependencies.is_empty() { + output.push_str("## Dependencies (downstream)\n\n"); + for (i, item) in dependencies.iter().enumerate() { + output.push_str(&format!( + "{}. {} ({}, depth: {})\n", + i + 1, + item.name, + item.file_path, + item.depth, + )); + } + output.push('\n'); + } + + if !other.is_empty() && query_type.is_none() { + output.push_str("## Related\n\n"); + for (i, item) in other.iter().enumerate() { + output.push_str(&format!( + "{}. {} ({}, depth: {})\n", + i + 1, + item.name, + item.file_path, + item.depth, + )); + } + output.push('\n'); + } + + Ok(ToolResult::success(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context_engine::{ + ContextEngineError, GraphQueryResponse, GraphResultItem, IndexStatusResponse, + MockContextEngine, SearchResponse, + }; + use crate::tool::ToolContext; + + // --- Test-only mock that always returns errors --- + struct FailingContextEngine; + + #[async_trait] + impl ContextEngineApi for FailingContextEngine { + async fn index_status(&self) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + async fn search( + &self, + _query: &str, + _strategy: Option<&str>, + _limit: Option, + ) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + async fn graph_query( + &self, + _query: Option<&str>, + _function_name: Option<&str>, + _file_path: Option<&str>, + _query_type: Option<&str>, + ) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + } + + fn make_caller_results() -> Vec { + vec![ + GraphResultItem { + name: "AuthMiddleware".into(), + file_path: "src/handler.go".into(), + chunk_id: "g1".into(), + depth: 1, + direction: Some("caller".into()), + }, + GraphResultItem { + name: "HandleWebSocket".into(), + file_path: "src/ws/connection.go".into(), + chunk_id: "g2".into(), + depth: 2, + direction: Some("caller".into()), + }, + ] + } + + fn make_dependency_results() -> Vec { + vec![GraphResultItem { + name: "jwt.ParseWithClaims".into(), + file_path: "vendor/jwt/parser.go".into(), + chunk_id: "g3".into(), + depth: 1, + direction: Some("dependency".into()), + }] + } + + fn make_mixed_results() -> Vec { + let mut results = make_caller_results(); + results.extend(make_dependency_results()); + results + } + + #[tokio::test] + async fn test_graph_formats_blast_radius() { + let mock = MockContextEngine::with_graph_results(make_caller_results()); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"function_name": "ValidateToken", "query_type": "blast_radius"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Blast Radius")); + assert!(result.output.contains("AuthMiddleware")); + assert!(result.output.contains("HandleWebSocket")); + assert!(result.output.contains("src/handler.go")); + assert!(result.output.contains("depth: 1")); + assert!(result.output.contains("depth: 2")); + } + + #[tokio::test] + async fn test_graph_formats_dependencies() { + let mock = MockContextEngine::with_graph_results(make_dependency_results()); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"function_name": "ValidateToken", "query_type": "dependencies"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Dependencies")); + assert!(result.output.contains("jwt.ParseWithClaims")); + assert!(result.output.contains("vendor/jwt/parser.go")); + } + + #[tokio::test] + async fn test_graph_formats_mixed() { + let mock = MockContextEngine::with_graph_results(make_mixed_results()); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"function_name": "ValidateToken"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Blast Radius")); + assert!(result.output.contains("Dependencies")); + assert!(result.output.contains("3 results")); + } + + #[tokio::test] + async fn test_graph_cold_start() { + let mock = MockContextEngine::not_indexed(); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"function_name": "anything"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("graph is being built")); + assert!(result.output.contains("grep")); + } + + #[tokio::test] + async fn test_graph_unavailable() { + let tool = CodebaseGraphTool::new(Arc::new(FailingContextEngine)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"function_name": "anything"}), &ctx) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("unavailable")); + assert!(result.output.contains("grep")); + } + + #[tokio::test] + async fn test_graph_empty() { + let mock = MockContextEngine::indexed_empty(); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"function_name": "NonexistentFn"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("No dependency information")); + } + + #[tokio::test] + async fn test_graph_no_params() { + let mock = MockContextEngine::indexed_empty(); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool.execute(json!({}), &ctx).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("'query' or 'function_name'")); + } + + #[tokio::test] + async fn test_graph_function_name_only() { + let mock = MockContextEngine::with_graph_results(make_caller_results()); + let tool = CodebaseGraphTool::new(Arc::new(mock)); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"function_name": "ValidateToken"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("ValidateToken")); + } +} diff --git a/crates/agent/src/tool/codebase_search.rs b/crates/agent/src/tool/codebase_search.rs new file mode 100644 index 00000000..cc260ac1 --- /dev/null +++ b/crates/agent/src/tool/codebase_search.rs @@ -0,0 +1,617 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use crate::context_engine::{ContextEngineApi, SearchResultItem}; +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; + +pub struct CodebaseSearchTool { + context_engine: Arc, + /// Canonical repo path (main checkout) — used for overlay detection. + repo_path: PathBuf, +} + +impl CodebaseSearchTool { + pub fn new(context_engine: Arc, repo_path: PathBuf) -> Self { + Self { context_engine, repo_path } + } +} + +#[async_trait] +impl Tool for CodebaseSearchTool { + fn name(&self) -> &str { + "codebase_search" + } + + fn description(&self) -> &str { + "Semantic code search across the entire indexed codebase. Returns relevant code chunks \ + ranked by AI-computed relevance (not keyword match). \ + USE THIS FIRST when you need to: understand how a feature works, find where something \ + is implemented, or explore an unfamiliar codebase. Handles conceptual queries like \ + 'authentication flow' or 'rate limiting logic'. Much more powerful than grep for \ + anything that isn't an exact string match. \ + If the index is still being built or unavailable, this tool will tell you — fall back \ + to grep/glob in that case." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "Natural language description of what you're looking for, or a code identifier / function name" + }, + "strategy": { + "type": "string", + "enum": ["multi", "vector", "keyword", "graph", "hybrid"], + "description": "Search strategy. 'multi' (default) combines vector+keyword. 'vector' for semantic/conceptual. 'keyword' for exact identifiers. 'graph' for dependency-aware. 'hybrid' for vector+graph blend." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return (default: 20)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: query".into()))?; + + let strategy = args.get("strategy").and_then(|v| v.as_str()); + let limit = args.get("limit").and_then(|v| v.as_u64().map(|n| n as u32)); + + log::info!( + "[codebase_search] query=\"{}\", strategy={:?}, limit={:?}", + query, strategy, limit + ); + + let start = std::time::Instant::now(); + let response = match self.context_engine.search(query, strategy, limit).await { + Ok(resp) => resp, + Err(e) => { + log::warn!("[codebase_search] Context engine error after {:?}: {e}", start.elapsed()); + return Ok(ToolResult::error(format!( + "Context engine is unavailable. Use grep and glob tools to search the \ + codebase directly. Error: {e}" + ))); + } + }; + let elapsed = start.elapsed(); + + if response.indexing { + log::info!("[codebase_search] Index not ready (took {:?})", elapsed); + return Ok(ToolResult::success( + "The codebase is currently being indexed. This is a one-time process that \ + takes 2-5 minutes. Use grep and glob tools to search manually while \ + indexing completes." + )); + } + + if response.results.is_empty() { + log::info!("[codebase_search] 0 results for \"{}\" (took {:?})", query, elapsed); + return Ok(ToolResult::success(format!( + "No results found for query '{query}'. Try rephrasing, using a different \ + strategy (keyword for exact matches, vector for conceptual), or use grep \ + for exact pattern matching." + ))); + } + + log::info!( + "[codebase_search] {} results for \"{}\" (took {:?}). Top files: {}", + response.results.len(), + query, + elapsed, + response.results.iter().take(5).map(|r| format!("{}({:.2})", r.file_path, r.score)).collect::>().join(", ") + ); + + let mut results = response.results; + + // Apply worktree overlay when running in a worktree (working_dir != repo_path) + if ctx.working_dir != self.repo_path { + log::info!("[codebase_search] Worktree detected (working_dir={:?}, repo_path={:?}), applying overlay", + ctx.working_dir, self.repo_path); + if let Err(e) = apply_worktree_overlay(&mut results, &ctx.working_dir).await { + log::warn!("[codebase_search] Worktree overlay failed, skipping: {e}"); + } + } else { + log::info!("[codebase_search] No worktree (working_dir == repo_path), skipping overlay"); + } + + let mut output = format!("Found {} results for \"{}\":\n\n", results.len(), query); + for (i, item) in results.iter().enumerate() { + output.push_str(&format!( + "## {}. {} (score: {:.2}, source: {})\n```{}\n{}\n```\n\n", + i + 1, + item.file_path, + item.score, + item.source, + item.language, + item.content, + )); + } + + Ok(ToolResult::success(output)) + } +} + +/// Overlay search results with worktree state. +/// Modified files: annotated (chunk content may be stale; no line-range data to re-extract). +/// Deleted files: drop from results. +/// New files: NOT added (not in index to match against). +async fn apply_worktree_overlay( + results: &mut Vec, + worktree_path: &Path, +) -> Result<(), ToolError> { + // 1. Get all modified + added files + let mut cmd = Command::new("git"); + cmd.args(["diff", "--name-only", "HEAD"]) + .current_dir(worktree_path); + git_ops::no_window::no_window_tokio(&mut cmd); + let output = cmd + .output() + .await + .map_err(|e| ToolError(format!("git diff failed: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ToolError(format!("git diff failed: {stderr}"))); + } + + let changed_files: HashSet = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.to_string()) + .collect(); + + // 2. Get deleted files specifically + let mut cmd = Command::new("git"); + cmd.args(["diff", "--name-only", "--diff-filter=D", "HEAD"]) + .current_dir(worktree_path); + git_ops::no_window::no_window_tokio(&mut cmd); + let del_output = cmd + .output() + .await + .map_err(|e| ToolError(format!("git diff failed: {e}")))?; + + if !del_output.status.success() { + let stderr = String::from_utf8_lossy(&del_output.stderr); + return Err(ToolError(format!("git diff (deleted) failed: {stderr}"))); + } + + let deleted_files: HashSet = String::from_utf8_lossy(&del_output.stdout) + .lines() + .map(|l| l.to_string()) + .collect(); + + log::info!("[codebase_search] overlay: {} changed files, {} deleted files", + changed_files.len(), deleted_files.len()); + + // 3. Drop results for deleted files + let before_len = results.len(); + results.retain(|r| !deleted_files.contains(&r.file_path)); + if results.len() < before_len { + log::info!("[codebase_search] overlay: dropped {} deleted-file results", before_len - results.len()); + } + + // 4. For modified files in results, annotate that content may be stale. + // We don't replace with full-file content because SearchResultItem holds a + // chunk (snippet), not a whole file — replacing would bloat context. + // The LLM can use the `read` tool to see current content if needed. + let mut annotated = 0usize; + for result in results.iter_mut() { + if changed_files.contains(&result.file_path) { + log::info!("[codebase_search] overlay: annotating stale file {}", result.file_path); + annotated += 1; + result.content = format!( + "/* NOTE: This file has local modifications in the worktree. \ + Content below is from the index and may be stale. \ + Use the read tool to see current content. */\n{}", + result.content + ); + } + } + if annotated > 0 { + log::info!("[codebase_search] overlay: annotated {} results as stale", annotated); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context_engine::{ + ContextEngineError, GraphQueryResponse, IndexStatusResponse, + MockContextEngine, SearchResponse, SearchResultItem, + }; + use crate::tool::ToolContext; + + // --- Test-only mock that always returns errors --- + struct FailingContextEngine; + + #[async_trait] + impl ContextEngineApi for FailingContextEngine { + async fn index_status(&self) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + async fn search( + &self, + _query: &str, + _strategy: Option<&str>, + _limit: Option, + ) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + async fn graph_query( + &self, + _query: Option<&str>, + _function_name: Option<&str>, + _file_path: Option<&str>, + _query_type: Option<&str>, + ) -> Result { + Err(ContextEngineError::Unavailable("test error".into())) + } + } + + fn make_search_results() -> Vec { + vec![ + SearchResultItem { + chunk_id: "c1".into(), + content: "fn authenticate() { /* auth logic */ }".into(), + file_path: "src/auth.rs".into(), + language: "rust".into(), + score: 0.95, + source: "vector".into(), + }, + SearchResultItem { + chunk_id: "c2".into(), + content: "fn validate_token() { /* token check */ }".into(), + file_path: "src/token.rs".into(), + language: "rust".into(), + score: 0.82, + source: "keyword".into(), + }, + SearchResultItem { + chunk_id: "c3".into(), + content: "class AuthHandler { }".into(), + file_path: "src/handler.ts".into(), + language: "typescript".into(), + score: 0.71, + source: "hybrid".into(), + }, + ] + } + + #[tokio::test] + async fn test_search_formats_results() { + let mock = MockContextEngine::with_search_results(make_search_results()); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "auth"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + let out = &result.output; + assert!(out.contains("Found 3 results")); + assert!(out.contains("src/auth.rs")); + assert!(out.contains("src/token.rs")); + assert!(out.contains("src/handler.ts")); + assert!(out.contains("score: 0.95")); + assert!(out.contains("source: vector")); + assert!(out.contains("fn authenticate()")); + assert!(out.contains("```rust")); + assert!(out.contains("```typescript")); + } + + #[tokio::test] + async fn test_search_cold_start() { + let mock = MockContextEngine::not_indexed(); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "anything"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("currently being indexed")); + assert!(result.output.contains("grep and glob")); + } + + #[tokio::test] + async fn test_search_unavailable() { + let tool = CodebaseSearchTool::new( + Arc::new(FailingContextEngine), + PathBuf::from("/repo"), + ); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "anything"}), &ctx) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("unavailable")); + assert!(result.output.contains("grep and glob")); + } + + #[tokio::test] + async fn test_search_empty_results() { + let mock = MockContextEngine::indexed_empty(); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "nonexistent"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("No results found")); + assert!(result.output.contains("nonexistent")); + } + + #[tokio::test] + async fn test_search_strategy_passthrough() { + // MockContextEngine ignores args, but we verify the tool parses and doesn't error + let mock = MockContextEngine::with_search_results(make_search_results()); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "auth", "strategy": "keyword"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Found 3 results")); + } + + #[tokio::test] + async fn test_search_limit_passthrough() { + let mock = MockContextEngine::with_search_results(make_search_results()); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "auth", "limit": 5}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Found 3 results")); + } + + #[tokio::test] + async fn test_search_defaults() { + let mock = MockContextEngine::with_search_results(make_search_results()); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/repo")); + let dir = tempfile::tempdir().unwrap(); + let ctx = ToolContext::test_context(dir.path()); + + // No strategy or limit — should work fine + let result = tool + .execute(json!({"query": "auth"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + } + + #[tokio::test] + async fn test_overlay_not_applied_same_dir() { + let mock = MockContextEngine::with_search_results(make_search_results()); + let dir = tempfile::tempdir().unwrap(); + // repo_path == working_dir → no overlay + let tool = CodebaseSearchTool::new(Arc::new(mock), dir.path().to_path_buf()); + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({"query": "auth"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + // All 3 results present (no overlay filtering) + assert!(result.output.contains("Found 3 results")); + assert!(result.output.contains("fn authenticate()")); + } + + #[tokio::test] + async fn test_overlay_patches_modified_file() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + + // Set up a git repo with an initial commit + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir_path) + .output() + .unwrap(); + std::fs::create_dir_all(dir_path.join("src")).unwrap(); + std::fs::write(dir_path.join("src/auth.rs"), "fn old_content() {}").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir_path) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + + // Now modify the file + std::fs::write(dir_path.join("src/auth.rs"), "fn new_content() {}").unwrap(); + + let results = vec![SearchResultItem { + chunk_id: "c1".into(), + content: "fn old_content() {}".into(), + file_path: "src/auth.rs".into(), + language: "rust".into(), + score: 0.95, + source: "vector".into(), + }]; + let mock = MockContextEngine::with_search_results(results); + // repo_path different from working_dir → overlay triggers + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/different/repo")); + let ctx = ToolContext::test_context(dir_path); + + let result = tool + .execute(json!({"query": "auth"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + // Content should be annotated as modified (not replaced with full file) + assert!(result.output.contains("local modifications")); + assert!(result.output.contains("may be stale")); + // Original chunk content is preserved (with annotation prepended) + assert!(result.output.contains("fn old_content()")); + } + + #[tokio::test] + async fn test_overlay_drops_deleted_file() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + + // Set up a git repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir_path) + .output() + .unwrap(); + std::fs::create_dir_all(dir_path.join("src")).unwrap(); + std::fs::write(dir_path.join("src/deleted.rs"), "fn gone() {}").unwrap(); + std::fs::write(dir_path.join("src/kept.rs"), "fn stay() {}").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir_path) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + + // Delete one file + std::fs::remove_file(dir_path.join("src/deleted.rs")).unwrap(); + + let results = vec![ + SearchResultItem { + chunk_id: "c1".into(), + content: "fn gone() {}".into(), + file_path: "src/deleted.rs".into(), + language: "rust".into(), + score: 0.90, + source: "vector".into(), + }, + SearchResultItem { + chunk_id: "c2".into(), + content: "fn stay() {}".into(), + file_path: "src/kept.rs".into(), + language: "rust".into(), + score: 0.80, + source: "keyword".into(), + }, + ]; + let mock = MockContextEngine::with_search_results(results); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/different/repo")); + let ctx = ToolContext::test_context(dir_path); + + let result = tool + .execute(json!({"query": "test"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + // Deleted file should be dropped + assert!(!result.output.contains("src/deleted.rs")); + assert!(!result.output.contains("fn gone()")); + // Kept file should remain + assert!(result.output.contains("src/kept.rs")); + assert!(result.output.contains("Found 1 results")); + } + + #[tokio::test] + async fn test_overlay_passes_unmodified() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + + // Set up a git repo with a clean file + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir_path) + .output() + .unwrap(); + std::fs::create_dir_all(dir_path.join("src")).unwrap(); + std::fs::write(dir_path.join("src/clean.rs"), "fn original() {}").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir_path) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + + // Don't modify any file — overlay should leave content unchanged + let results = vec![SearchResultItem { + chunk_id: "c1".into(), + content: "fn original() {}".into(), + file_path: "src/clean.rs".into(), + language: "rust".into(), + score: 0.90, + source: "vector".into(), + }]; + let mock = MockContextEngine::with_search_results(results); + let tool = CodebaseSearchTool::new(Arc::new(mock), PathBuf::from("/different/repo")); + let ctx = ToolContext::test_context(dir_path); + + let result = tool + .execute(json!({"query": "test"}), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("fn original()")); + assert!(result.output.contains("Found 1 results")); + // No modification annotation on clean files + assert!(!result.output.contains("local modifications")); + } +} diff --git a/crates/agent/src/tool/edit.rs b/crates/agent/src/tool/edit.rs new file mode 100644 index 00000000..cf69d170 --- /dev/null +++ b/crates/agent/src/tool/edit.rs @@ -0,0 +1,765 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::util::{floor_char_boundary, resolve_path}; +use super::{Tool, ToolContext, ToolResult}; + +pub struct EditTool; + +#[async_trait] +impl Tool for EditTool { + fn name(&self) -> &str { + "edit" + } + + fn description(&self) -> &str { + "Make exact string replacements in files. Specify old_string to search for and new_string \ + to replace it with. Supports fuzzy whitespace and indentation matching. \ + Use replaceAll=true to replace all occurrences." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["filePath", "oldString", "newString"], + "properties": { + "filePath": { + "type": "string", + "description": "Absolute or relative path to the file to edit" + }, + "oldString": { + "type": "string", + "description": "The text to search for in the file" + }, + "newString": { + "type": "string", + "description": "The replacement text" + }, + "replaceAll": { + "type": "boolean", + "description": "Replace all occurrences (default false)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let file_path = args + .get("filePath") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: filePath".into()))?; + + let old_string = args + .get("oldString") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: oldString".into()))?; + + let new_string = args + .get("newString") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: newString".into()))?; + + let replace_all = args + .get("replaceAll") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let path = resolve_path(&ctx.working_dir, file_path); + + // Note: path traversal check (outside working dir) is handled by the agent loop + // before execute() is called, with user approval flow. + + if !path.exists() { + return Ok(ToolResult::error(format!("Error: File not found: {}", path.display()))); + } + + if !path.is_file() { + return Ok(ToolResult::error(format!("Error: Path is not a file: {}", path.display()))); + } + + let raw_content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| ToolError(format!("Failed to read file: {e}")))?; + + match replace(&raw_content, old_string, new_string, replace_all) { + Ok(new_content) => { + // Preserve original line ending style + tokio::fs::write(&path, &new_content) + .await + .map_err(|e| ToolError(format!("Failed to write file: {e}")))?; + + let count_msg = if replace_all { " (all occurrences)" } else { "" }; + Ok(ToolResult::success(format!( + "Successfully edited {}{count_msg}", + path.display() + ))) + } + Err(e) => Ok(ToolResult::error(e)), + } + } +} + +/// Detect whether the file uses CRLF line endings. +fn detect_crlf(content: &str) -> bool { + // Check first ~1000 bytes for \r\n (use floor_char_boundary to avoid UTF-8 panic) + let check = &content[..floor_char_boundary(content, 1000)]; + check.contains("\r\n") +} + +/// Normalize CRLF to LF. +fn normalize_lf(s: &str) -> String { + s.replace("\r\n", "\n") +} + +/// Convert LF back to CRLF. +fn to_crlf(s: &str) -> String { + // First normalize to LF, then convert to CRLF + normalize_lf(s).replace('\n', "\r\n") +} + +/// The core replacement logic with multi-strategy fuzzy matching. +/// Public within the crate so `edit_plan` can reuse it. +pub(crate) fn replace( + content: &str, + old_string: &str, + new_string: &str, + replace_all: bool, +) -> Result { + // Validation + if old_string.is_empty() { + return Err("Error: oldString must not be empty".into()); + } + if old_string == new_string { + return Err("Error: oldString and newString are identical — no change needed".into()); + } + + let is_crlf = detect_crlf(content); + + // Normalize to LF for matching + let content_lf = normalize_lf(content); + let old_lf = normalize_lf(old_string); + let new_lf = normalize_lf(new_string); + + // Try each replacer in order (most precise → most fuzzy) + type Replacer = fn(&str, &str) -> Vec; + let replacers: &[(&str, Replacer)] = &[ + ("exact", exact_replacer), + ("line_trimmed", line_trimmed_replacer), + ("block_anchor", block_anchor_replacer), + ("whitespace", whitespace_normalized_replacer), + ("indentation", indentation_flexible_replacer), + ("escape_normalized", escape_normalized_replacer), + ("trimmed_boundary", trimmed_boundary_replacer), + ("context_aware", context_aware_replacer), + ]; + + let mut any_candidates_found = false; + + for (strategy_name, replacer) in replacers { + let candidates = replacer(&content_lf, &old_lf); + if candidates.is_empty() { + continue; + } + any_candidates_found = true; + + for candidate in &candidates { + let count = count_occurrences(&content_lf, candidate); + if count == 0 { + continue; // fuzzy match produced a candidate not actually in the content + } + if replace_all { + log::info!("[v1.0] edit: matched via '{}' strategy (replace_all, {} occurrences)", strategy_name, count); + let result = content_lf.replace(candidate, &new_lf); + return Ok(if is_crlf { to_crlf(&result) } else { result }); + } + if count == 1 { + log::info!("[v1.0] edit: matched via '{}' strategy (single match)", strategy_name); + let result = content_lf.replacen(candidate, &new_lf, 1); + return Ok(if is_crlf { to_crlf(&result) } else { result }); + } + // Multiple matches for this candidate — try next candidate/replacer + } + } + + if !any_candidates_found { + Err("No match found for the specified oldString in the file. \ + Read the file again and copy the exact text you want to replace, including 2-3 surrounding lines for context." + .into()) + } else { + // Enhanced: show match locations with line numbers so the LLM can disambiguate + let mut locations = Vec::new(); + // Use first candidate that had matches for location reporting + for (_name, replacer) in replacers { + let candidates = replacer(&content_lf, &old_lf); + for candidate in &candidates { + let mut search_from = 0; + while let Some(pos) = content_lf[search_from..].find(candidate.as_str()) { + let abs_pos = search_from + pos; + let line_num = content_lf[..abs_pos].matches('\n').count() + 1; + let preview_end = (abs_pos + 60).min(content_lf.len()); + let preview = content_lf[abs_pos..preview_end].replace('\n', "\\n"); + locations.push(format!(" Line {}: {}...", line_num, preview)); + search_from = abs_pos + candidate.len(); + if locations.len() >= 5 { + break; + } + } + if !locations.is_empty() { + break; + } + } + if !locations.is_empty() { + break; + } + } + let locations_str = if locations.is_empty() { + String::new() + } else { + format!("\nMatch locations:\n{}", locations.join("\n")) + }; + Err(format!( + "Multiple matches found for the specified oldString. \ + Add more surrounding lines to oldString to make it unique, or use replaceAll=true.{}", + locations_str + )) + } +} + +/// Count non-overlapping occurrences of `needle` in `haystack`. +fn count_occurrences(haystack: &str, needle: &str) -> usize { + if needle.is_empty() { + return 0; + } + haystack.matches(needle).count() +} + +// ── Replacer 1: Exact ── + +fn exact_replacer(content: &str, find: &str) -> Vec { + if content.contains(find) { + vec![find.to_string()] + } else { + vec![] + } +} + +// ── Replacer 2: Whitespace Normalized ── + +fn normalize_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn whitespace_normalized_replacer(content: &str, find: &str) -> Vec { + let find_normalized = normalize_whitespace(find); + if find_normalized.is_empty() { + return vec![]; + } + + let find_lines: Vec<&str> = find.lines().collect(); + + if find_lines.len() <= 1 { + // Single-line find + single_line_whitespace_match(content, &find_normalized) + } else { + // Multi-line find + multi_line_whitespace_match(content, &find_normalized, find_lines.len()) + } +} + +fn single_line_whitespace_match(content: &str, find_normalized: &str) -> Vec { + // Compile the whitespace-flexible regex once, outside the loop + let words: Vec<&str> = find_normalized.split(' ').collect(); + let pattern = words + .iter() + .map(|w| regex::escape(w)) + .collect::>() + .join(r"\s+"); + let ws_regex = regex::Regex::new(&pattern).ok(); + + let mut candidates = Vec::new(); + for line in content.lines() { + let line_normalized = normalize_whitespace(line); + if line_normalized == *find_normalized { + candidates.push(line.to_string()); + } else if line_normalized.contains(find_normalized) { + if let Some(ref re) = ws_regex { + if let Some(m) = re.find(line) { + candidates.push(m.as_str().to_string()); + } + } + } + } + candidates +} + +fn multi_line_whitespace_match( + content: &str, + find_normalized: &str, + find_line_count: usize, +) -> Vec { + let content_lines: Vec<&str> = content.lines().collect(); + let mut candidates = Vec::new(); + + if content_lines.len() < find_line_count { + return candidates; + } + + for start in 0..=(content_lines.len() - find_line_count) { + let window = &content_lines[start..start + find_line_count]; + let window_text = window.join("\n"); + let window_normalized = normalize_whitespace(&window_text); + if window_normalized == *find_normalized { + candidates.push(window_text); + } + } + candidates +} + +// ── Replacer 2b: Line Trimmed ── + +fn line_trimmed_replacer(content: &str, find: &str) -> Vec { + let find_lines: Vec<&str> = find.lines().collect(); + if find_lines.is_empty() { + return vec![]; + } + let find_trimmed: Vec<&str> = find_lines.iter().map(|l| l.trim()).collect(); + let content_lines: Vec<&str> = content.lines().collect(); + let mut candidates = Vec::new(); + + if content_lines.len() < find_lines.len() { + return candidates; + } + + for start in 0..=content_lines.len() - find_lines.len() { + let window = &content_lines[start..start + find_lines.len()]; + let window_trimmed: Vec<&str> = window.iter().map(|l| l.trim()).collect(); + if window_trimmed == find_trimmed { + candidates.push(window.join("\n")); + } + } + candidates +} + +// ── Replacer 2c: Block Anchor (Levenshtein) ── + +fn block_anchor_replacer(content: &str, find: &str) -> Vec { + let find_lines: Vec<&str> = find.lines().collect(); + if find_lines.len() < 3 { + return vec![]; + } + + let anchor_size = 2.min(find_lines.len() / 2); + let first_anchor: String = find_lines[..anchor_size].join("\n"); + let last_anchor: String = find_lines[find_lines.len() - anchor_size..].join("\n"); + let content_lines: Vec<&str> = content.lines().collect(); + let mut candidates = Vec::new(); + + if content_lines.len() < find_lines.len() { + return candidates; + } + + for start in 0..=content_lines.len() - find_lines.len() { + let window = &content_lines[start..start + find_lines.len()]; + let window_first: String = window[..anchor_size].join("\n"); + let window_last: String = window[window.len() - anchor_size..].join("\n"); + + let first_sim = strsim::normalized_levenshtein(&first_anchor, &window_first); + let last_sim = strsim::normalized_levenshtein(&last_anchor, &window_last); + + if first_sim >= 0.7 && last_sim >= 0.7 { + candidates.push(window.join("\n")); + } + } + candidates +} + +// ── Replacer 3: Indentation Flexible ── + +fn de_indent(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + // Count indentation in bytes — safe because we only slice at the same byte offsets below + // and indentation is virtually always ASCII (spaces/tabs). We use find_char_boundary + // as a safety net so we never panic on non-ASCII indentation. + let min_indent = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + + lines + .iter() + .map(|l| { + if l.trim().is_empty() { + "" + } else { + let cut = floor_char_boundary(l, min_indent); + &l[cut..] + } + }) + .collect::>() + .join("\n") +} + +fn indentation_flexible_replacer(content: &str, find: &str) -> Vec { + let find_lines: Vec<&str> = find.lines().collect(); + let find_line_count = find_lines.len(); + if find_line_count == 0 { + return vec![]; + } + + let de_indented_find = de_indent(find); + let content_lines: Vec<&str> = content.lines().collect(); + let mut candidates = Vec::new(); + + if content_lines.len() < find_line_count { + return candidates; + } + + for start in 0..=(content_lines.len() - find_line_count) { + let window = &content_lines[start..start + find_line_count]; + let window_text = window.join("\n"); + let de_indented_window = de_indent(&window_text); + if de_indented_window == de_indented_find { + candidates.push(window_text); + } + } + candidates +} + +// ── Replacer 5b: Escape Normalized ── + +fn escape_normalized_replacer(content: &str, find: &str) -> Vec { + fn normalize_escapes(s: &str) -> String { + s.replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\'", "'") + .replace("\\\\", "\\") + } + + let find_norm = normalize_escapes(find); + if find_norm == find { + return vec![]; // No escapes to normalize — skip + } + // Try exact match with the normalized find string + if content.contains(&find_norm) { + vec![find_norm] + } else { + vec![] + } +} + +// ── Replacer 5c: Trimmed Boundary ── + +fn trimmed_boundary_replacer(content: &str, find: &str) -> Vec { + let find_trimmed = find.trim_matches('\n'); + if find_trimmed == find || find_trimmed.is_empty() { + return vec![]; // No trimming happened or empty — skip + } + exact_replacer(content, find_trimmed) +} + +// ── Replacer 5d: Context Aware (Levenshtein) ── + +fn context_aware_replacer(content: &str, find: &str) -> Vec { + let anchors: Vec<&str> = find.lines().filter(|l| !l.trim().is_empty()).collect(); + if anchors.len() < 2 { + return vec![]; + } + + let first = anchors[0].trim(); + let last = anchors[anchors.len() - 1].trim(); + let content_lines: Vec<&str> = content.lines().collect(); + let expected_len = find.lines().count(); + // Allow some flexibility: search up to expected_len + 5 lines from start + let max_span = expected_len + 5; + + for (i, line) in content_lines.iter().enumerate() { + if strsim::normalized_levenshtein(first, line.trim()) >= 0.7 { + let search_end = content_lines.len().min(i + max_span); + for j in (i + 1..search_end).rev() { + if strsim::normalized_levenshtein(last, content_lines[j].trim()) >= 0.7 { + let candidate = content_lines[i..=j].join("\n"); + return vec![candidate]; + } + } + } + } + vec![] +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // ── replace() unit tests ── + + #[test] + fn test_exact_match() { + let content = "hello world\nfoo bar\n"; + let result = replace(content, "foo bar", "baz qux", false).unwrap(); + assert_eq!(result, "hello world\nbaz qux\n"); + } + + #[test] + fn test_whitespace_mismatch_recovery() { + let content = "if (x == y) {\n return true;\n}\n"; + // Search with different whitespace + let result = replace(content, "if (x == y) {", "if (x != y) {", false).unwrap(); + assert!(result.contains("if (x != y) {")); + } + + #[test] + fn test_indentation_mismatch_recovery() { + let content = " fn foo() {\n bar();\n }\n"; + // Search with no indentation + let result = replace(content, "fn foo() {\n bar();\n}", "fn foo() {\n baz();\n}", false).unwrap(); + assert!(result.contains("baz()")); + } + + #[test] + fn test_tabs_vs_spaces() { + let content = "\tfn foo() {\n\t\tbar();\n\t}\n"; + // Search with spaces + let result = replace(content, "fn foo() {\n bar();\n}", "fn foo() {\n baz();\n}", false).unwrap(); + assert!(result.contains("baz()")); + } + + #[test] + fn test_no_match_error() { + let content = "hello world\n"; + let result = replace(content, "nonexistent text", "replacement", false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No match found")); + } + + #[test] + fn test_multiple_matches_error() { + let content = "foo bar\nfoo bar\n"; + let result = replace(content, "foo bar", "baz", false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Multiple matches")); + } + + #[test] + fn test_replace_all() { + let content = "foo bar\nfoo bar\nbaz\n"; + let result = replace(content, "foo bar", "qux", true).unwrap(); + assert_eq!(result, "qux\nqux\nbaz\n"); + } + + #[test] + fn test_crlf_preserved() { + let content = "hello world\r\nfoo bar\r\n"; + let result = replace(content, "foo bar", "baz qux", false).unwrap(); + assert!(result.contains("\r\n")); + assert!(result.contains("baz qux")); + assert!(!result.contains("foo bar")); + } + + #[test] + fn test_lf_preserved() { + let content = "hello world\nfoo bar\n"; + let result = replace(content, "foo bar", "baz qux", false).unwrap(); + assert!(!result.contains("\r\n")); + assert!(result.contains("baz qux")); + } + + #[test] + fn test_empty_old_string_error() { + let result = replace("content", "", "replacement", false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("must not be empty")); + } + + #[test] + fn test_identical_strings_error() { + let result = replace("content", "foo", "foo", false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("identical")); + } + + // ── Tool-level tests ── + + #[tokio::test] + async fn test_edit_tool_file_not_found() { + let dir = tempdir().unwrap(); + let tool = EditTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ + "filePath": "nonexistent/file.txt", + "oldString": "hello", + "newString": "world" + }), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("File not found")); + } + + #[tokio::test] + async fn test_edit_tool_relative_path() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); + + let tool = EditTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ + "filePath": "test.txt", + "oldString": "hello", + "newString": "goodbye" + }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap(); + assert_eq!(content, "goodbye world"); + } + + // ── de_indent tests ── + + #[test] + fn test_de_indent_basic() { + let text = " hello\n world\n end"; + assert_eq!(de_indent(text), "hello\n world\nend"); + } + + #[test] + fn test_de_indent_blank_lines_preserved() { + let text = " hello\n\n world"; + assert_eq!(de_indent(text), "hello\n\nworld"); + } + + // ── replaceAll with fuzzy match ── + + #[test] + fn test_replace_all_with_indentation_mismatch() { + // Two identical blocks at 4-space indent, searched with 0-space indent + // This exercises the indentation-flexible replacer with replaceAll=true + let content = " fn foo() {\n bar();\n }\n\n fn foo() {\n bar();\n }\n"; + let result = replace(content, "fn foo() {\n bar();\n}", "fn foo() {\n baz();\n}", true).unwrap(); + assert!(result.contains("baz()")); + assert!(!result.contains("bar()")); + } + + #[test] + fn test_replace_all_with_whitespace_mismatch() { + let content = "if (x == y) { return true; }\nif (x == y) { return false; }\n"; + // replaceAll=true with whitespace-normalized match + let result = replace(content, "if (x == y) {", "if (x != y) {", true).unwrap(); + // Both occurrences should be replaced + assert_eq!(result.matches("if (x != y) {").count(), 2); + assert_eq!(result.matches("if (x == y) {").count(), 0); + } + + // ── replace_all with zero matches ── + + #[test] + fn test_replace_all_no_match_error() { + let content = "hello world\n"; + let result = replace(content, "nonexistent text", "replacement", true); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No match found")); + } + + // ── Line Trimmed replacer tests ── + + #[test] + fn test_line_trimmed_match() { + let content = " hello world \n foo bar \n"; + // Search without leading/trailing spaces per line + let result = replace(content, "hello world\nfoo bar", "changed\nlines", false).unwrap(); + assert!(result.contains("changed")); + } + + // ── Block Anchor replacer tests ── + + #[test] + fn test_block_anchor_match() { + // First and last lines match well, middle lines differ slightly + let content = "fn calculate(x: i32) {\n let result = x * 2 + 1;\n println!(\"result: {}\", result);\n return result;\n}\n"; + // Search with slightly different middle + let result = replace( + content, + "fn calculate(x: i32) {\n let result = x * 2;\n println!(\"result: {}\", result);\n return result;\n}", + "fn calculate(x: i32) {\n x * 3\n}", + false, + ); + // Block anchor should find this via first/last line similarity + assert!(result.is_ok(), "Block anchor should match: {:?}", result); + } + + // ── Escape Normalized replacer tests ── + + #[test] + fn test_escape_normalized_match() { + let content = "let msg = \"hello\\nworld\";\n"; + // Search with literal escape sequence + let result = replace(content, "let msg = \"hello\nworld\";", "let msg = \"changed\";", false); + // The escape normalizer should handle \\n → \n mapping + assert!(result.is_ok() || result.is_err()); // May or may not match depending on content encoding + } + + // ── Trimmed Boundary replacer tests ── + + #[test] + fn test_trimmed_boundary_match() { + let content = "hello world\nfoo bar\n"; + // Search with extra newlines at start/end + let result = replace(content, "\nhello world\n", "changed\n", false).unwrap(); + assert!(result.contains("changed")); + } + + // ── Context Aware replacer tests ── + + #[test] + fn test_context_aware_match() { + let content = "fn main() {\n setup();\n run();\n cleanup();\n}\n"; + // Search with first and last lines matching, middle slightly different + let result = replace( + content, + "fn main() {\n init();\n execute();\n cleanup();\n}", + "fn main() {\n new_code();\n}", + false, + ); + // Context-aware uses Levenshtein on anchor lines + assert!(result.is_ok(), "Context aware should match: {:?}", result); + } + + // ── Multi-occurrence error shows locations ── + + #[test] + fn test_multi_occurrence_shows_locations() { + let content = "foo bar\nhello\nfoo bar\nworld\n"; + let result = replace(content, "foo bar", "baz", false); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Multiple matches")); + assert!(err.contains("Line "), "Error should show line numbers: {err}"); + } + + // ── Replacer ordering tests ── + + #[test] + fn test_exact_takes_precedence_over_fuzzy() { + let content = "fn foo() {\n bar();\n}\n"; + // Exact match should be used first + let result = replace(content, "fn foo() {\n bar();\n}", "fn foo() {\n baz();\n}", false).unwrap(); + assert!(result.contains("baz()")); + } +} diff --git a/crates/agent/src/tool/edit_plan.rs b/crates/agent/src/tool/edit_plan.rs new file mode 100644 index 00000000..6bdb256e --- /dev/null +++ b/crates/agent/src/tool/edit_plan.rs @@ -0,0 +1,279 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; +use super::edit::replace; + +/// Tool that makes targeted edits to a plan file using find-and-replace. +/// +/// Reuses the multi-strategy fuzzy matching from the `edit` tool. +/// The plan file is either specified explicitly or auto-discovered +/// from `.agent/plan-*.md` in the working directory. +pub struct EditPlanTool; + +#[async_trait] +impl Tool for EditPlanTool { + fn name(&self) -> &str { + "edit_plan" + } + + fn description(&self) -> &str { + "Make targeted edits to a plan file using find-and-replace. \ + If file_path is omitted, auto-discovers the plan file from .agent/plan-*.md. \ + Use for small revisions. For complete rewrites, use save_plan instead." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["old_text", "new_text"], + "properties": { + "file_path": { + "type": "string", + "description": "Optional relative path to the plan file (e.g. '.agent/plan-add-auth.md'). If omitted, auto-discovers from .agent/plan-*.md." + }, + "old_text": { + "type": "string", + "description": "The exact text to find in the plan file" + }, + "new_text": { + "type": "string", + "description": "The replacement text" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let old_text = match args.get("old_text").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s, + _ => return Ok(ToolResult::error("'old_text' is required and must be non-empty.")), + }; + + let new_text = match args.get("new_text").and_then(|v| v.as_str()) { + Some(s) => s, + _ => return Ok(ToolResult::error("'new_text' is required.")), + }; + + if old_text == new_text { + return Ok(ToolResult::error("old_text and new_text are identical.")); + } + + // Resolve the plan file path + let plan_path = match args.get("file_path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { + Some(fp) => ctx.working_dir.join(fp), + None => match discover_plan_file(&ctx.working_dir).await { + Ok(path) => path, + Err(msg) => return Ok(ToolResult::error(msg)), + }, + }; + + // Read the plan file + let content = match tokio::fs::read_to_string(&plan_path).await { + Ok(c) => c, + Err(e) => return Ok(ToolResult::error(format!( + "Failed to read plan file {}: {e}", plan_path.display() + ))), + }; + + // Apply replacement using the edit tool's multi-strategy matching + let updated = match replace(&content, old_text, new_text, false) { + Ok(result) => result, + Err(err) => return Ok(ToolResult::error(format!( + "Edit failed in {}: {err}", plan_path.display() + ))), + }; + + // Write back + if let Err(e) = tokio::fs::write(&plan_path, &updated).await { + return Ok(ToolResult::error(format!( + "Failed to write plan file {}: {e}", plan_path.display() + ))); + } + + let relative_path = plan_path.strip_prefix(&ctx.working_dir) + .unwrap_or(&plan_path) + .display() + .to_string(); + + log::info!("[v1.0] edit_plan: edited {}", relative_path); + + Ok(ToolResult { + output: format!("Plan updated: {}\n\n{}", relative_path, updated), + is_error: false, + yield_data: None, + modified_files: vec![plan_path.to_string_lossy().to_string()], + }) + } +} + +/// Discover the plan file from `.agent/plan-*.md` in the working directory. +async fn discover_plan_file(working_dir: &std::path::Path) -> Result { + let agent_dir = working_dir.join(".agent"); + if !agent_dir.exists() { + return Err("No .agent/ directory found. No plan files to edit.".to_string()); + } + + let mut plan_files = Vec::new(); + let mut entries = match tokio::fs::read_dir(&agent_dir).await { + Ok(e) => e, + Err(e) => return Err(format!("Failed to read .agent/ directory: {e}")), + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("plan") && name_str.ends_with(".md") { + plan_files.push(entry.path()); + } + } + + match plan_files.len() { + 0 => Err("No plan files found in .agent/. Use save_plan to create one first.".to_string()), + 1 => Ok(plan_files.into_iter().next().unwrap()), + _ => { + let names: Vec = plan_files.iter() + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .collect(); + Err(format!( + "Multiple plan files found: {}. Specify file_path to choose one.", + names.join(", ") + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(dir: &std::path::Path) -> ToolContext { + ToolContext::test_context(dir) + } + + async fn write_plan(dir: &std::path::Path, filename: &str, content: &str) { + let agent_dir = dir.join(".agent"); + tokio::fs::create_dir_all(&agent_dir).await.unwrap(); + tokio::fs::write(agent_dir.join(filename), content).await.unwrap(); + } + + #[tokio::test] + async fn test_edit_plan_basic() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-test.md", "## Steps\n1. Do foo\n2. Do bar\n").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "Do foo", + "new_text": "Do baz" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(!result.is_error, "Unexpected error: {}", result.output); + assert!(result.output.contains("Do baz")); + + // Verify file on disk + let content = tokio::fs::read_to_string(tmp.path().join(".agent/plan-test.md")).await.unwrap(); + assert!(content.contains("Do baz")); + assert!(!content.contains("Do foo")); + } + + #[tokio::test] + async fn test_edit_plan_with_explicit_path() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-a.md", "Step A").await; + write_plan(tmp.path(), "plan-b.md", "Step B").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "file_path": ".agent/plan-b.md", + "old_text": "Step B", + "new_text": "Step B revised" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Step B revised")); + + // plan-a untouched + let a = tokio::fs::read_to_string(tmp.path().join(".agent/plan-a.md")).await.unwrap(); + assert_eq!(a, "Step A"); + } + + #[tokio::test] + async fn test_edit_plan_auto_discover_single() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-feature.md", "old content").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "old content", + "new_text": "new content" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("new content")); + } + + #[tokio::test] + async fn test_edit_plan_multiple_files_no_path_errors() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-a.md", "A").await; + write_plan(tmp.path(), "plan-b.md", "B").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "A", + "new_text": "A2" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("Multiple plan files found")); + assert!(result.output.contains("plan-a.md")); + assert!(result.output.contains("plan-b.md")); + } + + #[tokio::test] + async fn test_edit_plan_no_files_errors() { + let tmp = tempfile::tempdir().unwrap(); + // No .agent/ directory at all + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "foo", + "new_text": "bar" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("No .agent/ directory")); + } + + #[tokio::test] + async fn test_edit_plan_old_text_not_found() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-test.md", "## Steps\n1. Do foo\n").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "nonexistent text", + "new_text": "replacement" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("Edit failed") || result.output.contains("No match")); + } + + #[tokio::test] + async fn test_edit_plan_identical_text_errors() { + let tmp = tempfile::tempdir().unwrap(); + write_plan(tmp.path(), "plan-test.md", "content").await; + + let tool = EditPlanTool; + let result = tool.execute(json!({ + "old_text": "same", + "new_text": "same" + }), &test_ctx(tmp.path())).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("identical")); + } +} diff --git a/crates/agent/src/tool/git_tool.rs b/crates/agent/src/tool/git_tool.rs new file mode 100644 index 00000000..a9fb8569 --- /dev/null +++ b/crates/agent/src/tool/git_tool.rs @@ -0,0 +1,574 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::types::AgentEvent; +use super::{Tool, ToolContext, ToolResult}; + +/// Whitelist of allowed git subcommands. +const ALLOWED_SUBCOMMANDS: &[&str] = &[ + "status", "diff", "add", "commit", "branch", "checkout", "push", "log", "show", "stash", + "reset", "rev-parse", "remote", "fetch", "merge", "rebase", "tag", "cherry-pick", +]; + +/// Blocked subcommands that could be dangerous. +const BLOCKED_SUBCOMMANDS: &[&str] = &[ + "config", "clean", "gc", "filter-branch", "update-ref", "reflog", +]; + +/// Flags that are blocked across ALL subcommands to prevent argument injection. +/// Even if a subcommand is allowed, these flags change its behavior dangerously. +const BLOCKED_FLAGS: &[&str] = &[ + "--exec", // rebase --exec runs arbitrary shell commands + "--force", // push --force can overwrite remote history + "-f", // short form of --force + "--force-with-lease", // still a force push variant + "--hard", // reset --hard silently deletes uncommitted work + "--amend", // commit --amend rewrites the previous commit + "--no-verify", // skip pre-commit hooks (safety checks) +]; + +pub struct GitTool; + +#[async_trait] +impl Tool for GitTool { + fn name(&self) -> &str { + "git" + } + + fn description(&self) -> &str { + "Execute a git command in the project repository. The command runs with the working \ + directory set to the project root. Use this for version control operations like \ + status, diff, commit, branch, push, etc." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["command", "description"], + "properties": { + "command": { + "type": "string", + "description": "The git subcommand with arguments (e.g. 'status', 'diff --staged', 'commit -m \"msg\"')" + }, + "description": { + "type": "string", + "description": "A short 5-10 word summary of what this git command does" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let command = args + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: command".into()))?; + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Running git command"); + + // Emit status event + let _ = ctx + .event_tx + .send(AgentEvent::ToolStatus { + session_id: ctx.session_id.clone(), + tool_call_id: ctx.tool_call_id.clone(), + status: format!("Git: {description}"), + }) + .await; + + // Parse command into tokens + let tokens = split_shell_words(command); + if tokens.is_empty() { + return Ok(ToolResult::error("Error: empty git command")); + } + + let subcommand = &tokens[0]; + + // Check blocked list first + if BLOCKED_SUBCOMMANDS.contains(&subcommand.as_str()) { + return Ok(ToolResult::error(format!("Error: git subcommand '{subcommand}' is blocked for safety"))); + } + + // Check allowed list + if !ALLOWED_SUBCOMMANDS.contains(&subcommand.as_str()) { + return Ok(ToolResult::error(format!( + "Error: git subcommand '{subcommand}' is not in the allowed list. \ + Allowed: {}", + ALLOWED_SUBCOMMANDS.join(", ") + ))); + } + + // Check for blocked flags across ALL tokens (not just the subcommand). + // Match both exact flags (--force) and =value syntax (--exec=). + for token in &tokens[1..] { + let flag = token.as_str(); + let is_blocked = BLOCKED_FLAGS.iter().any(|blocked| { + flag == *blocked || flag.starts_with(&format!("{blocked}=")) + }); + if is_blocked { + // Show just the flag name portion for clarity + let display_flag = flag.split('=').next().unwrap_or(flag); + return Ok(ToolResult::error(format!( + "Error: flag '{display_flag}' is blocked for safety. \ + Blocked flags: {}", + BLOCKED_FLAGS.join(", ") + ))); + } + } + + // Build args for run_git_raw + let args_refs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect(); + + let output = git_ops::exec::run_git_raw(&ctx.working_dir, &args_refs) + .await + .map_err(|e| ToolError(format!("Git execution error: {e}")))?; + + let combined = match (output.stdout.is_empty(), output.stderr.is_empty()) { + (true, true) => String::new(), + (false, true) => output.stdout.clone(), + (true, false) => output.stderr.clone(), + (false, false) => format!("{}\n{}", output.stdout, output.stderr), + }; + + let output_text = format!("Exit code: {}\n{}", output.exit_code, combined); + + Ok(ToolResult { + output: output_text, + is_error: output.exit_code != 0, + yield_data: None, + modified_files: Vec::new(), + }) + } +} + +/// Minimal shell-word splitter that handles double-quoted strings. +fn split_shell_words(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_double_quote = false; + let mut in_single_quote = false; + let mut chars = input.chars(); + + while let Some(ch) = chars.next() { + match ch { + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '\\' if in_double_quote => { + // Escaped char inside double quotes + if let Some(next) = chars.next() { + current.push(next); + } + } + ' ' | '\t' if !in_double_quote && !in_single_quote => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => { + current.push(ch); + } + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + use tokio::process::Command; + + async fn init_repo_with_commit(dir: &std::path::Path) { + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .await + .unwrap(); + fs::write(dir.join("README.md"), "# Hello").unwrap(); + Command::new("git") + .args(["add", "-A"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(dir) + .output() + .await + .unwrap(); + } + + #[tokio::test] + async fn test_git_status() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "status", "description": "Check repo status"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Exit code: 0")); + } + + #[tokio::test] + async fn test_git_diff() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + fs::write(dir.path().join("README.md"), "# Modified").unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "diff", "description": "Show changes"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Modified")); + } + + #[tokio::test] + async fn test_git_commit() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + fs::write(dir.path().join("new.txt"), "new file").unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + // Stage first + tool.execute( + json!({"command": "add new.txt", "description": "Stage file"}), + &ctx, + ) + .await + .unwrap(); + + let result = tool + .execute( + json!({"command": "commit -m \"add new file\"", "description": "Commit changes"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Exit code: 0")); + } + + #[tokio::test] + async fn test_git_log() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "log --oneline -n 5", "description": "View recent commits"}), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("initial")); + } + + #[tokio::test] + async fn test_blocked_subcommand() { + let dir = tempdir().unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "config user.email", "description": "Get config"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked")); + } + + #[tokio::test] + async fn test_unknown_subcommand() { + let dir = tempdir().unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "bisect start", "description": "Start bisect"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("not in the allowed list")); + } + + #[tokio::test] + async fn test_blocked_flag_force() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "push origin main --force", "description": "Force push"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_exec() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "rebase --exec 'curl evil.com' HEAD~3", "description": "Rebase"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_hard() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "reset --hard HEAD~5", "description": "Hard reset"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_exec_equals_syntax() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "rebase --exec=curl evil.com HEAD~3", "description": "Rebase"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_force_short() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "push origin main -f", "description": "Force push short"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_force_with_lease() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "push --force-with-lease origin main", "description": "Force push"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_no_verify() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "commit --no-verify -m \"skip hooks\"", "description": "Commit"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_blocked_flag_amend() { + let dir = tempdir().unwrap(); + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "commit --amend -m \"evil\"", "description": "Amend commit"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("blocked for safety")); + } + + #[tokio::test] + async fn test_missing_command_param() { + let dir = tempdir().unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool.execute(json!({"description": "oops"}), &ctx).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_empty_command() { + let dir = tempdir().unwrap(); + + let tool = GitTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"command": "", "description": "empty"}), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("empty git command")); + } + + // ── split_shell_words tests ── + + #[test] + fn test_split_simple() { + assert_eq!(split_shell_words("status"), vec!["status"]); + } + + #[test] + fn test_split_with_args() { + assert_eq!( + split_shell_words("log --oneline -n 5"), + vec!["log", "--oneline", "-n", "5"] + ); + } + + #[test] + fn test_split_double_quoted() { + assert_eq!( + split_shell_words(r#"commit -m "hello world""#), + vec!["commit", "-m", "hello world"] + ); + } + + #[test] + fn test_split_single_quoted() { + assert_eq!( + split_shell_words("commit -m 'hello world'"), + vec!["commit", "-m", "hello world"] + ); + } + + #[test] + fn test_split_empty() { + assert!(split_shell_words("").is_empty()); + } + + #[test] + fn test_split_extra_spaces() { + assert_eq!( + split_shell_words(" diff --staged "), + vec!["diff", "--staged"] + ); + } +} diff --git a/crates/agent/src/tool/glob.rs b/crates/agent/src/tool/glob.rs new file mode 100644 index 00000000..57f7921a --- /dev/null +++ b/crates/agent/src/tool/glob.rs @@ -0,0 +1,273 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::util::resolve_path; +use super::{Tool, ToolContext, ToolResult}; + +const MAX_RESULTS: usize = 100; + +pub struct GlobTool; + +#[async_trait] +impl Tool for GlobTool { + fn name(&self) -> &str { + "glob" + } + + fn description(&self) -> &str { + "Find files matching a glob pattern. Respects .gitignore. \ + Returns absolute paths sorted by modification time (most recent first). \ + Example patterns: '**/*.rs', 'src/**/*.ts', '*.json'" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["pattern"], + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match files against (e.g. '**/*.rs')" + }, + "path": { + "type": "string", + "description": "Base directory to search in (defaults to working directory)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let pattern = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: pattern".into()))?; + + let base_path = match args.get("path").and_then(|v| v.as_str()) { + Some(p) => resolve_path(&ctx.working_dir, p), + None => ctx.working_dir.clone(), + }; + + let pattern = pattern.to_string(); + let base = base_path.clone(); + + let result = tokio::task::spawn_blocking(move || { + glob_search(&base, &pattern) + }) + .await + .map_err(|e| ToolError(format!("Glob task failed: {e}")))?; + + match result { + Ok(output) => Ok(ToolResult::success(output)), + Err(e) => Ok(ToolResult::error(e)), + } + } +} + +fn glob_search(base_path: &std::path::Path, pattern: &str) -> Result { + let matcher = globset::GlobBuilder::new(pattern) + .literal_separator(true) // * doesn't match /, only ** does + .build() + .map_err(|e| format!("Invalid glob pattern '{pattern}': {e}"))? + .compile_matcher(); + + let walker = ignore::WalkBuilder::new(base_path) + .standard_filters(true) + .hidden(false) // don't skip hidden files (let .gitignore handle it) + .build(); + + let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); + + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + // Skip directories + if entry.file_type().is_none_or(|ft| ft.is_dir()) { + continue; + } + + let abs_path = match entry.path().canonicalize() { + Ok(p) => p, + Err(_) => entry.path().to_path_buf(), + }; + + // Match against relative path from base + let rel_path = match entry.path().strip_prefix(base_path) { + Ok(r) => r, + Err(_) => entry.path(), + }; + + if matcher.is_match(rel_path) { + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + entries.push((abs_path, mtime)); + } + } + + if entries.is_empty() { + return Ok(format!("No files found matching pattern '{pattern}'")); + } + + let total = entries.len(); + + // Sort by mtime descending (most recent first) + entries.sort_by(|a, b| b.1.cmp(&a.1)); + + // Cap at MAX_RESULTS + let truncated = total > MAX_RESULTS; + entries.truncate(MAX_RESULTS); + + let mut output: String = entries + .iter() + .map(|(p, _)| p.display().to_string()) + .collect::>() + .join("\n"); + + if truncated { + output.push_str(&format!("\n\nShowing {MAX_RESULTS} of {total} results")); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use std::fs; + + #[tokio::test] + async fn test_glob_rs_files() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src/nested")).unwrap(); + fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); + fs::write(dir.path().join("src/nested/lib.rs"), "mod lib;").unwrap(); + fs::write(dir.path().join("readme.md"), "# Readme").unwrap(); + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "**/*.rs" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("main.rs")); + assert!(result.output.contains("lib.rs")); + assert!(!result.output.contains("readme.md")); + } + + #[tokio::test] + async fn test_glob_root_only() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("root.txt"), "root").unwrap(); + fs::create_dir_all(dir.path().join("sub")).unwrap(); + fs::write(dir.path().join("sub/nested.txt"), "nested").unwrap(); + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "*.txt" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("root.txt")); + // *.txt should not match nested files (no **) + assert!(!result.output.contains("nested.txt")); + } + + #[tokio::test] + async fn test_glob_gitignore_respected() { + let dir = tempdir().unwrap(); + // The ignore crate requires a .git dir to honor .gitignore + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".gitignore"), "ignored.txt\n").unwrap(); + fs::write(dir.path().join("kept.txt"), "kept").unwrap(); + fs::write(dir.path().join("ignored.txt"), "ignored").unwrap(); + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "**/*.txt" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("kept.txt")); + assert!(!result.output.contains("ignored.txt")); + } + + #[tokio::test] + async fn test_glob_no_matches() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.txt"), "test").unwrap(); + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "**/*.xyz" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("No files found")); + } + + #[tokio::test] + async fn test_glob_truncation() { + let dir = tempdir().unwrap(); + // Create 110 files + for i in 0..110 { + fs::write(dir.path().join(format!("file_{i:03}.txt")), "content").unwrap(); + } + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "**/*.txt" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Showing 100 of 110 results")); + } + + #[tokio::test] + async fn test_glob_sorted_by_mtime() { + let dir = tempdir().unwrap(); + + // Create files with different modification times + fs::write(dir.path().join("old.txt"), "old").unwrap(); + // Small sleep to ensure different mtime + std::thread::sleep(std::time::Duration::from_millis(50)); + fs::write(dir.path().join("new.txt"), "new").unwrap(); + + let tool = GlobTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "**/*.txt" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + // Most recent first + let lines: Vec<&str> = result.output.lines().collect(); + assert!(lines.len() >= 2); + assert!(lines[0].contains("new.txt"), "new.txt should be first (most recent)"); + assert!(lines[1].contains("old.txt"), "old.txt should be second"); + } +} diff --git a/crates/agent/src/tool/grep.rs b/crates/agent/src/tool/grep.rs new file mode 100644 index 00000000..d57bdbe1 --- /dev/null +++ b/crates/agent/src/tool/grep.rs @@ -0,0 +1,320 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::process::Command; + +use crate::error::ToolError; +use crate::util::{resolve_path, truncate_str}; +use super::{Tool, ToolContext, ToolResult}; + +const MAX_MATCHES: usize = 100; +const MAX_LINE_LENGTH: usize = 2000; + +pub struct GrepTool; + +#[async_trait] +impl Tool for GrepTool { + fn name(&self) -> &str { + "grep" + } + + fn description(&self) -> &str { + "Search file contents using regex patterns. \ + Returns matching lines with file paths and line numbers. \ + Use the 'include' parameter to filter by file type (e.g. '*.rs')." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["pattern"], + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for" + }, + "path": { + "type": "string", + "description": "File or directory to search in (defaults to working directory)" + }, + "include": { + "type": "string", + "description": "Glob pattern to filter files (e.g. '*.rs', '*.{ts,tsx}')" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let pattern = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: pattern".into()))?; + + let search_path = match args.get("path").and_then(|v| v.as_str()) { + Some(p) => resolve_path(&ctx.working_dir, p), + None => ctx.working_dir.clone(), + }; + + let include = args.get("include").and_then(|v| v.as_str()); + + // Try rg first, fall back to grep + let output = if rg_available() { + run_rg(pattern, &search_path, include, &ctx.working_dir).await + } else { + run_grep(pattern, &search_path, include, &ctx.working_dir).await + }; + + match output { + Ok(output) => { + let exit_code = output.status.code().unwrap_or(-1); + + match exit_code { + 0 => { + let stdout = String::from_utf8_lossy(&output.stdout); + let formatted = format_matches(&stdout); + Ok(ToolResult::success(formatted)) + } + 1 => { + // No matches (not an error for both rg and grep) + Ok(ToolResult::success(format!("No matches found for pattern '{pattern}'"))) + } + _ => { + let stderr = String::from_utf8_lossy(&output.stderr); + Ok(ToolResult::error(format!("Grep error (exit code {exit_code}): {stderr}"))) + } + } + } + Err(e) => { + Ok(ToolResult::error(format!("Failed to execute grep: {e}"))) + } + } + } +} + +fn rg_available() -> bool { + let mut cmd = std::process::Command::new("rg"); + cmd.arg("--version"); + git_ops::no_window::no_window_std(&mut cmd); + cmd.output().map(|o| o.status.success()).unwrap_or(false) +} + +async fn run_rg( + pattern: &str, + search_path: &std::path::Path, + include: Option<&str>, + working_dir: &std::path::Path, +) -> std::io::Result { + let mut cmd = Command::new("rg"); + cmd.arg("-n") + .arg("-H") + .arg("--color").arg("never") + .arg("--no-heading"); + + if let Some(glob) = include { + cmd.arg("--glob").arg(glob); + } + + cmd.arg(pattern) + .arg(search_path) + .current_dir(working_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + git_ops::no_window::no_window_tokio(&mut cmd); + + cmd.output().await +} + +async fn run_grep( + pattern: &str, + search_path: &std::path::Path, + include: Option<&str>, + working_dir: &std::path::Path, +) -> std::io::Result { + let mut cmd = Command::new("grep"); + cmd.arg("-r") // recursive + .arg("-n") // line numbers + .arg("-H") // always show filename + .arg("-E"); // extended regex (closer to rg default) + + if let Some(glob) = include { + cmd.arg("--include").arg(glob); + } + + cmd.arg(pattern) + .arg(search_path) + .current_dir(working_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + git_ops::no_window::no_window_tokio(&mut cmd); + + cmd.output().await +} + +fn format_matches(stdout: &str) -> String { + let lines: Vec<&str> = stdout.lines().collect(); + let total = lines.len(); + + let mut output = String::new(); + + for (count, line) in lines.iter().enumerate() { + if count >= MAX_MATCHES { + break; + } + + // Truncate long content lines (use truncate_str for UTF-8 safety) + if line.len() > MAX_LINE_LENGTH { + output.push_str(truncate_str(line, MAX_LINE_LENGTH)); + output.push_str("..."); + } else { + output.push_str(line); + } + output.push('\n'); + } + + // Remove trailing newline + if output.ends_with('\n') { + output.pop(); + } + + if total > MAX_MATCHES { + output.push_str(&format!("\n\nShowing {MAX_MATCHES} of {total} matches")); + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use std::fs; + + #[tokio::test] + async fn test_grep_simple_regex() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.rs"), "fn main() {\n println!(\"hello\");\n}\n").unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "fn main" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("fn main")); + assert!(result.output.contains("test.rs")); + } + + #[tokio::test] + async fn test_grep_include_filter() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("code.rs"), "fn hello() {}\n").unwrap(); + fs::write(dir.path().join("code.py"), "def hello():\n pass\n").unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "hello", "include": "*.rs" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("code.rs")); + assert!(!result.output.contains("code.py")); + } + + #[tokio::test] + async fn test_grep_no_matches() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.txt"), "hello world\n").unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "nonexistent_pattern_xyz" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("No matches found")); + } + + #[tokio::test] + async fn test_grep_long_line_truncation() { + let dir = tempdir().unwrap(); + let long_line = format!("match_here {}", "x".repeat(3000)); + fs::write(dir.path().join("long.txt"), &long_line).unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "match_here" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + for line in result.output.lines() { + assert!(line.len() <= MAX_LINE_LENGTH + 10, "Line too long: {} chars", line.len()); + } + } + + #[tokio::test] + async fn test_grep_many_matches_truncated() { + let dir = tempdir().unwrap(); + let content: String = (0..150).map(|i| format!("match_line_{i}\n")).collect(); + fs::write(dir.path().join("many.txt"), &content).unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "match_line" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Showing 100 of 150 matches")); + } + + #[tokio::test] + async fn test_grep_error_bad_regex() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.txt"), "hello\n").unwrap(); + + let tool = GrepTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute(json!({ "pattern": "[unclosed" }), &ctx) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("Grep error") || result.output.contains("Invalid") + || result.output.contains("Unmatched")); + } + + #[test] + fn test_format_matches_basic() { + let stdout = "file.rs:1:fn main()\nfile.rs:5:fn test()\n"; + let result = format_matches(stdout); + assert!(result.contains("file.rs:1:fn main()")); + assert!(result.contains("file.rs:5:fn test()")); + } + + #[test] + fn test_format_matches_utf8_truncation_no_panic() { + let prefix = "x".repeat(MAX_LINE_LENGTH - 2); + let line = format!("file.txt:1:{prefix}🌍yyyy"); + let result = format_matches(&line); + assert!(result.contains("...")); + assert!(result.len() <= MAX_LINE_LENGTH + 20); + } +} diff --git a/crates/agent/src/tool/mod.rs b/crates/agent/src/tool/mod.rs new file mode 100644 index 00000000..ebb1786e --- /dev/null +++ b/crates/agent/src/tool/mod.rs @@ -0,0 +1,510 @@ +pub mod schema; +pub mod read; +pub mod write; +pub mod bash; +pub mod edit; +pub mod glob; +pub mod grep; +pub mod git_tool; +pub mod pr_tool; +pub mod start_session; +pub mod ask_user; +pub mod todo_write; +pub mod apply_patch; +pub mod save_plan; +pub mod edit_plan; +pub mod codebase_search; +pub mod codebase_graph; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::context_engine::ContextEngineApi; +use crate::skills::{SkillRegistry, SkillTool}; +use crate::subagents::{SpawnSubagentTool, SubagentInheritance, SubagentRegistry}; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::error::ToolError; +use crate::llm::types::ToolDefinition; +use crate::types::AgentEvent; + +/// Result of a tool execution. +#[derive(Debug)] +pub struct ToolResult { + pub output: String, + pub is_error: bool, + /// Optional yield data — when set, the agent loop will yield control + /// (e.g., start_session sets this to signal a coding session should begin). + pub yield_data: Option, + /// Files modified by this tool execution (for write/edit/apply_patch tools). + pub modified_files: Vec, +} + +impl ToolResult { + /// Create a successful result with no yield. + pub fn success(output: impl Into) -> Self { + Self { + output: output.into(), + is_error: false, + yield_data: None, + modified_files: Vec::new(), + } + } + + /// Create an error result with no yield. + pub fn error(output: impl Into) -> Self { + Self { + output: output.into(), + is_error: true, + yield_data: None, + modified_files: Vec::new(), + } + } +} + +/// Context passed to every tool execution. +pub struct ToolContext { + pub working_dir: PathBuf, + pub cancel_token: CancellationToken, + pub event_tx: mpsc::Sender, + pub session_id: String, + pub tool_call_id: String, +} + +#[cfg(test)] +impl ToolContext { + pub(crate) fn test_context(dir: &std::path::Path) -> Self { + let (tx, _rx) = mpsc::channel(32); + Self { + working_dir: dir.to_path_buf(), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "test".into(), + tool_call_id: "tc_1".into(), + } + } +} + +/// Trait that all tools must implement. +#[async_trait] +pub trait Tool: Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn parameters_schema(&self) -> Value; + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result; +} + +/// Mode that determines which tools are available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum ToolMode { + /// Ask mode: read-only tools + start_session + ask_user. + Ask, + /// Coding mode: full tool suite for making changes. + Coding, + /// Plan mode: read-only tools + ask_user + start_session + todo_write. + Plan, +} + +/// Registry of available tools. +pub struct ToolRegistry { + tools: HashMap>, +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + } + } + + /// Create a registry pre-loaded with all default tools (coding mode, no context engine). + pub fn with_defaults() -> Self { + Self::for_mode(ToolMode::Coding, None, None) + } + + /// Create a registry for a specific mode. + /// When `context_engine` is provided, `codebase_search` and `codebase_graph` tools + /// are registered in all modes. + /// When `skills` is provided, the `skill` tool is registered in all modes. + pub fn for_mode( + mode: ToolMode, + context_engine: Option<(Arc, PathBuf)>, + skills: Option>, + ) -> Self { + let mut registry = Self::new(); + match mode { + ToolMode::Ask => { + registry.register(Arc::new(read::ReadTool)); + registry.register(Arc::new(glob::GlobTool)); + registry.register(Arc::new(grep::GrepTool)); + registry.register(Arc::new(start_session::StartSessionTool)); + registry.register(Arc::new(ask_user::AskUserTool)); + } + ToolMode::Coding => { + registry.register(Arc::new(read::ReadTool)); + registry.register(Arc::new(write::WriteTool)); + registry.register(Arc::new(bash::BashTool)); + registry.register(Arc::new(edit::EditTool)); + registry.register(Arc::new(glob::GlobTool)); + registry.register(Arc::new(grep::GrepTool)); + registry.register(Arc::new(git_tool::GitTool)); + registry.register(Arc::new(pr_tool::PrTool)); + registry.register(Arc::new(todo_write::TodoWriteTool)); + registry.register(Arc::new(apply_patch::ApplyPatchTool)); + } + ToolMode::Plan => { + registry.register(Arc::new(read::ReadTool)); + registry.register(Arc::new(glob::GlobTool)); + registry.register(Arc::new(grep::GrepTool)); + registry.register(Arc::new(ask_user::AskUserTool)); + registry.register(Arc::new(save_plan::SavePlanTool)); + registry.register(Arc::new(edit_plan::EditPlanTool)); + } + } + // Register context engine tools in ALL modes if available + if let Some((engine, repo_path)) = context_engine { + registry.register(Arc::new( + codebase_search::CodebaseSearchTool::new(engine.clone(), repo_path), + )); + registry.register(Arc::new( + codebase_graph::CodebaseGraphTool::new(engine), + )); + } + // Register skill tool in ALL modes if a registry is provided + if let Some(skills_registry) = skills { + log::info!( + "[skills] ToolRegistry::for_mode: registering `skill` tool in {:?} mode with {} skill(s) available", + mode, + skills_registry.len() + ); + registry.register(Arc::new(SkillTool::new(skills_registry))); + } + registry + } + + pub fn register(&mut self, tool: Arc) { + self.tools.insert(tool.name().to_string(), tool); + } + + /// Register `spawn_subagent` with the given registry + inheritance bundle. + /// Called post-`for_mode` by the Tauri layer (parent-turn construction) — + /// child loops never call this, which enforces depth-1. + pub fn register_spawn_subagent( + &mut self, + registry: Arc, + inherit: Arc, + ) { + if registry.is_empty() { + log::info!("[subagents] spawn_subagent NOT registered (empty registry)"); + return; + } + log::info!( + "[subagents] registering spawn_subagent: {} subagent(s) available, names={:?}, persister_factory={}, approval_factory={}, parent_thread_id={:?}", + registry.len(), + registry.names(), + inherit.persister_factory.is_some(), + inherit.approval_handler_factory.is_some(), + inherit.parent_thread_id, + ); + self.register(Arc::new(SpawnSubagentTool::new(registry, inherit))); + } + + pub fn get(&self, name: &str) -> Option> { + self.tools.get(name).cloned() + } + + /// Keep only tools whose name matches the predicate. Used by subagents + /// to apply `allowed-tools` filters post-construction. + pub fn retain bool>(&mut self, pred: F) { + self.tools.retain(|name, _| pred(name)); + } + + /// List registered tool names. + pub fn names(&self) -> Vec { + let mut out: Vec = self.tools.keys().cloned().collect(); + out.sort(); + out + } + + /// Return OpenAI-format tool definitions for all registered tools. + pub fn tool_definitions(&self) -> Vec { + let mut defs: Vec<_> = self + .tools + .values() + .map(|tool| ToolDefinition { + type_: "function".to_string(), + function: crate::llm::types::FunctionDefinition { + name: tool.name().to_string(), + description: Some(tool.description().to_string()), + parameters: Some(tool.parameters_schema()), + }, + cache_control: None, + }) + .collect(); + // Sort for deterministic ordering + defs.sort_by(|a, b| a.function.name.cmp(&b.function.name)); + defs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Probe: measure serialized size of system prompt + tool definitions per mode. + // Run with: cargo test -p agent probe_cache_sizes -- --nocapture --ignored + #[test] + #[ignore] + fn probe_cache_sizes() { + use crate::agent::prompt::build_system_prompt; + use std::path::PathBuf; + let wd = PathBuf::from("."); + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let sys_blocks = build_system_prompt(mode, &wd, Some("main"), None, None, None); + let sys: String = sys_blocks.iter().map(|b| b.text.as_str()).collect::>().join("\n"); + let reg = ToolRegistry::for_mode(mode, None, None); + let tools = reg.tool_definitions(); + let tools_json = serde_json::to_string(&tools).unwrap(); + let sys_chars = sys.chars().count(); + let tools_chars = tools_json.chars().count(); + let total_chars = sys_chars + tools_chars; + eprintln!( + "mode={:?} sys={}c (~{}tok) tools={}c (~{}tok, n={}) TOTAL={}c (~{}tok)", + mode, + sys_chars, sys_chars / 4, + tools_chars, tools_chars / 4, tools.len(), + total_chars, total_chars / 4, + ); + } + } + + // Probe: confirm tool_definitions() is byte-stable across calls (required for caching). + #[test] + #[ignore] + fn probe_tool_definitions_byte_stable() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let reg = ToolRegistry::for_mode(mode, None, None); + let a = serde_json::to_string(®.tool_definitions()).unwrap(); + let b = serde_json::to_string(®.tool_definitions()).unwrap(); + eprintln!("mode={:?} bytes_equal={} len={}", mode, a == b, a.len()); + assert_eq!(a, b, "tool definitions not byte-stable in {:?} mode", mode); + } + } + + #[test] + fn test_with_defaults_registers_all_tools() { + let reg = ToolRegistry::with_defaults(); + let names: Vec = reg + .tool_definitions() + .iter() + .map(|d| d.function.name.clone()) + .collect(); + // Sorted alphabetically by tool_definitions() + assert_eq!(names, vec!["apply_patch", "bash", "create_pr", "edit", "git", "glob", "grep", "read", "todo_write", "write"]); + } + + #[test] + fn test_tool_definitions_sorted() { + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(write::WriteTool)); + reg.register(Arc::new(read::ReadTool)); + reg.register(Arc::new(bash::BashTool)); + let defs = reg.tool_definitions(); + let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect(); + assert_eq!(names, vec!["bash", "read", "write"]); + } + + #[test] + fn test_get_missing_tool_returns_none() { + let reg = ToolRegistry::new(); + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn test_ask_mode_tools() { + let reg = ToolRegistry::for_mode(ToolMode::Ask, None, None); + let mut names: Vec = reg + .tool_definitions() + .iter() + .map(|d| d.function.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, vec!["ask_user", "glob", "grep", "read", "start_session"]); + } + + #[test] + fn test_coding_mode_tools() { + let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); + let mut names: Vec = reg + .tool_definitions() + .iter() + .map(|d| d.function.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, vec!["apply_patch", "bash", "create_pr", "edit", "git", "glob", "grep", "read", "todo_write", "write"]); + } + + #[test] + fn test_plan_mode_tools() { + let reg = ToolRegistry::for_mode(ToolMode::Plan, None, None); + let mut names: Vec = reg + .tool_definitions() + .iter() + .map(|d| d.function.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, vec!["ask_user", "edit_plan", "glob", "grep", "read", "save_plan"]); + } + + #[test] + fn test_start_session_not_in_coding() { + let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); + assert!(reg.get("start_session").is_none()); + } + + #[test] + fn test_ask_user_not_in_coding() { + let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); + assert!(reg.get("ask_user").is_none()); + } + + #[test] + fn test_ask_user_in_ask_mode() { + let reg = ToolRegistry::for_mode(ToolMode::Ask, None, None); + assert!(reg.get("ask_user").is_some()); + } + + #[test] + fn test_ask_user_in_plan_mode() { + let reg = ToolRegistry::for_mode(ToolMode::Plan, None, None); + assert!(reg.get("ask_user").is_some()); + } + + #[test] + fn test_destructive_tools_not_in_ask() { + let reg = ToolRegistry::for_mode(ToolMode::Ask, None, None); + assert!(reg.get("write").is_none()); + assert!(reg.get("edit").is_none()); + assert!(reg.get("bash").is_none()); + assert!(reg.get("git").is_none()); + assert!(reg.get("create_pr").is_none()); + } + + #[test] + fn test_destructive_tools_not_in_plan() { + let reg = ToolRegistry::for_mode(ToolMode::Plan, None, None); + assert!(reg.get("write").is_none()); + assert!(reg.get("edit").is_none()); + assert!(reg.get("bash").is_none()); + assert!(reg.get("git").is_none()); + assert!(reg.get("create_pr").is_none()); + } + + // --- Context engine tool registration tests --- + + fn mock_context_engine_arg() -> Option<(Arc, PathBuf)> { + use crate::context_engine::MockContextEngine; + Some(( + Arc::new(MockContextEngine::indexed_empty()), + PathBuf::from("/repo"), + )) + } + + #[test] + fn test_ask_mode_with_context_engine() { + let reg = ToolRegistry::for_mode(ToolMode::Ask, mock_context_engine_arg(), None); + assert!(reg.get("codebase_search").is_some()); + assert!(reg.get("codebase_graph").is_some()); + // Original ask tools still present + assert!(reg.get("read").is_some()); + assert!(reg.get("grep").is_some()); + assert!(reg.get("start_session").is_some()); + } + + #[test] + fn test_coding_mode_with_context_engine() { + let reg = ToolRegistry::for_mode(ToolMode::Coding, mock_context_engine_arg(), None); + assert!(reg.get("codebase_search").is_some()); + assert!(reg.get("codebase_graph").is_some()); + // Original coding tools still present + assert!(reg.get("read").is_some()); + assert!(reg.get("write").is_some()); + assert!(reg.get("bash").is_some()); + } + + #[test] + fn test_plan_mode_with_context_engine() { + let reg = ToolRegistry::for_mode(ToolMode::Plan, mock_context_engine_arg(), None); + assert!(reg.get("codebase_search").is_some()); + assert!(reg.get("codebase_graph").is_some()); + // Original plan tools still present + assert!(reg.get("read").is_some()); + assert!(reg.get("save_plan").is_some()); + } + + #[test] + fn test_ask_mode_without_context_engine() { + let reg = ToolRegistry::for_mode(ToolMode::Ask, None, None); + assert!(reg.get("codebase_search").is_none()); + assert!(reg.get("codebase_graph").is_none()); + } + + #[test] + fn test_coding_mode_without_context_engine() { + let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); + assert!(reg.get("codebase_search").is_none()); + assert!(reg.get("codebase_graph").is_none()); + } + + // --- Skill tool registration tests --- + + fn mock_skill_registry() -> Arc { + use crate::skills::registry::SkillInput; + use std::collections::HashSet; + let input = SkillInput { + raw: "---\nname: hello\ndescription: A greeting skill.\n---\nbody\n".to_string(), + path: PathBuf::from("/hello"), + }; + Arc::new(crate::skills::SkillRegistry::new( + vec![], + vec![input], + vec![], + &HashSet::new(), + )) + } + + #[test] + fn test_skill_tool_registered_in_all_modes_when_provided() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let reg = ToolRegistry::for_mode(mode, None, Some(mock_skill_registry())); + assert!( + reg.get("skill").is_some(), + "skill tool missing in {:?} mode", + mode + ); + } + } + + #[test] + fn test_skill_tool_absent_when_no_registry() { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let reg = ToolRegistry::for_mode(mode, None, None); + assert!( + reg.get("skill").is_none(), + "skill tool should be absent in {:?} mode without registry", + mode + ); + } + } +} diff --git a/crates/agent/src/tool/pr_tool.rs b/crates/agent/src/tool/pr_tool.rs new file mode 100644 index 00000000..833237de --- /dev/null +++ b/crates/agent/src/tool/pr_tool.rs @@ -0,0 +1,430 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::types::AgentEvent; +use super::{Tool, ToolContext, ToolResult}; + +pub struct PrTool; + +#[async_trait] +impl Tool for PrTool { + fn name(&self) -> &str { + "create_pr" + } + + fn description(&self) -> &str { + "Create a GitHub pull request. Requires the repository to have a GitHub remote \ + and a valid GitHub auth token (GITHUB_TOKEN env var or gh CLI)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["title", "body", "branch"], + "properties": { + "title": { + "type": "string", + "description": "The title of the pull request" + }, + "body": { + "type": "string", + "description": "The body/description of the pull request (markdown supported)" + }, + "branch": { + "type": "string", + "description": "The head branch to create the PR from" + }, + "base": { + "type": "string", + "description": "The base branch to merge into (default: main)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: title".into()))?; + + let body = args + .get("body") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: body".into()))?; + + let branch = args + .get("branch") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: branch".into()))?; + + let base = args + .get("base") + .and_then(|v| v.as_str()) + .unwrap_or("main"); + + // Emit status event + let _ = ctx + .event_tx + .send(AgentEvent::ToolStatus { + session_id: ctx.session_id.clone(), + tool_call_id: ctx.tool_call_id.clone(), + status: format!("Creating PR: {title}"), + }) + .await; + + match git_ops::pr::create(&ctx.working_dir, title, body, branch, base).await { + Ok(pr) => Ok(ToolResult::success(format!("Pull request created: {}\nPR #{}", pr.url, pr.number))), + Err(git_ops::GitOpsError::NoAuthToken) => Ok(ToolResult::error( + "Error: No GitHub auth token found. Set GITHUB_TOKEN environment \ + variable or authenticate with `gh auth login`.", + )), + Err(git_ops::GitOpsError::GitHubApi { status, body }) => { + // Log the full body for debugging but don't expose it to the LLM — + // GitHub error responses can contain token scopes or OAuth details. + log::warn!("GitHub API error (HTTP {status}): {body}"); + Ok(ToolResult::error(format!( + "Error: GitHub API returned HTTP {status}. \ + Check that the branch is pushed and the token has repo scope." + ))) + } + Err(git_ops::GitOpsError::InvalidRemoteUrl(url)) => Ok(ToolResult::error( + format!("Error: Could not parse GitHub remote URL: {url}"), + )), + Err(git_ops::GitOpsError::GitCommand { stderr, .. }) + if stderr.contains("not found on remote") => + { + Ok(ToolResult::error(format!("Error: {stderr}"))) + } + Err(e) => { + // Log full error internally; only return a safe summary to the LLM + // to avoid leaking connection details or headers. + log::warn!("PR creation error: {e}"); + Ok(ToolResult::error("Error creating PR. Check git remote, network, and auth configuration.")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use tempfile::tempdir; + + /// Mutex to serialize tests that mutate the GITHUB_TOKEN env var. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[tokio::test] + async fn test_missing_title() { + let dir = tempdir().unwrap(); + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"body": "desc", "branch": "feat"}), + &ctx, + ) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_missing_body() { + let dir = tempdir().unwrap(); + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"title": "PR", "branch": "feat"}), + &ctx, + ) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_missing_branch() { + let dir = tempdir().unwrap(); + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({"title": "PR", "body": "desc"}), + &ctx, + ) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_no_auth_token_graceful() { + // Hold mutex to prevent other tests from seeing our env var mutation + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + + let dir = tempdir().unwrap(); + + // Init a git repo with a fake remote + tokio::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args([ + "remote", + "add", + "origin", + "https://github.com/test/repo.git", + ]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + // Ensure GITHUB_TOKEN is not set for this test + let original = std::env::var("GITHUB_TOKEN").ok(); + std::env::remove_var("GITHUB_TOKEN"); + + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ + "title": "Test PR", + "body": "Test body", + "branch": "feature" + }), + &ctx, + ) + .await + .unwrap(); + + // Should return an error about auth, not panic + assert!(result.is_error); + assert!( + result.output.contains("auth") || result.output.contains("Error"), + "Expected auth error, got: {}", + result.output + ); + + // Restore + if let Some(val) = original { + std::env::set_var("GITHUB_TOKEN", val); + } + } + + #[tokio::test] + async fn test_invalid_remote_url_error() { + let dir = tempdir().unwrap(); + + // Init a git repo with a non-GitHub remote + tokio::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args([ + "remote", + "add", + "origin", + "https://gitlab.com/user/repo.git", + ]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ + "title": "Test PR", + "body": "Test body", + "branch": "feature" + }), + &ctx, + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output.contains("remote URL"), + "Expected remote URL error, got: {}", + result.output + ); + } + + #[tokio::test] + async fn test_branch_not_pushed_error() { + // Test at the git-ops layer directly — the PR tool wraps this + // We can't test through PrTool::execute with a fake remote because + // ls-remote would try to connect to the network. + // Instead, verify the branch_exists_on_remote function works correctly + // with a local "remote" (a bare repo). + let dir = tempdir().unwrap(); + let bare_dir = dir.path().join("bare.git"); + let work_dir = dir.path().join("work"); + + // Create a bare repo as "remote" + tokio::process::Command::new("git") + .args(["init", "--bare"]) + .arg(&bare_dir) + .output() + .await + .unwrap(); + + // Create a working repo pointing to the bare repo + tokio::process::Command::new("git") + .args(["clone"]) + .arg(&bare_dir) + .arg(&work_dir) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + + // Create an initial commit and push + std::fs::write(work_dir.join("README.md"), "# Test").unwrap(); + tokio::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + + // Detect the default branch name (master or main) + let branch_output = tokio::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + let default_branch = String::from_utf8_lossy(&branch_output.stdout).trim().to_string(); + + tokio::process::Command::new("git") + .args(["push", "-u", "origin", &default_branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + + // branch_exists_on_remote should return false for a non-existent branch + let exists = + git_ops::pr::branch_exists_on_remote(&work_dir, "nonexistent-branch", "origin") + .await + .unwrap(); + assert!(!exists, "Branch should not exist on remote"); + + // And true for the pushed branch + let exists = + git_ops::pr::branch_exists_on_remote(&work_dir, &default_branch, "origin") + .await + .unwrap(); + assert!(exists, "{default_branch} should exist on remote"); + } + + #[tokio::test] + async fn test_no_remote_error() { + let dir = tempdir().unwrap(); + + // Init a git repo with NO remote at all + tokio::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + tokio::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + let tool = PrTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ + "title": "Test PR", + "body": "Test body", + "branch": "feature" + }), + &ctx, + ) + .await + .unwrap(); + + // Should gracefully error, not panic + assert!(result.is_error); + } + + #[tokio::test] + #[ignore] // Requires real GitHub token and repo + async fn test_create_pr_real() { + // This test would need a real repo with a pushed branch + } +} diff --git a/crates/agent/src/tool/read.rs b/crates/agent/src/tool/read.rs new file mode 100644 index 00000000..65fa1ad6 --- /dev/null +++ b/crates/agent/src/tool/read.rs @@ -0,0 +1,363 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::util::{resolve_path, truncate_str}; +use super::{Tool, ToolContext, ToolResult}; + +const DEFAULT_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; +const MAX_BYTES: usize = 50 * 1024; + +pub struct ReadTool; + +#[async_trait] +impl Tool for ReadTool { + fn name(&self) -> &str { + "read" + } + + fn description(&self) -> &str { + "Read a file's contents or list a directory. Returns line-numbered output for files. \ + Supports offset and limit parameters for reading specific sections of large files." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { + "type": "string", + "description": "Absolute or relative path to the file or directory to read" + }, + "offset": { + "type": "integer", + "description": "1-indexed line number to start reading from" + }, + "limit": { + "type": "integer", + "description": "Maximum number of lines to read (default 2000)" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let file_path = args + .get("filePath") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: filePath".into()))?; + + let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(DEFAULT_LIMIT as u64) as usize; + + let path = resolve_path(&ctx.working_dir, file_path); + + if !path.exists() { + return Ok(ToolResult::error(format!("Error: Path does not exist: {}", path.display()))); + } + + if path.is_dir() { + return read_directory(&path, offset, limit).await; + } + + read_file(&path, offset, limit).await + } +} + +async fn read_directory(path: &std::path::Path, offset: usize, limit: usize) -> Result { + let mut entries: Vec = Vec::new(); + + let mut read_dir = tokio::fs::read_dir(path) + .await + .map_err(|e| ToolError(format!("Failed to read directory: {e}")))?; + + while let Some(entry) = read_dir.next_entry().await.map_err(|e| ToolError(format!("Error reading entry: {e}")))? { + let name = entry.file_name().to_string_lossy().to_string(); + let metadata = entry.metadata().await.ok(); + let suffix = if metadata.as_ref().is_some_and(|m| m.is_dir()) { + "/" + } else { + "" + }; + entries.push(format!("{name}{suffix}")); + } + + entries.sort(); + + // Apply offset and limit + let start = if offset > 0 { offset - 1 } else { 0 }; + let listing: String = entries + .iter() + .skip(start) + .take(limit) + .map(|e| e.as_str()) + .collect::>() + .join("\n"); + + let output = format!("Directory: {}\n\n{}", path.display(), listing); + + Ok(ToolResult::success(output)) +} + +async fn read_file(path: &std::path::Path, offset: usize, limit: usize) -> Result { + // Read raw bytes for binary detection + let raw_bytes = tokio::fs::read(path) + .await + .map_err(|e| ToolError(format!("Failed to read file: {e}")))?; + + // Binary detection: check for null bytes in first 4KB + let check_len = raw_bytes.len().min(4096); + if raw_bytes[..check_len].contains(&0) { + return Ok(ToolResult::error(format!("Error: {} appears to be a binary file", path.display()))); + } + + let content = String::from_utf8_lossy(&raw_bytes); + let lines: Vec<&str> = content.lines().collect(); + + // Apply offset (1-indexed) and limit + let start = if offset > 0 { (offset - 1).min(lines.len()) } else { 0 }; + let end = (start + limit).min(lines.len()); + let selected_lines = &lines[start..end]; + + // Build line-numbered output, respecting MAX_BYTES + let mut output = String::new(); + let mut total_bytes = 0; + + for (i, line) in selected_lines.iter().enumerate() { + let line_num = start + i + 1; + let truncated_line = if line.len() > MAX_LINE_LENGTH { + truncate_str(line, MAX_LINE_LENGTH) + } else { + line + }; + let formatted = format!("{line_num}\t{truncated_line}\n"); + + total_bytes += formatted.len(); + if total_bytes > MAX_BYTES { + output.push_str(&format!( + "\n... truncated (file too large, showing {i} of {} selected lines)", + selected_lines.len() + )); + break; + } + + output.push_str(&formatted); + } + + // Add info about total lines if we're showing a subset + if start > 0 || end < lines.len() { + output.push_str(&format!( + "\n(showing lines {}-{} of {} total)", + start + 1, + end, + lines.len() + )); + } + + Ok(ToolResult::success(output)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_read_text_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("1\tline one")); + assert!(result.output.contains("2\tline two")); + assert!(result.output.contains("3\tline three")); + } + + #[tokio::test] + async fn test_read_with_offset_and_limit() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + let content: String = (1..=100).map(|i| format!("line {i}\n")).collect(); + std::fs::write(&file_path, &content).unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute( + json!({ "filePath": file_path.to_str().unwrap(), "offset": 10, "limit": 5 }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("10\tline 10")); + assert!(result.output.contains("14\tline 14")); + assert!(!result.output.contains("15\tline 15")); + } + + #[tokio::test] + async fn test_read_directory() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("alpha.txt"), "").unwrap(); + std::fs::write(dir.path().join("beta.txt"), "").unwrap(); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": dir.path().to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("alpha.txt")); + assert!(result.output.contains("beta.txt")); + assert!(result.output.contains("subdir/")); + } + + #[tokio::test] + async fn test_read_binary_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("binary.bin"); + std::fs::write(&file_path, b"\x00\x01\x02\x03binary data").unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("binary file")); + } + + #[tokio::test] + async fn test_read_nonexistent_file() { + let dir = tempdir().unwrap(); + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": "/nonexistent/file.txt" }), &ctx) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("does not exist")); + } + + #[tokio::test] + async fn test_read_line_truncation() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("long.txt"); + let long_line = "x".repeat(3000); + std::fs::write(&file_path, &long_line).unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + // The line should be truncated at MAX_LINE_LENGTH + // Line format: "1\t" + content, so content portion should be <= 2000 + let line = result.output.lines().next().unwrap(); + let content_part = line.splitn(2, '\t').nth(1).unwrap(); + assert_eq!(content_part.len(), MAX_LINE_LENGTH); + } + + #[tokio::test] + async fn test_read_relative_path() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("relative.txt"); + std::fs::write(&file_path, "content here").unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": "relative.txt" }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("content here")); + } + + #[tokio::test] + async fn test_read_max_bytes_truncation() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("large.txt"); + // Create a file larger than MAX_BYTES (50KB) + // Each line is ~80 chars, so ~700 lines will exceed 50KB + let content: String = (0..800) + .map(|i| format!("this is line {:04} with enough text to make it around eighty characters long xxxx\n", i)) + .collect(); + assert!(content.len() > MAX_BYTES); + std::fs::write(&file_path, &content).unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("truncated")); + // Output should be roughly bounded by MAX_BYTES + assert!(result.output.len() <= MAX_BYTES + 200); + } + + #[tokio::test] + async fn test_read_empty_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("empty.txt"); + std::fs::write(&file_path, "").unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + assert!(!result.is_error); + } + + #[tokio::test] + async fn test_read_unicode_line_truncation() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("unicode.txt"); + // Create a line of emoji that exceeds MAX_LINE_LENGTH in bytes + // '🌍' is 4 bytes, so 600 emojis = 2400 bytes > 2000 + let emoji_line: String = "🌍".repeat(600); + std::fs::write(&file_path, &emoji_line).unwrap(); + + let tool = ReadTool; + let ctx = ToolContext::test_context(dir.path()); + let result = tool + .execute(json!({ "filePath": file_path.to_str().unwrap() }), &ctx) + .await + .unwrap(); + + // Should not panic (the whole point of the truncate_str fix) + assert!(!result.is_error); + // Line should be truncated — fewer than 600 emoji in the output + let output_emoji_count = result.output.matches('🌍').count(); + assert!(output_emoji_count < 600); + assert!(output_emoji_count == MAX_LINE_LENGTH / 4); // 500 emoji at 4 bytes each + } +} diff --git a/crates/agent/src/tool/save_plan.rs b/crates/agent/src/tool/save_plan.rs new file mode 100644 index 00000000..57589773 --- /dev/null +++ b/crates/agent/src/tool/save_plan.rs @@ -0,0 +1,144 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; + +/// Tool that saves the implementation plan to disk and yields to the frontend. +/// +/// The agent calls this when the plan is complete. It: +/// 1. Writes the plan to `.agent/{filename}` in the working directory +/// 2. Yields control back to the frontend with the plan text +/// +/// The frontend shows a PlanCard with an "Implement" button. +pub struct SavePlanTool; + +#[async_trait] +impl Tool for SavePlanTool { + fn name(&self) -> &str { + "save_plan" + } + + fn description(&self) -> &str { + "Save your implementation plan and present it to the user for approval. \ + Call this when your plan is complete and ready for review. \ + You must provide a descriptive filename (e.g. 'plan-add-auth-middleware.md'). \ + The plan will be saved to .agent/{filename} and shown to the user with an Implement button." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["plan", "filename"], + "properties": { + "plan": { + "type": "string", + "description": "The complete implementation plan in markdown format" + }, + "filename": { + "type": "string", + "description": "A descriptive filename for the plan, e.g. 'plan-add-auth-middleware.md' or 'plan-refactor-database-layer.md'. Must end in .md." + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let plan = args + .get("plan") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if plan.is_empty() { + return Ok(ToolResult::error("Plan must not be empty.")); + } + + let filename = args + .get("filename") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("plan.md"); + + // Sanitize filename — only allow alphanumeric, hyphens, underscores, dots + let safe_filename: String = filename + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' }) + .collect(); + let safe_filename = if safe_filename.ends_with(".md") { safe_filename } else { format!("{safe_filename}.md") }; + + // Save plan to .agent/{filename} + let agent_dir = crate::util::ensure_agent_dir(&ctx.working_dir).await; + let plan_path = agent_dir.join(&safe_filename); + if let Err(e) = tokio::fs::write(&plan_path, &plan).await { + return Ok(ToolResult::error(format!("Failed to write plan file: {e}"))); + } + + log::info!( + "[v1.0] save_plan: wrote {} bytes to {}", + plan.len(), + plan_path.display() + ); + + let yield_data = json!({ + "yield_type": "save_plan", + "plan": plan, + "plan_path": plan_path.to_string_lossy(), + }); + + Ok(ToolResult { + output: format!("Plan saved to {}", plan_path.display()), + is_error: false, + yield_data: Some(yield_data), + modified_files: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(dir: &std::path::Path) -> ToolContext { + ToolContext::test_context(dir) + } + + #[tokio::test] + async fn test_save_plan_basic() { + let tmp = tempfile::tempdir().unwrap(); + let tool = SavePlanTool; + let result = tool + .execute( + json!({"plan": "## Goal\nFix the bug\n\n## Steps\n1. Read file\n2. Edit file"}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.yield_data.is_some()); + + let data = result.yield_data.unwrap(); + assert_eq!(data["yield_type"], "save_plan"); + assert!(data["plan"].as_str().unwrap().contains("Fix the bug")); + + // Verify file was written + let plan_path = tmp.path().join(".agent").join("plan.md"); + assert!(plan_path.exists()); + let content = tokio::fs::read_to_string(&plan_path).await.unwrap(); + assert!(content.contains("Fix the bug")); + } + + #[tokio::test] + async fn test_save_plan_empty_error() { + let tmp = tempfile::tempdir().unwrap(); + let tool = SavePlanTool; + let result = tool + .execute(json!({"plan": ""}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.yield_data.is_none()); + } +} diff --git a/crates/agent/src/tool/schema.rs b/crates/agent/src/tool/schema.rs new file mode 100644 index 00000000..420d3d5a --- /dev/null +++ b/crates/agent/src/tool/schema.rs @@ -0,0 +1,126 @@ +use serde_json::Value; + +/// Lightweight validation of tool arguments against a JSON schema. +/// +/// Checks that required fields are present and have the correct basic type. +/// Returns a human-readable error message on failure (the LLM can learn from it). +pub fn validate_args(args: &Value, schema: &Value) -> Result<(), String> { + let args_obj = args + .as_object() + .ok_or_else(|| "Arguments must be a JSON object".to_string())?; + + let schema_obj = match schema.as_object() { + Some(o) => o, + None => return Ok(()), // no schema = no validation + }; + + // Check required fields + if let Some(Value::Array(required)) = schema_obj.get("required") { + for req in required { + if let Some(field_name) = req.as_str() { + if !args_obj.contains_key(field_name) { + return Err(format!("Missing required parameter: '{field_name}'")); + } + } + } + } + + // Check types for provided fields + if let Some(Value::Object(properties)) = schema_obj.get("properties") { + for (key, value) in args_obj { + if let Some(prop_schema) = properties.get(key) { + if let Some(expected_type) = prop_schema.get("type").and_then(|t| t.as_str()) { + let type_ok = match expected_type { + "string" => value.is_string(), + "integer" => value.is_i64() || value.is_u64(), + "number" => value.is_number(), + "boolean" => value.is_boolean(), + "object" => value.is_object(), + "array" => value.is_array(), + _ => true, // unknown type, skip + }; + if !type_ok { + return Err(format!( + "Parameter '{key}' must be of type '{expected_type}', got {}", + json_type_name(value) + )); + } + } + } + } + } + + Ok(()) +} + +fn json_type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_valid_args_pass() { + let schema = json!({ + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { "type": "string" }, + "offset": { "type": "integer" } + } + }); + let args = json!({ "filePath": "/test.rs", "offset": 10 }); + assert!(validate_args(&args, &schema).is_ok()); + } + + #[test] + fn test_missing_required_field() { + let schema = json!({ + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { "type": "string" } + } + }); + let args = json!({}); + let err = validate_args(&args, &schema).unwrap_err(); + assert!(err.contains("filePath")); + } + + #[test] + fn test_type_mismatch() { + let schema = json!({ + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { "type": "string" } + } + }); + let args = json!({ "filePath": 42 }); + let err = validate_args(&args, &schema).unwrap_err(); + assert!(err.contains("string")); + } + + #[test] + fn test_no_schema_passes() { + let args = json!({ "anything": "goes" }); + assert!(validate_args(&args, &json!(null)).is_ok()); + } + + #[test] + fn test_args_not_object() { + let schema = json!({ "type": "object" }); + let err = validate_args(&json!("not an object"), &schema).unwrap_err(); + assert!(err.contains("JSON object")); + } +} diff --git a/crates/agent/src/tool/start_session.rs b/crates/agent/src/tool/start_session.rs new file mode 100644 index 00000000..e2ad7e2a --- /dev/null +++ b/crates/agent/src/tool/start_session.rs @@ -0,0 +1,215 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use super::{Tool, ToolContext, ToolResult}; + +/// Tool that initiates a coding session from ask mode. +/// In ask mode, this signals the agent loop to yield with `AgentResult::StartSession`. +pub struct StartSessionTool; + +#[async_trait] +impl Tool for StartSessionTool { + fn name(&self) -> &str { + "start_session" + } + + fn description(&self) -> &str { + "Start a coding session to make changes to the codebase. Use this when you need to write, edit, or execute code. \ + This creates a new git worktree and branch for isolated work." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["project_path", "task_summary"], + "properties": { + "project_path": { + "type": "string", + "description": "Absolute path to the project root directory" + }, + "branch": { + "type": "string", + "description": "Optional branch name. If not provided, one will be generated from the task summary." + }, + "task_summary": { + "type": "string", + "description": "Brief description of what this coding session will accomplish" + } + } + }) + } + + async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result { + let project_path = args + .get("project_path") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let branch = args + .get("branch") + .and_then(|v| v.as_str()) + .map(String::from); + let task_summary = args + .get("task_summary") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + // Validate project_path exists + let path = std::path::Path::new(&project_path); + if project_path.is_empty() || !path.exists() { + return Ok(ToolResult::error(format!( + "Cannot start coding session: project path '{}' does not exist. \ + Ask the user to select a project folder first.", + project_path + ))); + } + + // Validate it's a git repository (required for worktree creation) + let git_dir = path.join(".git"); + if !git_dir.exists() { + return Ok(ToolResult::error(format!( + "Cannot start coding session: '{}' is not a git repository. \ + Tell the user to please select a project folder with git initialized using the folder picker.", + project_path + ))); + } + + let yield_data = json!({ + "yield_type": "start_session", + "project_path": project_path, + "task_summary": task_summary, + "branch": branch, + }); + + Ok(ToolResult { + output: format!("Starting coding session: {task_summary}"), + is_error: false, + yield_data: Some(yield_data), + modified_files: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::ToolContext; + + fn make_git_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + dir + } + + #[tokio::test] + async fn test_start_session_basic() { + let dir = make_git_repo(); + let tool = StartSessionTool; + let args = json!({ + "project_path": dir.path().to_string_lossy(), + "task_summary": "Fix the login bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("Fix the login bug")); + assert!(result.yield_data.is_some()); + + let data = result.yield_data.unwrap(); + assert_eq!(data["project_path"], dir.path().to_string_lossy().as_ref()); + assert_eq!(data["task_summary"], "Fix the login bug"); + assert!(data.get("branch").is_none() || data["branch"].is_null()); + } + + #[tokio::test] + async fn test_start_session_with_branch() { + let dir = make_git_repo(); + let tool = StartSessionTool; + let args = json!({ + "project_path": dir.path().to_string_lossy(), + "branch": "fix/login-bug", + "task_summary": "Fix the login bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + let data = result.yield_data.unwrap(); + assert_eq!(data["branch"], "fix/login-bug"); + } + + #[tokio::test] + async fn test_start_session_empty_path() { + let tool = StartSessionTool; + let args = json!({ + "project_path": "", + "task_summary": "Fix a bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("does not exist")); + assert!(result.yield_data.is_none()); + } + + #[tokio::test] + async fn test_start_session_nonexistent_path() { + let tool = StartSessionTool; + let args = json!({ + "project_path": "/this/path/does/not/exist/at/all", + "task_summary": "Fix a bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("does not exist")); + assert!(result.yield_data.is_none()); + } + + #[tokio::test] + async fn test_start_session_not_a_git_repo() { + let dir = tempfile::tempdir().unwrap(); + let tool = StartSessionTool; + let args = json!({ + "project_path": dir.path().to_string_lossy(), + "task_summary": "Fix a bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("not a git repository")); + assert!(result.yield_data.is_none()); + } + + #[tokio::test] + async fn test_start_session_valid_git_repo() { + let dir = tempfile::tempdir().unwrap(); + // Initialize a git repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let tool = StartSessionTool; + let args = json!({ + "project_path": dir.path().to_string_lossy(), + "task_summary": "Fix the login bug" + }); + let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); + let result = tool.execute(args, &ctx).await.unwrap(); + + assert!(!result.is_error); + assert!(result.yield_data.is_some()); + assert!(result.output.contains("Fix the login bug")); + } +} diff --git a/crates/agent/src/tool/todo_write.rs b/crates/agent/src/tool/todo_write.rs new file mode 100644 index 00000000..4c0f98fa --- /dev/null +++ b/crates/agent/src/tool/todo_write.rs @@ -0,0 +1,264 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::types::{AgentEvent, TodoItem}; +use super::{Tool, ToolContext, ToolResult}; + +/// Tool that lets the agent track progress on multi-step tasks. +/// +/// Each call replaces the entire todo list (replace-all semantics). +/// The list is written to `{working_dir}/.agent/todos.md` for persistence +/// across context compaction, and emitted as a `TodoUpdated` event for the UI. +pub struct TodoWriteTool; + +#[async_trait] +impl Tool for TodoWriteTool { + fn name(&self) -> &str { + "todo_write" + } + + fn description(&self) -> &str { + "Write or update a todo list to track progress on multi-step tasks. \ + Each call replaces the entire list. Mark items as completed as you finish them." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["todos"], + "properties": { + "todos": { + "type": "array", + "description": "The complete todo list. Each call replaces the entire list.", + "items": { + "type": "object", + "required": ["id", "content", "status"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this todo item" + }, + "content": { + "type": "string", + "description": "Description of the task" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"], + "description": "Current status of the task" + } + } + } + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let todos_value = match args.get("todos") { + Some(v) if v.is_array() => v, + _ => return Ok(ToolResult::error("'todos' must be a non-empty array.")), + }; + + let arr = todos_value.as_array().unwrap(); + let mut todos: Vec = Vec::with_capacity(arr.len()); + + for (i, item) in arr.iter().enumerate() { + let id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let content = item + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending") + .to_string(); + + if id.is_empty() || content.is_empty() { + return Ok(ToolResult::error(format!( + "Todo item at index {} must have non-empty 'id' and 'content'.", + i + ))); + } + + match status.as_str() { + "pending" | "in_progress" | "completed" => {} + _ => { + return Ok(ToolResult::error(format!( + "Todo item '{}' has invalid status '{}'. Must be: pending, in_progress, or completed.", + id, status + ))); + } + } + + todos.push(TodoItem { id, content, status }); + } + + let status_counts: (usize, usize, usize) = todos.iter().fold((0,0,0), |mut acc, t| { + match t.status.as_str() { "pending" => acc.0 += 1, "in_progress" => acc.1 += 1, "completed" => acc.2 += 1, _ => {} }; acc + }); + log::info!("[v1.0] todo_write: {} items (pending={}, in_progress={}, completed={})", todos.len(), status_counts.0, status_counts.1, status_counts.2); + + // Format as markdown + let mut md = String::from("# Agent Todos\n\n"); + for todo in &todos { + let marker = match todo.status.as_str() { + "completed" => "[x]", + "in_progress" => "[-]", + _ => "[ ]", + }; + md.push_str(&format!("- {} {}\n", marker, todo.content)); + } + + // Write to .agent/todos.md + let agent_dir = crate::util::ensure_agent_dir(&ctx.working_dir).await; + + let todo_path = agent_dir.join("todos.md"); + if let Err(e) = tokio::fs::write(&todo_path, &md).await { + log::warn!("Failed to write todos.md: {e}"); + } + + // Emit event for UI + let _ = ctx.event_tx.send(AgentEvent::TodoUpdated { + session_id: ctx.session_id.clone(), + todos: todos.clone(), + }).await; + + // Return the formatted list as tool output + Ok(ToolResult::success(md)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(dir: &std::path::Path) -> ToolContext { + ToolContext::test_context(dir) + } + + #[tokio::test] + async fn test_todo_write_basic() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + let result = tool + .execute( + json!({ + "todos": [ + {"id": "1", "content": "Read the file", "status": "completed"}, + {"id": "2", "content": "Edit the function", "status": "in_progress"}, + {"id": "3", "content": "Run tests", "status": "pending"} + ] + }), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("[x] Read the file")); + assert!(result.output.contains("[-] Edit the function")); + assert!(result.output.contains("[ ] Run tests")); + } + + #[tokio::test] + async fn test_todo_write_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + tool.execute( + json!({ + "todos": [ + {"id": "1", "content": "Task one", "status": "pending"} + ] + }), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + let todo_path = tmp.path().join(".agent").join("todos.md"); + assert!(todo_path.exists()); + let content = tokio::fs::read_to_string(&todo_path).await.unwrap(); + assert!(content.contains("Task one")); + } + + #[tokio::test] + async fn test_todo_write_replaces_on_update() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + + // First write + tool.execute( + json!({"todos": [{"id": "1", "content": "First task", "status": "pending"}]}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + // Second write — replaces + tool.execute( + json!({"todos": [{"id": "2", "content": "Second task", "status": "completed"}]}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(tmp.path().join(".agent/todos.md")) + .await + .unwrap(); + assert!(!content.contains("First task")); + assert!(content.contains("Second task")); + } + + #[tokio::test] + async fn test_todo_write_empty_list() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + let result = tool + .execute(json!({"todos": []}), &test_ctx(tmp.path())) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("# Agent Todos")); + } + + #[tokio::test] + async fn test_todo_write_invalid_status() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + let result = tool + .execute( + json!({"todos": [{"id": "1", "content": "Task", "status": "done"}]}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("invalid status")); + } + + #[tokio::test] + async fn test_todo_write_missing_content() { + let tmp = tempfile::tempdir().unwrap(); + let tool = TodoWriteTool; + let result = tool + .execute( + json!({"todos": [{"id": "1", "content": "", "status": "pending"}]}), + &test_ctx(tmp.path()), + ) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output.contains("non-empty")); + } +} diff --git a/crates/agent/src/tool/write.rs b/crates/agent/src/tool/write.rs new file mode 100644 index 00000000..bbb59efd --- /dev/null +++ b/crates/agent/src/tool/write.rs @@ -0,0 +1,136 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::error::ToolError; +use crate::util::resolve_path; +use super::{Tool, ToolContext, ToolResult}; + +pub struct WriteTool; + +#[async_trait] +impl Tool for WriteTool { + fn name(&self) -> &str { + "write" + } + + fn description(&self) -> &str { + "Write content to a file. Creates the file and any parent directories if they don't exist. \ + Overwrites the file if it already exists." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["filePath", "content"], + "properties": { + "filePath": { + "type": "string", + "description": "Absolute or relative path to the file to write" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + } + }) + } + + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let file_path = args + .get("filePath") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: filePath".into()))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError("Missing required parameter: content".into()))?; + + let path = resolve_path(&ctx.working_dir, file_path); + + // Note: path traversal check (outside working dir) is handled by the agent loop + // before execute() is called, with user approval flow. + + // Create parent directories if needed + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| ToolError(format!("Failed to create parent directories: {e}")))?; + } + + let byte_count = content.len(); + tokio::fs::write(&path, content) + .await + .map_err(|e| ToolError(format!("Failed to write file: {e}")))?; + + Ok(ToolResult::success(format!("Wrote {byte_count} bytes to {}", path.display()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_write_new_file() { + let dir = tempdir().unwrap(); + let tool = WriteTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "filePath": "hello.txt", "content": "hello world" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + assert!(result.output.contains("11 bytes")); + + let written = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(); + assert_eq!(written, "hello world"); + } + + #[tokio::test] + async fn test_write_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let tool = WriteTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "filePath": "a/b/c/deep.txt", "content": "deep content" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + let written = std::fs::read_to_string(dir.path().join("a/b/c/deep.txt")).unwrap(); + assert_eq!(written, "deep content"); + } + + #[tokio::test] + async fn test_write_overwrites_existing() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("existing.txt"); + std::fs::write(&file_path, "old content").unwrap(); + + let tool = WriteTool; + let ctx = ToolContext::test_context(dir.path()); + + let result = tool + .execute( + json!({ "filePath": file_path.to_str().unwrap(), "content": "new content" }), + &ctx, + ) + .await + .unwrap(); + + assert!(!result.is_error); + let written = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(written, "new content"); + } +} diff --git a/crates/agent/src/types.rs b/crates/agent/src/types.rs new file mode 100644 index 00000000..fece62e5 --- /dev/null +++ b/crates/agent/src/types.rs @@ -0,0 +1,161 @@ +use serde::Serialize; + +/// Events emitted by the agent loop for UI consumption. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum AgentEvent { + TextDelta { + session_id: String, + delta: String, + }, + ThinkingDelta { + session_id: String, + delta: String, + }, + ToolStart { + session_id: String, + tool_call_id: String, + tool_name: String, + args_summary: String, + }, + ToolStatus { + session_id: String, + tool_call_id: String, + status: String, + }, + ToolEnd { + session_id: String, + tool_call_id: String, + success: bool, + summary: String, + /// Files modified by this tool (for write/edit tools). + #[serde(skip_serializing_if = "Option::is_none")] + modified_files: Option>, + }, + Error { + session_id: String, + message: String, + retrying: bool, + }, + Done { + session_id: String, + summary: Option, + }, + Compaction { + session_id: String, + }, + /// Token usage update after each LLM call. + TokenUsage { + session_id: String, + total_tokens: u32, + context_limit: u32, + /// Tokens served from prompt cache this call (read tier). + /// None when caching isn't active or the provider didn't report it. + cache_read_tokens: Option, + /// Tokens written to prompt cache this call (write tier, Anthropic). + cache_creation_tokens: Option, + }, + /// Fired at the end of each agent loop iteration (after all tool calls complete). + /// Carries the turn number and list of files modified during this turn. + TurnCompleted { + session_id: String, + turn_count: u32, + modified_files: Vec, + }, + /// The ask-mode agent wants to start a coding session. + SessionStart { + session_id: String, + project_path: String, + branch: Option, + task_summary: String, + }, + /// The agent is asking the user a clarifying question (yield). + UserQuestionAsked { + session_id: String, + question: String, + #[serde(skip_serializing_if = "Option::is_none")] + options: Option>, + }, + /// The agent updated its todo list. + TodoUpdated { + session_id: String, + todos: Vec, + }, + /// The plan-mode agent saved an implementation plan (yield). + PlanReady { + session_id: String, + plan: String, + plan_path: String, + project_path: String, + }, + /// The `skill` tool loaded a skill body into the conversation. + SkillLoaded { + session_id: String, + skill_name: String, + }, + /// A `spawn_subagent` tool call has started a child AgentLoop. Emitted on + /// the PARENT's event_tx. Per DEC-1, the child's stream is NOT forwarded + /// to the parent UI — this event (plus SubagentEnd) is the sole parent- + /// visible representation. `prompt_preview` is the first ~120 chars of + /// the user-facing prompt, truncated for chip display. + SubagentStart { + session_id: String, + parent_tool_call_id: String, + child_session_id: String, + subagent_name: String, + prompt_preview: String, + }, + /// The child AgentLoop finished. `success` is false on error or cancel; + /// `summary` is the child's final assistant text (or an error message). + SubagentEnd { + session_id: String, + parent_tool_call_id: String, + child_session_id: String, + success: bool, + summary: String, + }, +} + +/// A single todo item tracked by the agent. +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +pub struct TodoItem { + pub id: String, + pub content: String, + pub status: String, +} + +/// Result returned by the agent loop when it finishes. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum AgentResult { + /// Agent completed normally with a final summary. + Done { summary: String }, + /// Agent wants to start a coding session (yield from ask mode). + StartSession { + project_path: String, + branch: Option, + task_summary: String, + }, + /// Agent is asking the user a clarifying question (yield from ask/plan mode). + AskUser { + question: String, + options: Option>, + }, + /// Plan-mode agent saved a plan and yielded for user approval. + PlanReady { + plan: String, + plan_path: String, + }, +} + +impl AgentResult { + /// Extract the summary from a Done result, panicking if it's not Done. + /// Useful in tests. + #[cfg(test)] + pub fn unwrap_done(self) -> String { + match self { + AgentResult::Done { summary } => summary, + other => panic!("Expected AgentResult::Done, got {:?}", other), + } + } +} diff --git a/crates/agent/src/util.rs b/crates/agent/src/util.rs new file mode 100644 index 00000000..6067186e --- /dev/null +++ b/crates/agent/src/util.rs @@ -0,0 +1,353 @@ +use std::path::{Path, PathBuf}; + +/// Ensure the `.agent/` directory exists and is excluded from git via `info/exclude`. +/// Called by any tool that writes to `.agent/` (save_plan, todo_write, truncate_and_persist). +/// Works in both the main repo and worktrees. +pub(crate) async fn ensure_agent_dir(working_dir: &Path) -> PathBuf { + let agent_dir = working_dir.join(".agent"); + let _ = tokio::fs::create_dir_all(&agent_dir).await; + + // Add .agent/ to git's info/exclude (invisible to user, not tracked) + ensure_git_exclude(working_dir, ".agent/").await; + + agent_dir +} + +/// Add an entry to git's `info/exclude` for the repo at `working_dir`. +/// Uses `git rev-parse --absolute-git-dir` to find the correct gitdir. +/// Best-effort idempotent — concurrent calls may produce duplicate entries +/// (harmless, git deduplicates). Never modifies user-visible files. +pub(crate) async fn ensure_git_exclude(working_dir: &Path, entry: &str) { + let mut cmd = tokio::process::Command::new("git"); + cmd.args(["rev-parse", "--absolute-git-dir"]) + .current_dir(working_dir); + git_ops::no_window::no_window_tokio(&mut cmd); + let output = cmd.output().await; + + let git_dir = match output { + Ok(o) if o.status.success() => { + PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()) + } + _ => return, // Not a git repo — skip silently + }; + + let exclude_dir = git_dir.join("info"); + let exclude_path = exclude_dir.join("exclude"); + + let needs_append = if exclude_path.exists() { + tokio::fs::read_to_string(&exclude_path) + .await + .map(|c| !c.lines().any(|line| line.trim() == entry)) + .unwrap_or(true) + } else { + true + }; + + if needs_append { + let _ = tokio::fs::create_dir_all(&exclude_dir).await; + use tokio::io::AsyncWriteExt; + if let Ok(mut file) = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&exclude_path) + .await + { + let _ = file.write_all(format!("\n{entry}\n").as_bytes()).await; + } + } +} + +/// Resolve a file path against a working directory. +/// Returns the path as-is if absolute, otherwise joins with `working_dir`. +pub(crate) fn resolve_path(working_dir: &std::path::Path, file_path: &str) -> PathBuf { + let p = PathBuf::from(file_path); + if p.is_absolute() { + p + } else { + working_dir.join(p) + } +} + +/// Check if a resolved path is inside the working directory. +/// Returns `true` if the path is inside (safe), `false` if outside (needs approval). +/// +/// For existing paths, uses `canonicalize()` to resolve symlinks. +/// For new paths (don't exist yet), checks the nearest existing ancestor. +pub(crate) fn is_path_within_working_dir(working_dir: &Path, resolved: &Path) -> bool { + let canonical_wd = match working_dir.canonicalize() { + Ok(p) => p, + Err(_) => return false, + }; + + // For existing files/dirs, canonicalize resolves symlinks + if resolved.exists() { + return resolved.canonicalize() + .map(|p| p.starts_with(&canonical_wd)) + .unwrap_or(false); + } + + // For new files, check the nearest existing ancestor + let mut ancestor = resolved.to_path_buf(); + loop { + if ancestor.exists() { + return ancestor.canonicalize() + .map(|p| p.starts_with(&canonical_wd)) + .unwrap_or(false); + } + if !ancestor.pop() { + return false; + } + } +} + +/// Find the largest byte offset <= `target` that is a valid UTF-8 char boundary. +pub(crate) fn floor_char_boundary(s: &str, target: usize) -> usize { + if target >= s.len() { + return s.len(); + } + let mut end = target; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + end +} + +/// Safely truncate a string to at most `max_bytes` bytes without splitting +/// a multi-byte character. +pub(crate) fn truncate_str(s: &str, max_bytes: usize) -> &str { + &s[..floor_char_boundary(s, max_bytes)] +} + +/// Truncate output that exceeds line or byte limits. +/// Used by both bash tool output and the global tool output truncation in the agent loop. +pub(crate) fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> String { + let total_lines = output.lines().count(); + let total_bytes = output.len(); + + if total_lines <= max_lines && total_bytes <= max_bytes { + return output.to_string(); + } + + let mut result = String::new(); + let mut byte_count = 0; + + for (line_count, line) in output.lines().enumerate() { + if line_count >= max_lines || byte_count + line.len() + 1 > max_bytes { + result.push_str(&format!( + "\n... truncated ({line_count} of {total_lines} lines, {byte_count} of {total_bytes} bytes)" + )); + return result; + } + if line_count > 0 { + result.push('\n'); + byte_count += 1; + } + result.push_str(line); + byte_count += line.len(); + } + + result +} + +/// Truncate tool output and persist the full content to a temp file when truncated. +/// +/// When output exceeds limits, saves the full untruncated output to +/// `{working_dir}/.agent/tool-output/{tool_call_id}.txt` so the LLM can +/// read specific sections later using the read tool. +pub(crate) async fn truncate_and_persist( + output: &str, + max_lines: usize, + max_bytes: usize, + working_dir: &Path, + tool_call_id: &str, +) -> String { + let total_lines = output.lines().count(); + let total_bytes = output.len(); + + // No truncation needed + if total_lines <= max_lines && total_bytes <= max_bytes { + return output.to_string(); + } + + // Truncate first + let truncated = truncate_output(output, max_lines, max_bytes); + + // Save full output to temp file + let base_agent_dir = ensure_agent_dir(working_dir).await; + let agent_dir = base_agent_dir.join("tool-output"); + if let Ok(()) = tokio::fs::create_dir_all(&agent_dir).await { + let temp_path = agent_dir.join(format!("{tool_call_id}.txt")); + if let Ok(()) = tokio::fs::write(&temp_path, output).await { + return format!( + "{truncated}\nFull output saved to {}. Use the read tool to view specific sections.", + temp_path.display() + ); + } + } + + // Fallback: return truncated without file reference + truncated +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + // ── resolve_path tests ── + + #[test] + fn test_resolve_absolute_path() { + let result = resolve_path(Path::new("/working"), "/absolute/file.rs"); + assert_eq!(result, PathBuf::from("/absolute/file.rs")); + } + + #[test] + fn test_resolve_relative_path() { + let result = resolve_path(Path::new("/working"), "relative/file.rs"); + assert_eq!(result, PathBuf::from("/working/relative/file.rs")); + } + + // ── truncate_str tests ── + + #[test] + fn test_truncate_str_ascii() { + assert_eq!(truncate_str("hello world", 5), "hello"); + } + + #[test] + fn test_truncate_str_short_string() { + assert_eq!(truncate_str("hi", 10), "hi"); + } + + #[test] + fn test_truncate_str_empty() { + assert_eq!(truncate_str("", 5), ""); + } + + #[test] + fn test_truncate_str_emoji() { + let s = "Hello 🌍"; + assert_eq!(s.len(), 10); + let result = truncate_str(s, 7); + assert_eq!(result, "Hello "); + } + + #[test] + fn test_truncate_str_cjk() { + let s = "中文"; + assert_eq!(s.len(), 6); + let result = truncate_str(s, 4); + assert_eq!(result, "中"); + } + + #[test] + fn test_truncate_str_exact_boundary() { + assert_eq!(truncate_str("abcdef", 3), "abc"); + } + + // ── truncate_output tests ── + + #[test] + fn test_truncate_output_small_passes_through() { + let input = "line 1\nline 2\nline 3"; + assert_eq!(truncate_output(input, 2000, 50 * 1024), input); + } + + #[test] + fn test_truncate_output_exceeds_line_limit() { + let input: String = (0..2500).map(|i| format!("line {i}")).collect::>().join("\n"); + let result = truncate_output(&input, 2000, 50 * 1024); + assert!(result.contains("truncated")); + assert!(result.contains("2000 of 2500 lines")); + } + + #[test] + fn test_truncate_output_exceeds_byte_limit() { + let input: String = (0..100).map(|i| format!("line {:04} {}", i, "x".repeat(600))).collect::>().join("\n"); + assert!(input.len() > 50 * 1024); + let result = truncate_output(&input, 2000, 50 * 1024); + assert!(result.contains("truncated")); + } + + #[test] + fn test_truncate_output_empty() { + assert_eq!(truncate_output("", 2000, 50 * 1024), ""); + } + + #[test] + fn test_truncate_output_exactly_at_limit() { + let input: String = (0..2000).map(|_| "x\n").collect(); + let result = truncate_output(&input, 2000, 50 * 1024); + assert!(!result.contains("truncated")); + } + + // ── floor_char_boundary tests ── + + #[test] + fn test_floor_char_boundary_ascii() { + assert_eq!(floor_char_boundary("hello", 3), 3); + } + + #[test] + fn test_floor_char_boundary_at_string_end() { + assert_eq!(floor_char_boundary("hello", 100), 5); + } + + #[test] + fn test_floor_char_boundary_snaps_back_on_multibyte() { + let s = "abc🌍def"; // 🌍 is 4 bytes at positions 3-6 + assert_eq!(floor_char_boundary(s, 5), 3); + assert_eq!(floor_char_boundary(s, 7), 7); + } + + #[test] + fn test_floor_char_boundary_zero() { + assert_eq!(floor_char_boundary("hello", 0), 0); + } + + // ── truncate_and_persist tests ── + + #[tokio::test] + async fn test_truncate_and_persist_no_truncation() { + let dir = tempfile::tempdir().unwrap(); + let result = truncate_and_persist("short output", 2000, 50 * 1024, dir.path(), "tc_1").await; + assert_eq!(result, "short output"); + // No .agent directory should be created + assert!(!dir.path().join(".agent").exists()); + } + + #[tokio::test] + async fn test_truncate_and_persist_saves_full_output() { + let dir = tempfile::tempdir().unwrap(); + let long_output: String = (0..100).map(|i| format!("line {i}")).collect::>().join("\n"); + // Use a small line limit to trigger truncation + let result = truncate_and_persist(&long_output, 10, 50 * 1024, dir.path(), "tc_42").await; + + assert!(result.contains("truncated")); + assert!(result.contains("tc_42.txt")); + assert!(result.contains("Use the read tool")); + + // Verify the full output was saved to the temp file + let saved_path = dir.path().join(".agent").join("tool-output").join("tc_42.txt"); + assert!(saved_path.exists()); + let saved_content = std::fs::read_to_string(&saved_path).unwrap(); + assert_eq!(saved_content, long_output); + } + + #[tokio::test] + async fn test_truncate_and_persist_byte_limit() { + let dir = tempfile::tempdir().unwrap(); + // Create output that exceeds byte limit + let long_output: String = (0..100).map(|i| format!("line {:04} {}", i, "x".repeat(600))).collect::>().join("\n"); + assert!(long_output.len() > 50 * 1024); + + let result = truncate_and_persist(&long_output, 2000, 50 * 1024, dir.path(), "tc_bytes").await; + + assert!(result.contains("truncated")); + let saved_path = dir.path().join(".agent").join("tool-output").join("tc_bytes.txt"); + assert!(saved_path.exists()); + let saved_content = std::fs::read_to_string(&saved_path).unwrap(); + assert_eq!(saved_content, long_output); + } +} diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs new file mode 100644 index 00000000..8139b591 --- /dev/null +++ b/crates/agent/tests/integration.rs @@ -0,0 +1,1108 @@ +//! Integration tests against real OpenAI API. +//! +//! Requires OPENAI_API_KEY environment variable to be set. +//! Run with: OPENAI_API_KEY=sk-... cargo test --test integration -- --ignored --nocapture + +use std::path::{Path, PathBuf}; + +use tempfile::tempdir; + +use agent::agent::config::{AgentConfig, CompactionConfig, RetryConfig}; +use agent::agent::loop_::AgentLoop; +use agent::agent::spawn_agent; +use agent::llm::LlmClientConfig; +use agent::persistence::MockPersister; +use agent::tool::{ToolMode, ToolRegistry}; +use agent::types::{AgentEvent, AgentResult}; + +use std::sync::Arc; + +// ── Helpers ── + +#[allow(dead_code)] +struct TestRunResult { + text: String, + events: Vec, + working_dir: PathBuf, +} + +/// Full result including the raw AgentResult variant. +#[allow(dead_code)] +struct FullRunResult { + agent_result: AgentResult, + events: Vec, + working_dir: PathBuf, +} + +impl TestRunResult { + /// All tool names invoked, in order. + fn tool_names(&self) -> Vec<&str> { + self.events + .iter() + .filter_map(|e| { + if let AgentEvent::ToolStart { tool_name, .. } = e { + Some(tool_name.as_str()) + } else { + None + } + }) + .collect() + } + + fn has_done_event(&self) -> bool { + self.events + .iter() + .any(|e| matches!(e, AgentEvent::Done { .. })) + } +} + +impl FullRunResult { + fn tool_names(&self) -> Vec<&str> { + self.events + .iter() + .filter_map(|e| { + if let AgentEvent::ToolStart { tool_name, .. } = e { + Some(tool_name.as_str()) + } else { + None + } + }) + .collect() + } + + #[allow(dead_code)] + fn has_event bool>(&self, pred: F) -> bool { + self.events.iter().any(pred) + } +} + +/// Build a single-block (uncached) system prompt — for test fixtures that +/// override the default system prompt. +fn sys(text: impl Into) -> Vec { + vec![agent::agent::prompt::SystemBlock { + text: text.into(), + cache_control: None, + }] +} + +fn make_config(api_key: &str, working_dir: PathBuf) -> AgentConfig { + let base_url = std::env::var("LLM_BASE_URL") + .unwrap_or_else(|_| "https://api.openai.com/v1".to_string()); + let model = std::env::var("LLM_MODEL") + .unwrap_or_else(|_| "gpt-4o-mini".to_string()); + // GPT-5.x / o1 / o3 / o4 reasoning models reject `temperature=0.0`. Drop the + // field for those; keep 0.0 for Claude where it's deterministic and supported. + let temperature = if model.starts_with("claude-") { + Some(0.0) + } else { + None + }; + // Support custom auth headers via env vars: LLM_AUTH_TOKEN + LLM_USER_ID + LLM_WORKSPACE_ID + let auth_headers = if let Ok(token) = std::env::var("LLM_AUTH_TOKEN") { + let user_id = std::env::var("LLM_USER_ID").expect("LLM_AUTH_TOKEN set but LLM_USER_ID missing"); + let workspace_id = std::env::var("LLM_WORKSPACE_ID").expect("LLM_AUTH_TOKEN set but LLM_WORKSPACE_ID missing"); + vec![ + ("X-Auth-Token".to_string(), token), + ("X-USER-ID".to_string(), user_id), + ("X-Workspace-ID".to_string(), workspace_id), + ] + } else { + vec![("Authorization".to_string(), format!("Bearer {}", api_key))] + }; + + AgentConfig { + llm: LlmClientConfig { + base_url, + model, + temperature, + max_completion_tokens: Some(1024), + auth_headers, + thinking: None, + disable_cache_control: false, + }, + working_dir, + mode: ToolMode::Coding, + max_iterations: 20, + system_prompt: Some(vec![agent::agent::prompt::SystemBlock { + text: "You are a coding assistant. Use the available tools to complete tasks. \ + Be concise and use tools directly without asking for confirmation.".to_string(), + cache_control: None, + }]), + retry_config: RetryConfig::default(), + compaction_config: Default::default(), + compaction_llm: None, + context_engine: None, + context_engine_repo_path: None, + skills: None, + subagents: None, + subagent_inheritance: None, + } +} + +async fn run_agent(config: AgentConfig, message: &str) -> TestRunResult { + let working_dir = config.working_dir.clone(); + let spawned = spawn_agent(config, agent::llm::types::ChatMessage::user(message), None, None, None); + let handle = spawned.handle; + let mut event_rx = spawned.event_rx; + let cancel_token = spawned.cancel_token; + eprintln!("Session ID: {}", spawned.session_id); + + let event_collector = tokio::spawn(async move { + let mut events = Vec::new(); + while let Some(event) = event_rx.recv().await { + eprintln!("Event: {:?}", event); + events.push(event); + } + events + }); + + let result = handle.await.unwrap(); + drop(cancel_token); + let events = event_collector.await.unwrap(); + + let text = match result { + Ok(AgentResult::Done { summary }) => { + eprintln!("Agent completed: {summary}"); + summary + } + Ok(AgentResult::StartSession { task_summary, .. }) => { + eprintln!("Agent wants to start session: {task_summary}"); + task_summary + } + Ok(AgentResult::AskUser { question, .. }) => { + eprintln!("Agent asks: {question}"); + question + } + Ok(AgentResult::PlanReady { plan, .. }) => { + eprintln!("Agent produced plan"); + plan + } + Err(e) => panic!("Agent failed: {e}"), + }; + + TestRunResult { + text, + events, + working_dir, + } +} + +/// Run an agent with a specific ToolMode. Returns the full AgentResult. +async fn run_agent_with_mode(config: AgentConfig, message: &str, mode: ToolMode) -> FullRunResult { + let working_dir = config.working_dir.clone(); + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(256); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let session_id = uuid::Uuid::new_v4().to_string(); + let registry = ToolRegistry::for_mode(mode, None, None); + + let message_owned = message.to_string(); + let handle = { + let cancel_token = cancel_token.clone(); + let session_id = session_id.clone(); + tokio::spawn(async move { + let mut agent_loop = + AgentLoop::new(config, registry, cancel_token, event_tx, session_id); + agent_loop.run(agent::llm::types::ChatMessage::user(message_owned)).await + }) + }; + + let event_collector = tokio::spawn(async move { + let mut events = Vec::new(); + while let Some(event) = event_rx.recv().await { + eprintln!("Event: {:?}", event); + events.push(event); + } + events + }); + + let result = handle.await.unwrap(); + drop(cancel_token); + let events = event_collector.await.unwrap(); + + let agent_result = match result { + Ok(r) => { + eprintln!("Agent result: {:?}", r); + r + } + Err(e) => panic!("Agent failed: {e}"), + }; + + FullRunResult { + agent_result, + events, + working_dir, + } +} + +/// Run agent with a custom config that returns FullRunResult (coding mode). +async fn run_agent_full(config: AgentConfig, message: &str) -> FullRunResult { + run_agent_with_mode(config, message, ToolMode::Coding).await +} + +fn api_key() -> String { + std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| "unused".to_string()) +} + +fn read_file(dir: &Path, name: &str) -> String { + std::fs::read_to_string(dir.join(name)).unwrap() +} + +// ── Tests ── + +/// Phase 1 — write + read round-trip. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_agent_creates_and_reads_file() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "Create a file called hello.txt containing exactly 'hello world', then read it back to confirm.", + ).await; + + // File created with correct content — this is the real assertion + let content = read_file(&r.working_dir, "hello.txt"); + assert_eq!(content.trim(), "hello world"); + + let names = r.tool_names(); + assert!(names.contains(&"write"), "Expected write tool, got: {names:?}"); + assert!(r.has_done_event()); +} + +/// Phase 2, Step 2.7 — read then edit (multi-tool sequential). +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_agent_reads_and_edits_file() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "hello world\n").unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "Read the file test.txt, then change 'hello' to 'goodbye' in it using the edit tool.", + ).await; + + // File edited correctly + let content = read_file(&r.working_dir, "test.txt"); + assert!(content.contains("goodbye"), "Should contain 'goodbye'"); + assert!(!content.contains("hello"), "Should not contain 'hello'"); + + // Tool ordering: read must appear before edit + let names = r.tool_names(); + let read_pos = names.iter().position(|n| *n == "read").expect("read tool not used"); + let edit_pos = names.iter().position(|n| *n == "edit").expect("edit tool not used"); + assert!(read_pos < edit_pos, "read should come before edit, got: {names:?}"); + assert!(r.has_done_event()); +} + +/// Phase 2 — glob + grep search tools. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_agent_uses_glob_and_grep() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + let src = tmp.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("main.rs"), "fn main() {\n println!(\"hello\");\n}\n").unwrap(); + std::fs::write(src.join("lib.rs"), "pub fn add(a: i32, b: i32) -> i32 { a + b }\n").unwrap(); + std::fs::write(tmp.path().join("README.md"), "# My Project\n").unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "Find all .rs files in this project using glob, then search for the word 'fn' using grep. \ + Tell me what you found.", + ).await; + + let names = r.tool_names(); + assert!(names.contains(&"glob"), "Expected glob tool, got: {names:?}"); + assert!(names.contains(&"grep"), "Expected grep tool, got: {names:?}"); + assert!(r.has_done_event()); +} + +/// Error recovery — agent handles a tool error and adapts. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_agent_recovers_from_tool_error() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "Try to read a file called missing.txt. It won't exist. \ + After the error, create it with the content 'recovered'.", + ).await; + + // File should exist now with correct content + let content = read_file(&r.working_dir, "missing.txt"); + assert_eq!(content.trim(), "recovered"); + assert!(r.has_done_event()); +} + +/// Phase 6 — git tool: status, add, commit in a temp repo. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_agent_uses_git_tool() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + + // Initialize a real git repo in the temp dir + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + // Create an initial commit so the repo is not empty + std::fs::write(tmp.path().join("README.md"), "# Test Project\n").unwrap(); + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + // Now create an uncommitted file for the agent to discover + std::fs::write(tmp.path().join("new_feature.txt"), "some new code\n").unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "Use the git tool to check the status of this repo. There should be an untracked file. \ + Stage it with git add, then commit it with the message 'add new feature'. \ + Finally run git log to confirm the commit.", + ).await; + + let names = r.tool_names(); + assert!(names.contains(&"git"), "Expected git tool, got: {names:?}"); + + // Verify the commit actually happened by checking git log + let log_output = std::process::Command::new("git") + .args(["log", "--oneline"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let log_text = String::from_utf8_lossy(&log_output.stdout); + assert!(log_text.contains("add new feature"), "Commit not found in log: {log_text}"); + + // The file should no longer be untracked + let status_output = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let status_text = String::from_utf8_lossy(&status_output.stdout); + assert!(status_text.trim().is_empty(), "Repo should be clean, got: {status_text}"); + + assert!(r.has_done_event()); +} + +// ── Phase 3 Tests ── + +/// Phase 3 — Ask mode agent calls start_session when asked to make changes. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_ask_mode_starts_coding_session() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap(); + + // Initialize a git repo so start_session validation passes + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.system_prompt = Some(sys( + "You are a coding assistant in ask mode. You have read-only tools (read, glob, grep) \ + and a start_session tool. When the user asks you to modify code, you MUST call start_session \ + with the project_path set to the working directory and a task_summary. \ + Do NOT try to use write or edit tools — they are not available. \ + Call start_session immediately without explanation.", + )); + + let r = run_agent_with_mode( + config, + &format!( + "Please fix the main function in main.rs to print 'hello'. \ + The project path is '{}'.", + tmp.path().display() + ), + ToolMode::Ask, + ) + .await; + + // The agent should yield a StartSession result + match &r.agent_result { + AgentResult::StartSession { + project_path, + task_summary, + .. + } => { + eprintln!("StartSession: project_path={project_path}, task_summary={task_summary}"); + assert!(!project_path.is_empty(), "project_path should not be empty"); + assert!(!task_summary.is_empty(), "task_summary should not be empty"); + } + other => { + panic!("Expected StartSession, got {:?}", other); + } + } + + // Verify start_session was called + let names = r.tool_names(); + assert!( + names.contains(&"start_session"), + "Expected start_session tool, got: {names:?}" + ); +} + +/// Phase 3 — Ask mode cannot use destructive tools (write, edit, bash, git). +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_ask_mode_tool_isolation() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "original content\n").unwrap(); + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.system_prompt = Some(sys( + "You are a coding assistant in ask mode. You only have read, glob, grep, and start_session tools. \ + Read the file test.txt and tell the user its content. Do NOT try to modify it.", + )); + + let r = run_agent_with_mode( + config, + "Read test.txt and tell me what's in it.", + ToolMode::Ask, + ) + .await; + + // Verify only read-only tools were used (ask mode has: read, glob, grep, start_session, ask_user) + let names = r.tool_names(); + for name in &names { + assert!( + ["read", "glob", "grep", "start_session", "ask_user"].contains(name), + "Ask mode used unexpected tool: {name}" + ); + } + assert!(names.contains(&"read"), "Expected read tool, got: {names:?}"); + + // File should be unmodified + let content = read_file(&r.working_dir, "test.txt"); + assert_eq!(content, "original content\n"); +} + +/// Phase 3 — Max iterations returns graceful Done (not an error). +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_max_iterations_graceful() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + let src = tmp.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + for i in 0..20 { + std::fs::write( + src.join(format!("file{i}.rs")), + format!("fn func_{i}() {{}}\n"), + ) + .unwrap(); + } + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.max_iterations = 2; // Very low limit — glob + read will consume both steps + + let r = run_agent_full( + config, + "Read every .rs file in the src/ directory one by one, then write a summary to summary.txt. \ + There are 20 files so read each one individually, then create the summary file.", + ) + .await; + + // Should complete with Done (not panic or error), either gracefully or by hitting the limit + match &r.agent_result { + AgentResult::Done { summary } => { + eprintln!("Max iterations result: {summary}"); + // Either the agent hit the limit or completed — both are Ok(Done), not Err + } + other => panic!("Expected Done, got: {:?}", other), + } +} + +/// Phase 3 — Coding mode with persistence records all messages. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_coding_mode_with_persistence() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("hello.txt"), "hello world\n").unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + let persister = Arc::new(MockPersister::new()); + + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(256); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let session_id = uuid::Uuid::new_v4().to_string(); + let registry = ToolRegistry::for_mode(ToolMode::Coding, None, None); + + let handle = { + let cancel_token = cancel_token.clone(); + let session_id = session_id.clone(); + let persister_clone = Arc::clone(&persister) as Arc; + tokio::spawn(async move { + let mut agent_loop = + AgentLoop::new(config, registry, cancel_token, event_tx, session_id); + agent_loop = agent_loop.with_persister(persister_clone, Some("test-thread".into())); + agent_loop.run(agent::llm::types::ChatMessage::user("Read hello.txt and tell me its content.")).await + }) + }; + + // Collect events + let event_collector = tokio::spawn(async move { + let mut events = Vec::new(); + while let Some(event) = event_rx.recv().await { + events.push(event); + } + events + }); + + let result = handle.await.unwrap().unwrap(); + drop(cancel_token); + let _events = event_collector.await.unwrap(); + + // Result should be Done + match &result { + AgentResult::Done { summary } => { + eprintln!("Persisted agent result: {summary}"); + assert!( + summary.to_lowercase().contains("hello world") + || summary.to_lowercase().contains("hello"), + "Summary should reference file content" + ); + } + other => panic!("Expected Done, got: {:?}", other), + } + + // Give fire-and-forget persistence tasks a moment to complete + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Verify persistence received messages + let messages = persister.messages(); + assert!( + messages.len() >= 2, + "Should have at least user + assistant messages, got {}", + messages.len() + ); + eprintln!("Persisted {} messages", messages.len()); + + // All should have thread_id = "test-thread" + for (tid, _msg) in &messages { + assert_eq!( + tid.as_deref(), + Some("test-thread"), + "Expected thread_id 'test-thread'" + ); + } +} + +/// Phase 3 — Compaction triggers with small context limit. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_compaction_triggers_with_small_context() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + + // Create several files so the agent generates enough content to trigger compaction + for i in 0..5 { + std::fs::write( + tmp.path().join(format!("file{i}.txt")), + format!("Content of file {i}: {}", "x".repeat(200)), + ) + .unwrap(); + } + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + // Very small context limit to force compaction + config.compaction_config = CompactionConfig { + context_limit: 500, // ~500 tokens → threshold at 400 tokens + threshold_pct: 0.80, + keep_recent_messages: 2, + max_messages: 10_000, + }; + config.max_iterations = 10; + + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(256); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let session_id = uuid::Uuid::new_v4().to_string(); + let registry = ToolRegistry::for_mode(ToolMode::Coding, None, None); + let persister = Arc::new(MockPersister::new()); + + let handle = { + let cancel_token = cancel_token.clone(); + let session_id = session_id.clone(); + let persister_clone = Arc::clone(&persister) as Arc; + tokio::spawn(async move { + let mut agent_loop = + AgentLoop::new(config, registry, cancel_token, event_tx, session_id); + agent_loop = agent_loop.with_persister(persister_clone, Some("compact-thread".into())); + agent_loop + .run(agent::llm::types::ChatMessage::user("Read all .txt files one by one (file0.txt through file4.txt) and summarize each one.")) + .await + }) + }; + + let event_collector = tokio::spawn(async move { + let mut events = Vec::new(); + while let Some(event) = event_rx.recv().await { + events.push(event); + } + events + }); + + let _result = handle.await.unwrap(); + drop(cancel_token); + let events = event_collector.await.unwrap(); + + // Check if compaction event was emitted + let has_compaction = events + .iter() + .any(|e| matches!(e, AgentEvent::Compaction { .. })); + + // Give persistence time to flush + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Check if compaction was persisted + let messages = persister.messages(); + let has_compaction_record = messages + .iter() + .any(|(_, msg)| matches!(msg.message_type, agent::persistence::MessageType::Compaction)); + + eprintln!( + "Compaction event emitted: {has_compaction}, compaction persisted: {has_compaction_record}, total messages: {}", + messages.len() + ); + + // With a 500-token context limit and 5 file reads, compaction should trigger. + // If it doesn't (model is very concise), at least verify the agent completed. + if has_compaction { + assert!( + has_compaction_record, + "Compaction event emitted but no compaction record persisted" + ); + } +} + +// ── v1.0 Feature Tests ── + +/// Plan mode: agent uses ask_user to yield a clarifying question. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_plan_mode_ask_user_yield() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap(); + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.mode = ToolMode::Plan; + config.system_prompt = Some(sys( + "You are a planning agent. You have read-only tools and an ask_user tool. \ + When the user's request is ambiguous, you MUST call the ask_user tool to clarify. \ + The user's request below is intentionally vague — call ask_user with a clarifying question \ + and provide 2-3 options. Do NOT try to answer directly, you MUST ask a question first.", + )); + + let r = run_agent_with_mode( + config, + "Improve the code.", + ToolMode::Plan, + ) + .await; + + // Should yield AskUser (not Done or StartSession) + match &r.agent_result { + AgentResult::AskUser { question, .. } => { + eprintln!("Agent asked: {question}"); + assert!(!question.is_empty(), "Question should not be empty"); + } + other => { + // Some models may not follow the instruction perfectly — at least verify + // ask_user was attempted or the agent returned a reasonable result + let names = r.tool_names(); + if names.contains(&"ask_user") { + eprintln!("ask_user was called but agent returned {:?}", other); + } else { + eprintln!("WARN: Agent did not use ask_user tool. Got: {:?}", other); + } + } + } +} + +/// Coding mode: agent uses todo_write for multi-step tasks. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_coding_mode_todo_write() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + + let config = make_config(&api_key(), tmp.path().to_path_buf()); + + let r = run_agent( + config, + "You must complete these 3 steps. Use the todo_write tool to track them: \ + 1. Create a file called a.txt with content 'aaa'. \ + 2. Create a file called b.txt with content 'bbb'. \ + 3. Create a file called c.txt with content 'ccc'. \ + Use todo_write to plan these steps BEFORE starting, then mark each as completed as you finish.", + ) + .await; + + // Verify todo_write was used + let names = r.tool_names(); + assert!( + names.contains(&"todo_write"), + "Expected todo_write tool, got: {names:?}" + ); + + // Verify the files were created + assert!( + tmp.path().join("a.txt").exists(), + "a.txt should have been created" + ); + assert!( + tmp.path().join("b.txt").exists(), + "b.txt should have been created" + ); + assert!( + tmp.path().join("c.txt").exists(), + "c.txt should have been created" + ); + + // Verify the todos.md file was written + let todos_path = tmp.path().join(".agent").join("todos.md"); + assert!( + todos_path.exists(), + "todos.md should have been created by todo_write" + ); + + assert!(r.has_done_event()); +} + +/// Plan mode: tool isolation — only read-only tools + ask_user + start_session + todo_write. +#[tokio::test] +#[ignore = "requires OPENAI_API_KEY"] +async fn test_plan_mode_tool_isolation() { + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "plan mode content\n").unwrap(); + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.mode = ToolMode::Plan; + config.system_prompt = Some(sys( + "You are a planning agent. Read test.txt and tell me what's in it. \ + Do NOT try to modify files — you only have read-only tools.", + )); + + let r = run_agent_with_mode( + config, + "Read test.txt and describe its contents.", + ToolMode::Plan, + ) + .await; + + // Verify only plan-mode tools were used + let names = r.tool_names(); + let plan_tools = ["read", "glob", "grep", "ask_user", "start_session", "todo_write"]; + for name in &names { + assert!( + plan_tools.contains(name), + "Plan mode used unexpected tool: {name}" + ); + } + + // File should be unmodified + let content = read_file(&r.working_dir, "test.txt"); + assert_eq!(content, "plan mode content\n"); +} + +// ── Compaction integration test ── + +/// Test that compaction fires with a real LLM when the context is pre-seeded +/// with enough history to exceed the (tiny) threshold. +/// We seed 6 prior conversation messages (creates clean compaction boundaries), +/// then ask the agent to read a file — the token count will exceed the threshold. +#[tokio::test] +#[ignore] // Requires OPENAI_API_KEY +async fn test_compaction_survives_multi_file_reads() { + let tmp = tempdir().unwrap(); + std::fs::write( + tmp.path().join("data.txt"), + (0..50).map(|i| format!("row {i}: {}", "x".repeat(80))).collect::>().join("\n"), + ).unwrap(); + + let mut config = make_config(&api_key(), tmp.path().to_path_buf()); + config.compaction_config = CompactionConfig { + context_limit: 2000, + threshold_pct: 0.40, // threshold = 800 tokens + keep_recent_messages: 2, + max_messages: 10_000, + }; + config.max_iterations = 10; + + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(256); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let session_id = uuid::Uuid::new_v4().to_string(); + let registry = ToolRegistry::for_mode(ToolMode::Coding, None, None); + + // Seed prior context with clean boundaries (user/assistant text pairs) + let prior_context = vec![ + agent::llm::types::ChatMessage::user("What files are in this project?"), + agent::llm::types::ChatMessage::assistant(Some("I found several configuration files including data.txt, config.yaml, and main.rs. The project appears to be a Rust application with some data files.".into()), None, None), + agent::llm::types::ChatMessage::user("Tell me about the architecture"), + agent::llm::types::ChatMessage::assistant(Some("The architecture follows a layered pattern with a data layer, service layer, and presentation layer. Each module has its own configuration constants defined in separate files.".into()), None, None), + agent::llm::types::ChatMessage::user("What patterns do they use?"), + agent::llm::types::ChatMessage::assistant(Some("They use the builder pattern for configuration, the observer pattern for events, and dependency injection throughout. The code is well-structured with clear separation of concerns.".into()), None, None), + ]; + + let sid = session_id.clone(); + let ct = cancel_token.clone(); + let handle = tokio::spawn(async move { + let mut agent = AgentLoop::new(config, registry, ct, event_tx, sid); + agent = agent.with_initial_context(prior_context); + agent.run(agent::llm::types::ChatMessage::user( + "Read data.txt and tell me how many rows it has." + )).await + }); + + let event_collector = tokio::spawn(async move { + let mut events = Vec::new(); + while let Some(event) = event_rx.recv().await { + eprintln!("Event: {:?}", event); + events.push(event); + } + events + }); + + let result = handle.await.unwrap(); + drop(cancel_token); + let events = event_collector.await.unwrap(); + + // Agent should complete successfully + match result { + Ok(AgentResult::Done { summary }) => eprintln!("Agent completed: {summary}"), + Ok(other) => eprintln!("Agent result: {:?}", other), + Err(e) => panic!("Agent failed: {e}"), + } + + let compaction_count = events + .iter() + .filter(|e| matches!(e, AgentEvent::Compaction { .. })) + .count(); + eprintln!("Compaction fired {} time(s)", compaction_count); + + assert!( + compaction_count >= 1, + "Compaction should fire at least once with context_limit=2000, seeded context, and a file read" + ); + + assert!( + events.iter().any(|e| matches!(e, AgentEvent::Done { .. })), + "Agent should emit Done even after compaction" + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Prompt caching tests +// +// These assert the cache-token telemetry path end-to-end. Intentionally written +// BEFORE the gateway + Rust cache_control changes land — they fail today with +// cache counts at zero, and pass once Stage 2 (gateway) + Stage 3 (Rust client) +// are both in place. +// +// Env setup for the ignored test: +// LLM_BASE_URL = gateway URL that forwards cache_control to Anthropic +// LLM_MODEL = claude-sonnet-4-6 (coding-mode system+tools clears 2K min) +// OPENAI_API_KEY (or gateway auth envvars populated by make_config) +// ─────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_usage_deserializes_prompt_tokens_details() { + // This is the shape the gateway will emit after Stage 2: Anthropic's + // cache_{read,creation}_input_tokens mapped into OpenAI-style + // prompt_tokens_details.{cached_tokens, cache_creation_tokens}. + use agent::llm::types::Usage; + let json = r#"{ + "prompt_tokens": 5000, + "completion_tokens": 300, + "total_tokens": 5300, + "prompt_tokens_details": { + "cached_tokens": 4500, + "cache_creation_tokens": 400 + } + }"#; + let usage: Usage = serde_json::from_str(json).expect("Usage should parse"); + let details = usage + .prompt_tokens_details + .expect("prompt_tokens_details should be present"); + assert_eq!(details.cached_tokens, Some(4500)); + assert_eq!(details.cache_creation_tokens, Some(400)); +} + +#[test] +fn test_usage_without_cache_details_still_parses() { + // Backward-compat: pre-caching responses (or OpenAI responses without + // caching active) must still deserialize cleanly. + use agent::llm::types::Usage; + let json = r#"{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}"#; + let usage: Usage = serde_json::from_str(json).expect("legacy Usage should parse"); + assert!(usage.prompt_tokens_details.is_none()); +} + +/// End-to-end caching test. Runs a multi-LLM-call coding session against a +/// real caching-capable gateway/provider. Asserts that: +/// - at least one LLM call writes to cache (cache_creation_tokens > 0) +/// - at least one subsequent LLM call reads from cache (cache_read_tokens > 0) +/// +/// Fails today because: +/// - Gateway strips cache_control fields from outbound Anthropic requests +/// - Rust client does not mark cache_control on system/tools +/// - Gateway does not surface cache_* tokens in Usage +/// +/// Expected to pass after Stages 2 and 3 ship. +#[tokio::test] +#[ignore = "requires ANTHROPIC_API_KEY + caching-capable endpoint (gateway or direct)"] +async fn test_caching_emits_cache_tokens_across_turns() { + let _ = env_logger::try_init(); + let api_key = api_key(); + let tmp = tempdir().unwrap(); + let mut cfg = make_config(&api_key, tmp.path().to_path_buf()); + + // Coding mode: system(~2.1K) + tools(~1.4K) ≈ 3.5K tokens. + // We pad the user message with ~3KB of stable context so turn-1's request + // clears the HIGHEST Anthropic minimum (Opus 4.6 + Haiku 4.5 = 4096 tokens). + // Without padding, Opus/Haiku fall below threshold on turn 1 → no cache write + // → nothing for turn 2 to read. With padding, all three Claude models and + // OpenAI-family models clear their respective minima. + cfg.mode = ToolMode::Coding; + cfg.max_iterations = 5; + + // CRITICAL: use the REAL build_system_prompt, which returns a 2-block system + // with cache_control on the static body. The default test harness system + // prompt (from make_config) is a single uncached block, which would never + // trigger Anthropic caching no matter what the client does. + cfg.system_prompt = Some(agent::agent::prompt::build_system_prompt( + cfg.mode, + &cfg.working_dir, + None, // no branch + None, // no project note + None, // no skills + None, // no subagents + )); + + std::fs::write(tmp.path().join("sample.txt"), "hello world\n").unwrap(); + + // Stable preamble repeated deterministically. ~9 KB ≈ ~2.2K tokens → combined + // with the real Coding system prompt (~3.5K tokens system+tools) we comfortably + // clear Opus 4.6 / Haiku 4.5's 4096-token minimum on turn 1. + let padding = "The assistant is careful, precise, and does not speculate. " + .repeat(150); + let user_message = format!( + "{padding}\n\nRead the file `sample.txt` in the working directory and \ + tell me its contents in one short sentence." + ); + + let result = run_agent(cfg, &user_message).await; + + let token_usages: Vec<(Option, Option)> = result + .events + .iter() + .filter_map(|e| match e { + AgentEvent::TokenUsage { + cache_read_tokens, + cache_creation_tokens, + .. + } => Some((*cache_read_tokens, *cache_creation_tokens)), + _ => None, + }) + .collect(); + + eprintln!( + "Captured {} TokenUsage events: {:?}", + token_usages.len(), + token_usages + ); + + assert!( + token_usages.len() >= 2, + "Need at least 2 LLM calls to test caching; got {} (did the agent use a tool?)", + token_usages.len() + ); + + let any_write = token_usages + .iter() + .any(|(_r, w)| w.unwrap_or(0) > 0); + let any_read = token_usages + .iter() + .any(|(r, _w)| r.unwrap_or(0) > 0); + + // Provider-aware oracle. Anthropic reports BOTH creation and read tokens; + // OpenAI only reports cached reads (no creation — writes are automatic + free + // in their pricing model). We accept either pattern as proof caching is + // engaged end-to-end. + let model = std::env::var("LLM_MODEL").unwrap_or_default(); + let is_openai_family = model.starts_with("gpt-") + || model.starts_with("o1-") + || model.starts_with("o3") + || model.starts_with("o4") + || model.starts_with("chatgpt-"); + + if is_openai_family { + // OpenAI: success iff at least one turn read from cache. + assert!( + any_read, + "OpenAI: expected at least one LLM call to READ cache \ + (prompt_tokens_details.cached_tokens > 0), but none did. \ + TokenUsage events: {:?}", + token_usages + ); + } else { + // Anthropic (and anything else that reports both): success iff BOTH + // write and read occurred. This is the strict end-to-end oracle. + assert!( + any_write, + "Expected at least one LLM call to WRITE cache (cache_creation_tokens > 0), but got none. \ + Likely causes: gateway strips cache_control; Rust client doesn't emit cache_control; \ + prompt is below provider's minimum cacheable size. TokenUsage events: {:?}", + token_usages + ); + assert!( + any_read, + "Expected at least one LLM call to READ cache (cache_read_tokens > 0), but got none. \ + Either the cache was never written, or subsequent calls don't match the cached prefix. \ + TokenUsage events: {:?}", + token_usages + ); + } +} diff --git a/crates/git-ops/Cargo.toml b/crates/git-ops/Cargo.toml new file mode 100644 index 00000000..81edbdfe --- /dev/null +++ b/crates/git-ops/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "git-ops" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["process"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +log = "0.4" + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } +tempfile = "3" diff --git a/crates/git-ops/src/checkpoint.rs b/crates/git-ops/src/checkpoint.rs new file mode 100644 index 00000000..e0c61dde --- /dev/null +++ b/crates/git-ops/src/checkpoint.rs @@ -0,0 +1,494 @@ +use std::path::Path; + +use crate::error::GitOpsError; +use crate::exec::{run_git, run_git_raw}; +use crate::types::DiffOutput; + +/// Info about a captured checkpoint. +#[derive(Debug, Clone)] +pub struct CheckpointInfo { + pub ref_name: String, + pub commit_sha: String, + pub turn_count: u32, + pub created_at: String, // ISO 8601 from git creatordate +} + +/// Generate the git ref path for a checkpoint. +/// Format: `refs/agent/checkpoints/{thread_id}/turn-{turn}` +pub fn checkpoint_ref(thread_id: &str, turn: u32) -> String { + format!("refs/agent/checkpoints/{}/turn-{}", thread_id, turn) +} + +/// Check if a checkpoint ref exists in the repository. +pub async fn has_checkpoint( + repo_path: &Path, + ref_name: &str, +) -> Result { + let output = run_git_raw(repo_path, &["rev-parse", "--verify", ref_name]).await?; + Ok(output.exit_code == 0) +} + +/// Capture the current worktree state as a hidden git ref. +/// +/// Performs: +/// 1. git add -A (stage everything including untracked) +/// 2. git write-tree -> tree SHA +/// 3. git rev-parse HEAD -> parent SHA +/// 4. git commit-tree {tree} -p {parent} -m "checkpoint: {ref_name}" -> commit SHA +/// 5. git update-ref {ref_name} {commit_sha} +/// +/// Returns the commit SHA of the checkpoint. +pub async fn capture_checkpoint( + repo_path: &Path, + ref_name: &str, +) -> Result { + // 1. Stage everything (including untracked files) + run_git(repo_path, &["add", "-A"]).await?; + + // 2. Write the current index as a tree object + let tree_output = run_git(repo_path, &["write-tree"]).await?; + let tree_sha = tree_output.stdout.trim().to_string(); + + // 3. Get the current HEAD as parent + let head_output = run_git(repo_path, &["rev-parse", "HEAD"]).await?; + let parent_sha = head_output.stdout.trim().to_string(); + + // 4. Create a commit object (dangling — not on any branch) + let msg = format!("checkpoint: {}", ref_name); + let commit_output = run_git( + repo_path, + &["commit-tree", &tree_sha, "-p", &parent_sha, "-m", &msg], + ) + .await?; + let commit_sha = commit_output.stdout.trim().to_string(); + + // 5. Point the ref at the new commit + run_git(repo_path, &["update-ref", ref_name, &commit_sha]).await?; + + // NOTE: We intentionally do NOT run `git reset HEAD` here. + // The staged state from `git add -A` is harmless — the agent's tools (write/edit/bash) + // work on the filesystem directly, not via git staging. And final diffs use + // checkpoint-based refs, not `git diff` (unstaged). + // Running `git reset HEAD` would unstage new files, which breaks the LLM's + // `git add` + `git commit` workflow (the checkpoint fires between tool calls + // and would unstage files the LLM just staged). + + Ok(commit_sha) +} + +/// Compute the diff between two checkpoint refs. +/// Returns a DiffOutput with raw unified diff text + stats. +pub async fn diff_checkpoints( + repo_path: &Path, + from_ref: &str, + to_ref: &str, +) -> Result { + let range = format!("{}..{}", from_ref, to_ref); + crate::core::diff(repo_path, None, false, Some(&range)).await +} + +/// Restore the worktree to the state of a checkpoint. +/// +/// Steps: +/// 1. Capture a pre-restore snapshot at {ref_name}-pre-restore +/// 2. git checkout {ref_name} -- . (restore files without moving branch) +/// +/// The pre-restore snapshot ensures recovery is always possible. +pub async fn restore_checkpoint( + repo_path: &Path, + ref_name: &str, +) -> Result<(), GitOpsError> { + // 1. Safety: capture the current state before restoring + let pre_restore_ref = format!("{}-pre-restore", ref_name); + capture_checkpoint(repo_path, &pre_restore_ref).await?; + + // 2. Restore working tree to match the checkpoint exactly. + // read-tree sets the index to the checkpoint's tree. + // checkout-index writes all index entries to the working tree. + // clean -fd removes working tree files no longer in the index. + // This handles modifications, deletions, AND additions correctly + // (unlike `git checkout -- .` which leaves added files behind). + run_git(repo_path, &["read-tree", ref_name]).await?; + run_git(repo_path, &["checkout-index", "-f", "-a"]).await?; + run_git(repo_path, &["clean", "-fd"]).await?; + + Ok(()) +} + +/// Extract the turn number from a checkpoint ref name. +/// Expected format: `refs/agent/checkpoints/{thread_id}/turn-{N}` +/// Also handles pre-restore refs like `turn-2-pre-restore`. +fn parse_turn_from_ref(ref_name: &str) -> Option { + let last_segment = ref_name.rsplit('/').next()?; + let turn_part = last_segment.strip_prefix("turn-")?; + // Handle "turn-5" or "turn-5-pre-restore" + let num_str = turn_part.split('-').next()?; + num_str.parse().ok() +} + +/// Delete all checkpoint refs for a thread from a given turn onward. +/// Used after restore to clean up stale future checkpoints. +/// Also deletes associated pre-restore snapshots (e.g. `turn-2-pre-restore` +/// is treated as belonging to turn 2 and deleted when `from_turn <= 2`). +/// Returns the number of refs deleted. +pub async fn delete_checkpoint_refs( + repo_path: &Path, + thread_id: &str, + from_turn: u32, +) -> Result { + let prefix = format!("refs/agent/checkpoints/{}/", thread_id); + let output = run_git(repo_path, &["for-each-ref", "--format=%(refname)", &prefix]).await?; + + let mut deleted = 0u32; + for line in output.stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(turn) = parse_turn_from_ref(line) { + if turn >= from_turn { + run_git(repo_path, &["update-ref", "-d", line]).await?; + deleted += 1; + } + } + } + + Ok(deleted) +} + +/// List all checkpoints for a thread, ordered by turn count. +/// Excludes pre-restore snapshots from the listing. +pub async fn list_checkpoints( + repo_path: &Path, + thread_id: &str, +) -> Result, GitOpsError> { + let prefix = format!("refs/agent/checkpoints/{}/", thread_id); + let output = run_git( + repo_path, + &[ + "for-each-ref", + "--format=%(refname) %(objectname:short) %(creatordate:iso-strict)", + "--sort=refname", + &prefix, + ], + ) + .await?; + + let mut checkpoints = Vec::new(); + for line in output.stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Skip pre-restore snapshots + if line.contains("-pre-restore") { + continue; + } + + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + if parts.len() < 3 { + continue; // Malformed line, skip + } + + let ref_name = parts[0].to_string(); + let commit_sha = parts[1].to_string(); + let created_at = parts[2].to_string(); + let turn_count = parse_turn_from_ref(&ref_name).unwrap_or(0); + + checkpoints.push(CheckpointInfo { + ref_name, + commit_sha, + turn_count, + created_at, + }); + } + + // Sort numerically by turn count (--sort=refname is lexicographic) + checkpoints.sort_by_key(|c| c.turn_count); + + Ok(checkpoints) +} + +/// Delete ALL checkpoint refs for a thread. +/// Called when a coding session is completed and worktree is removed. +pub async fn delete_all_checkpoints( + repo_path: &Path, + thread_id: &str, +) -> Result { + delete_checkpoint_refs(repo_path, thread_id, 0).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::init_repo_with_commit; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_checkpoint_ref_format() { + assert_eq!( + checkpoint_ref("thread-abc", 0), + "refs/agent/checkpoints/thread-abc/turn-0" + ); + assert_eq!( + checkpoint_ref("thread-abc", 5), + "refs/agent/checkpoints/thread-abc/turn-5" + ); + } + + #[tokio::test] + async fn test_capture_checkpoint_creates_ref() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let ref_name = checkpoint_ref("t1", 0); + let sha = capture_checkpoint(dir.path(), &ref_name).await.unwrap(); + + // SHA should be a 40-char hex string + assert_eq!(sha.len(), 40); + assert!(sha.chars().all(|c| c.is_ascii_hexdigit())); + + // The ref should exist + assert!(has_checkpoint(dir.path(), &ref_name).await.unwrap()); + } + + #[tokio::test] + async fn test_capture_checkpoint_captures_uncommitted_changes() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + // State 1: modify the file + fs::write(dir.path().join("README.md"), "# State One").unwrap(); + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // State 2: modify the file again (don't commit) + fs::write(dir.path().join("README.md"), "# State Two").unwrap(); + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + + // Diff between the two checkpoints should show the change + let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); + assert!(d.diff.contains("State Two")); + assert!(d.diff.contains("State One")); + } + + #[tokio::test] + async fn test_has_checkpoint_false_for_missing() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let result = has_checkpoint(dir.path(), "refs/agent/checkpoints/nonexistent/turn-0") + .await + .unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn test_diff_checkpoints_shows_changes() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // Write a new file between checkpoints + fs::write(dir.path().join("new.txt"), "new file content").unwrap(); + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + + let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); + assert!(d.diff.contains("new.txt")); + assert!(d.insertions > 0); + } + + #[tokio::test] + async fn test_diff_checkpoints_no_changes() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // No edits between captures + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + + let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); + assert!(d.diff.is_empty()); + assert_eq!(d.insertions, 0); + assert_eq!(d.deletions, 0); + } + + #[tokio::test] + async fn test_restore_checkpoint() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + // Capture state with "hello" + fs::write(dir.path().join("data.txt"), "hello").unwrap(); + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // Overwrite to "world" and capture + fs::write(dir.path().join("data.txt"), "world").unwrap(); + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + + // Verify current state + assert_eq!( + fs::read_to_string(dir.path().join("data.txt")).unwrap(), + "world" + ); + + // Restore to turn-0 + restore_checkpoint(dir.path(), &ref0).await.unwrap(); + + // File should be back to "hello" + assert_eq!( + fs::read_to_string(dir.path().join("data.txt")).unwrap(), + "hello" + ); + } + + #[tokio::test] + async fn test_restore_creates_pre_restore_snapshot() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + fs::write(dir.path().join("file.txt"), "some content").unwrap(); + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + + // Restore to turn-0 + restore_checkpoint(dir.path(), &ref0).await.unwrap(); + + // Pre-restore snapshot should exist + let pre_restore_ref = format!("{}-pre-restore", ref0); + assert!(has_checkpoint(dir.path(), &pre_restore_ref).await.unwrap()); + } + + #[tokio::test] + async fn test_delete_checkpoint_refs_from_turn() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let thread_id = "t1"; + // Create 4 checkpoints, modifying a file between each + for turn in 0..4u32 { + fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); + let r = checkpoint_ref(thread_id, turn); + capture_checkpoint(dir.path(), &r).await.unwrap(); + } + + // Delete from turn 2 onward + let deleted = delete_checkpoint_refs(dir.path(), thread_id, 2).await.unwrap(); + assert_eq!(deleted, 2); + + // turn-0 and turn-1 should still exist + assert!(has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 0)).await.unwrap()); + assert!(has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 1)).await.unwrap()); + + // turn-2 and turn-3 should be gone + assert!(!has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 2)).await.unwrap()); + assert!(!has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 3)).await.unwrap()); + } + + #[tokio::test] + async fn test_list_checkpoints_ordered() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let thread_id = "t1"; + for turn in 0..3u32 { + fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); + let r = checkpoint_ref(thread_id, turn); + capture_checkpoint(dir.path(), &r).await.unwrap(); + } + + let checkpoints = list_checkpoints(dir.path(), thread_id).await.unwrap(); + assert_eq!(checkpoints.len(), 3); + assert_eq!(checkpoints[0].turn_count, 0); + assert_eq!(checkpoints[1].turn_count, 1); + assert_eq!(checkpoints[2].turn_count, 2); + + // Each should have a non-empty SHA and ISO date + for cp in &checkpoints { + assert!(!cp.commit_sha.is_empty()); + assert!(!cp.created_at.is_empty()); + } + } + + #[tokio::test] + async fn test_delete_all_checkpoints() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let thread_id = "t1"; + for turn in 0..2u32 { + fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); + let r = checkpoint_ref(thread_id, turn); + capture_checkpoint(dir.path(), &r).await.unwrap(); + } + + let deleted = delete_all_checkpoints(dir.path(), thread_id).await.unwrap(); + assert_eq!(deleted, 2); + + let remaining = list_checkpoints(dir.path(), thread_id).await.unwrap(); + assert!(remaining.is_empty()); + } + + #[tokio::test] + async fn test_restore_removes_files_added_after_checkpoint() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + // Capture turn-0 (only README.md exists) + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // Create a new file after the checkpoint + fs::write(dir.path().join("added_later.txt"), "I was added later").unwrap(); + let ref1 = checkpoint_ref("t1", 1); + capture_checkpoint(dir.path(), &ref1).await.unwrap(); + assert!(dir.path().join("added_later.txt").exists()); + + // Restore to turn-0 — the added file should be REMOVED + restore_checkpoint(dir.path(), &ref0).await.unwrap(); + assert!( + !dir.path().join("added_later.txt").exists(), + "File added after checkpoint should be removed on restore" + ); + // Original file should still be there + assert!(dir.path().join("README.md").exists()); + } + + #[tokio::test] + async fn test_capture_checkpoint_includes_untracked_files() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + // Create an untracked file (NOT committed, NOT staged) + fs::write(dir.path().join("untracked.txt"), "i am untracked").unwrap(); + + let ref0 = checkpoint_ref("t1", 0); + capture_checkpoint(dir.path(), &ref0).await.unwrap(); + + // Delete the untracked file + fs::remove_file(dir.path().join("untracked.txt")).unwrap(); + assert!(!dir.path().join("untracked.txt").exists()); + + // Restore to turn-0 should bring it back + restore_checkpoint(dir.path(), &ref0).await.unwrap(); + assert!(dir.path().join("untracked.txt").exists()); + assert_eq!( + fs::read_to_string(dir.path().join("untracked.txt")).unwrap(), + "i am untracked" + ); + } +} diff --git a/crates/git-ops/src/core.rs b/crates/git-ops/src/core.rs new file mode 100644 index 00000000..d0585135 --- /dev/null +++ b/crates/git-ops/src/core.rs @@ -0,0 +1,592 @@ +use std::path::Path; + +use crate::error::GitOpsError; +use crate::exec::run_git; +use crate::types::*; + +/// Get repository status. +pub async fn status(repo_path: &Path) -> Result { + let output = run_git(repo_path, &["status", "--porcelain=v2", "--branch"]).await?; + let raw = output.stdout.clone(); + + let mut branch = String::new(); + let mut ahead: u32 = 0; + let mut behind: u32 = 0; + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + + for line in raw.lines() { + if line.starts_with("# branch.head ") { + branch = line.strip_prefix("# branch.head ").unwrap_or("").to_string(); + } else if line.starts_with("# branch.ab ") { + // Format: # branch.ab +N -M + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + ahead = parts[2] + .strip_prefix('+') + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + behind = parts[3] + .strip_prefix('-') + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } + } else if line.starts_with("1 ") || line.starts_with("2 ") { + // Changed entries: "1 XY sub mH mI mW hH hI path" or "2 XY ... path\torigPath" + let parts: Vec<&str> = line.splitn(9, ' ').collect(); + if parts.len() >= 9 { + let xy = parts[1]; + let x = &xy[0..1]; // staged status + let y = &xy[1..2]; // unstaged status + + // For rename entries (prefix "2"), path may contain a tab + let path = if line.starts_with("2 ") { + parts[8].split('\t').next().unwrap_or(parts[8]) + } else { + parts[8] + }; + + if x != "." { + staged.push(FileStatus { + path: path.to_string(), + status: x.to_string(), + }); + } + if y != "." { + unstaged.push(FileStatus { + path: path.to_string(), + status: y.to_string(), + }); + } + } + } else if line.starts_with("? ") { + // Untracked: "? path" + let path = line.strip_prefix("? ").unwrap_or(""); + untracked.push(FileStatus { + path: path.to_string(), + status: "?".to_string(), + }); + } + } + + Ok(StatusOutput { + branch, + ahead, + behind, + staged, + unstaged, + untracked, + raw, + }) +} + +/// Get diff output. +pub async fn diff( + repo_path: &Path, + files: Option<&[&str]>, + staged: bool, + ref_range: Option<&str>, +) -> Result { + // Get the raw diff + let mut diff_args = vec!["diff"]; + if let Some(range) = ref_range { + diff_args.push(range); + } else if staged { + diff_args.push("--cached"); + } + if let Some(files) = files { + diff_args.push("--"); + diff_args.extend_from_slice(files); + } + let diff_output = run_git(repo_path, &diff_args).await?; + + // Get the stat summary + let mut stat_args = vec!["diff", "--stat"]; + if let Some(range) = ref_range { + stat_args.push(range); + } else if staged { + stat_args.push("--cached"); + } + if let Some(files) = files { + stat_args.push("--"); + stat_args.extend_from_slice(files); + } + let stat_output = run_git(repo_path, &stat_args).await?; + + // Parse stat summary from the last line, e.g. " 2 files changed, 3 insertions(+), 1 deletion(-)" + let stat_text = stat_output.stdout.trim().to_string(); + let (files_changed, insertions, deletions) = parse_diff_stat(&stat_text); + + Ok(DiffOutput { + diff: diff_output.stdout, + files_changed, + insertions, + deletions, + stat: stat_text, + }) +} + +fn parse_diff_stat(stat: &str) -> (u32, u32, u32) { + let last_line = stat.lines().last().unwrap_or(""); + + fn extract_number(line: &str, keyword: &str) -> u32 { + line.find(keyword) + .and_then(|idx| line[..idx].trim().split_whitespace().last()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0) + } + + ( + extract_number(last_line, "file"), + extract_number(last_line, "insertion"), + extract_number(last_line, "deletion"), + ) +} + +/// Commit changes. +pub async fn commit( + repo_path: &Path, + message: &str, + files: Option<&[&str]>, +) -> Result { + // Stage files + if let Some(files) = files { + let mut add_args = vec!["add", "--"]; + add_args.extend_from_slice(files); + run_git(repo_path, &add_args).await?; + } else { + run_git(repo_path, &["add", "-A"]).await?; + } + + // Commit + run_git(repo_path, &["commit", "-m", message]).await?; + + // Get the short SHA + let sha_output = run_git(repo_path, &["rev-parse", "--short", "HEAD"]).await?; + let sha = sha_output.stdout.trim().to_string(); + + // Get files changed count from the commit + let show_output = run_git(repo_path, &["show", "--stat", "--format=", "HEAD"]).await?; + let (files_changed, _, _) = parse_diff_stat(&show_output.stdout); + + Ok(CommitOutput { + sha, + message: message.to_string(), + files_changed, + }) +} + +/// Push to remote. +pub async fn push( + repo_path: &Path, + branch: Option<&str>, + remote: Option<&str>, +) -> Result { + let remote = remote.unwrap_or("origin"); + let mut push_args = vec!["push", remote]; + if let Some(branch) = branch { + push_args.push(branch); + } + + let output = run_git(repo_path, &push_args).await?; + + // If no branch was specified, resolve the current branch name so the + // output accurately reflects what was actually pushed. + let resolved_branch = match branch { + Some(b) => b.to_string(), + None => run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]) + .await + .map(|o| o.stdout.trim().to_string()) + .unwrap_or_default(), + }; + + Ok(PushOutput { + remote: remote.to_string(), + branch: resolved_branch, + raw: format!("{}{}", output.stdout, output.stderr), + }) +} + +/// List branches. +pub async fn branches(repo_path: &Path) -> Result, GitOpsError> { + let output = run_git( + repo_path, + &[ + "branch", + "-a", + "--format=%(if)%(HEAD)%(then)*%(else) %(end)\t%(refname:short)\t%(upstream:short)", + ], + ) + .await?; + + let mut result = Vec::new(); + for line in output.stdout.lines() { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 2 { + let is_current = parts[0].trim() == "*"; + let name = parts[1].to_string(); + let upstream = parts + .get(2) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + result.push(BranchInfo { + name, + is_current, + upstream, + }); + } + } + + Ok(result) +} + +/// Create a new branch. +pub async fn create_branch( + repo_path: &Path, + name: &str, + from: Option<&str>, +) -> Result<(), GitOpsError> { + let mut args = vec!["checkout", "-b", name]; + if let Some(from) = from { + args.push(from); + } + run_git(repo_path, &args).await?; + Ok(()) +} + +/// Switch to an existing branch. +pub async fn switch_branch(repo_path: &Path, name: &str) -> Result<(), GitOpsError> { + run_git(repo_path, &["checkout", name]).await?; + Ok(()) +} + +/// Get git log. +pub async fn log(repo_path: &Path, limit: Option) -> Result, GitOpsError> { + let limit_str = limit.unwrap_or(20).to_string(); + let output = run_git( + repo_path, + &["log", &format!("--format=%h\t%s\t%an\t%aI"), "-n", &limit_str], + ) + .await?; + + let mut entries = Vec::new(); + for line in output.stdout.lines() { + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.splitn(4, '\t').collect(); + if parts.len() >= 4 { + entries.push(LogEntry { + sha: parts[0].to_string(), + message: parts[1].to_string(), + author: parts[2].to_string(), + date: parts[3].to_string(), + }); + } + } + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + use tokio::process::Command; + use crate::test_util::init_repo_with_commit; + + // ── status tests ── + + #[tokio::test] + async fn test_status_clean_repo() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let s = status(dir.path()).await.unwrap(); + assert!(s.staged.is_empty()); + assert!(s.unstaged.is_empty()); + assert!(s.untracked.is_empty()); + } + + #[tokio::test] + async fn test_status_branch_name() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let s = status(dir.path()).await.unwrap(); + // Default branch could be "main" or "master" depending on git config + assert!(!s.branch.is_empty()); + } + + #[tokio::test] + async fn test_status_modified_file() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("README.md"), "# Modified").unwrap(); + let s = status(dir.path()).await.unwrap(); + assert_eq!(s.unstaged.len(), 1); + assert_eq!(s.unstaged[0].path, "README.md"); + assert_eq!(s.unstaged[0].status, "M"); + } + + #[tokio::test] + async fn test_status_staged_file() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("README.md"), "# Staged").unwrap(); + Command::new("git") + .args(["add", "README.md"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + let s = status(dir.path()).await.unwrap(); + assert_eq!(s.staged.len(), 1); + assert_eq!(s.staged[0].status, "M"); + } + + #[tokio::test] + async fn test_status_untracked_file() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("new.txt"), "new file").unwrap(); + let s = status(dir.path()).await.unwrap(); + assert_eq!(s.untracked.len(), 1); + assert_eq!(s.untracked[0].path, "new.txt"); + } + + // ── diff tests ── + + #[tokio::test] + async fn test_diff_unstaged_changes() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("README.md"), "# Modified\nNew line").unwrap(); + let d = diff(dir.path(), None, false, None).await.unwrap(); + assert!(!d.diff.is_empty()); + assert!(d.diff.contains("Modified")); + assert_eq!(d.files_changed, 1); + } + + #[tokio::test] + async fn test_diff_staged_changes() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("README.md"), "# Staged change").unwrap(); + Command::new("git") + .args(["add", "README.md"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + let d = diff(dir.path(), None, true, None).await.unwrap(); + assert!(!d.diff.is_empty()); + assert!(d.diff.contains("Staged change")); + } + + #[tokio::test] + async fn test_diff_specific_files() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("README.md"), "# Changed").unwrap(); + fs::write(dir.path().join("other.txt"), "other").unwrap(); + Command::new("git") + .args(["add", "other.txt"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add other"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + fs::write(dir.path().join("other.txt"), "changed other").unwrap(); + + let d = diff(dir.path(), Some(&["README.md"]), false, None).await.unwrap(); + assert!(d.diff.contains("Changed")); + // Should not contain other.txt changes + assert!(!d.diff.contains("changed other")); + } + + // ── commit tests ── + + #[tokio::test] + async fn test_commit_all_files() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("new.txt"), "new").unwrap(); + let c = commit(dir.path(), "add new file", None).await.unwrap(); + assert!(!c.sha.is_empty()); + assert_eq!(c.message, "add new file"); + assert!(c.files_changed >= 1); + } + + #[tokio::test] + async fn test_commit_specific_files() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("a.txt"), "a").unwrap(); + fs::write(dir.path().join("b.txt"), "b").unwrap(); + + let c = commit(dir.path(), "add a only", Some(&["a.txt"])).await.unwrap(); + assert!(!c.sha.is_empty()); + + // b.txt should still be untracked + let s = status(dir.path()).await.unwrap(); + assert!(s.untracked.iter().any(|f| f.path == "b.txt")); + } + + #[tokio::test] + async fn test_commit_sha_returned() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("file.txt"), "content").unwrap(); + let c = commit(dir.path(), "test sha", None).await.unwrap(); + // Short SHA should be 7+ chars + assert!(c.sha.len() >= 7); + } + + // ── branches tests ── + + #[tokio::test] + async fn test_branches_single() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let b = branches(dir.path()).await.unwrap(); + assert_eq!(b.len(), 1); + assert!(b[0].is_current); + } + + #[tokio::test] + async fn test_branches_created_branch_appears() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + create_branch(dir.path(), "feature", None).await.unwrap(); + // Switch back to verify both exist + // Switch back - try master first, then main + if switch_branch(dir.path(), "master").await.is_err() { + switch_branch(dir.path(), "main").await.unwrap(); + } + + let b = branches(dir.path()).await.unwrap(); + assert!(b.len() >= 2); + assert!(b.iter().any(|br| br.name == "feature")); + } + + // ── create/switch branch tests ── + + #[tokio::test] + async fn test_create_and_switch_branch() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + create_branch(dir.path(), "dev", None).await.unwrap(); + let s = status(dir.path()).await.unwrap(); + assert_eq!(s.branch, "dev"); + + // Switch back - try master first, then main + if switch_branch(dir.path(), "master").await.is_err() { + switch_branch(dir.path(), "main").await.unwrap(); + } + let s = status(dir.path()).await.unwrap(); + assert_ne!(s.branch, "dev"); + } + + // ── log tests ── + + #[tokio::test] + async fn test_log_entries() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("a.txt"), "a").unwrap(); + commit(dir.path(), "second commit", None).await.unwrap(); + + let entries = log(dir.path(), None).await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].message, "second commit"); + assert_eq!(entries[1].message, "initial"); + } + + #[tokio::test] + async fn test_log_with_limit() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + fs::write(dir.path().join("a.txt"), "a").unwrap(); + commit(dir.path(), "second", None).await.unwrap(); + fs::write(dir.path().join("b.txt"), "b").unwrap(); + commit(dir.path(), "third", None).await.unwrap(); + + let entries = log(dir.path(), Some(2)).await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].message, "third"); + } + + #[tokio::test] + async fn test_log_entries_have_fields() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let entries = log(dir.path(), None).await.unwrap(); + assert!(!entries[0].sha.is_empty()); + assert!(!entries[0].author.is_empty()); + assert!(!entries[0].date.is_empty()); + } + + // ── push test ── + + #[tokio::test] + async fn test_push_fails_without_remote() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let result = push(dir.path(), None, None).await; + assert!(result.is_err()); + } + + // ── parse_diff_stat tests ── + + #[test] + fn test_parse_diff_stat_full() { + let stat = " 2 files changed, 10 insertions(+), 3 deletions(-)"; + let (f, i, d) = parse_diff_stat(stat); + assert_eq!(f, 2); + assert_eq!(i, 10); + assert_eq!(d, 3); + } + + #[test] + fn test_parse_diff_stat_insertions_only() { + let stat = " 1 file changed, 5 insertions(+)"; + let (f, i, d) = parse_diff_stat(stat); + assert_eq!(f, 1); + assert_eq!(i, 5); + assert_eq!(d, 0); + } + + #[test] + fn test_parse_diff_stat_empty() { + let (f, i, d) = parse_diff_stat(""); + assert_eq!(f, 0); + assert_eq!(i, 0); + assert_eq!(d, 0); + } +} diff --git a/crates/git-ops/src/error.rs b/crates/git-ops/src/error.rs new file mode 100644 index 00000000..73a1d2ee --- /dev/null +++ b/crates/git-ops/src/error.rs @@ -0,0 +1,33 @@ +#[derive(Debug, thiserror::Error)] +pub enum GitOpsError { + #[error("git command failed (exit {exit_code}): {stderr}")] + GitCommand { + exit_code: i32, + stderr: String, + stdout: String, + }, + + #[error("git not found: {0}")] + GitNotFound(std::io::Error), + + #[error("invalid repo path: {0}")] + InvalidRepoPath(String), + + #[error("GitHub API error (status {status}): {body}")] + GitHubApi { status: u16, body: String }, + + #[error("no GitHub auth token found")] + NoAuthToken, + + #[error("invalid remote URL: {0}")] + InvalidRemoteUrl(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/git-ops/src/exec.rs b/crates/git-ops/src/exec.rs new file mode 100644 index 00000000..ba79af1f --- /dev/null +++ b/crates/git-ops/src/exec.rs @@ -0,0 +1,104 @@ +use std::path::Path; + +use tokio::process::Command; + +use crate::error::GitOpsError; +use crate::no_window::no_window_tokio; +use crate::types::GitOutput; + +/// Run a git command in the given repo directory. +/// Returns `Err(GitCommand)` on non-zero exit code. +/// Used by typed functions (core.rs) where failure is exceptional. +pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = run_git_inner(repo_path, args).await?; + if output.exit_code != 0 { + return Err(GitOpsError::GitCommand { + exit_code: output.exit_code, + stderr: output.stderr.clone(), + stdout: output.stdout.clone(), + }); + } + Ok(output) +} + +/// Run a git command in the given repo directory. +/// Always returns `Ok` with the exit code — even on non-zero exit. +/// Used by the agent git tool where non-zero exit is informational. +pub async fn run_git_raw(repo_path: &Path, args: &[&str]) -> Result { + run_git_inner(repo_path, args).await +} + +async fn run_git_inner(repo_path: &Path, args: &[&str]) -> Result { + if !repo_path.exists() { + return Err(GitOpsError::InvalidRepoPath( + repo_path.display().to_string(), + )); + } + + let mut cmd = Command::new("git"); + cmd.args(args) + .current_dir(repo_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + no_window_tokio(&mut cmd); + let output = cmd.output().await.map_err(GitOpsError::GitNotFound)?; + + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok(GitOutput { + exit_code, + stdout, + stderr, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use crate::test_util::init_repo; + + #[tokio::test] + async fn test_run_git_valid_repo() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let output = run_git(dir.path(), &["status"]).await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_run_git_invalid_path() { + let result = run_git(Path::new("/nonexistent/path"), &["status"]).await; + assert!(matches!(result, Err(GitOpsError::InvalidRepoPath(_)))); + } + + #[tokio::test] + async fn test_run_git_nonzero_exit_is_error() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + // checkout a nonexistent branch should fail + let result = run_git(dir.path(), &["checkout", "nonexistent-branch-xyz"]).await; + assert!(matches!(result, Err(GitOpsError::GitCommand { .. }))); + } + + #[tokio::test] + async fn test_run_git_raw_nonzero_exit_is_ok() { + let dir = tempdir().unwrap(); + init_repo(dir.path()).await; + + let output = run_git_raw(dir.path(), &["checkout", "nonexistent-branch-xyz"]) + .await + .unwrap(); + assert_ne!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_run_git_raw_invalid_path() { + let result = run_git_raw(Path::new("/nonexistent/path"), &["status"]).await; + assert!(matches!(result, Err(GitOpsError::InvalidRepoPath(_)))); + } +} diff --git a/crates/git-ops/src/ide.rs b/crates/git-ops/src/ide.rs new file mode 100644 index 00000000..9c4a2a70 --- /dev/null +++ b/crates/git-ops/src/ide.rs @@ -0,0 +1,89 @@ +use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; + +/// Cached IDE detection — runs `which cursor` at most once per process lifetime, +/// avoiding repeated blocking subprocess calls from the async runtime. +static DETECTED_IDE: OnceLock = OnceLock::new(); + +/// Detect the available IDE. Tries `cursor` first, falls back to `code`. +/// Result is cached after the first call. +fn detect_ide() -> &'static str { + DETECTED_IDE.get_or_init(|| { + if Command::new("which") + .arg("cursor") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + "cursor".to_string() + } else { + "code".to_string() + } + }) +} + +/// Open a file in the IDE. Fire-and-forget. +pub fn open_file(file_path: &Path, ide: Option<&str>) { + let ide = ide.unwrap_or_else(|| detect_ide()); + if let Err(e) = Command::new(ide).arg(file_path).spawn() { + log::debug!("Failed to open file in {ide}: {e}"); + } +} + +/// Open a diff between two files in the IDE. Fire-and-forget. +pub fn open_diff(file_a: &Path, file_b: &Path, ide: Option<&str>) { + let ide = ide.unwrap_or_else(|| detect_ide()); + if let Err(e) = Command::new(ide).arg("--diff").arg(file_a).arg(file_b).spawn() { + log::debug!("Failed to open diff in {ide}: {e}"); + } +} + +/// Open a project directory in the IDE. Fire-and-forget. +pub fn open_project(dir_path: &Path, ide: Option<&str>) { + let ide = ide.unwrap_or_else(|| detect_ide()); + if let Err(e) = Command::new(ide).arg(dir_path).spawn() { + log::debug!("Failed to open project in {ide}: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_ide_returns_something() { + let ide = detect_ide(); + assert!(ide == "cursor" || ide == "code", "Got: {ide}"); + } + + #[test] + fn test_detect_ide_is_cached() { + // Calling twice should return the same pointer (cached) + let first = detect_ide(); + let second = detect_ide(); + assert!(std::ptr::eq(first, second)); + } + + #[test] + #[ignore] // Launches a process + fn test_open_file() { + open_file(Path::new("/tmp/test.txt"), Some("echo")); + } + + #[test] + #[ignore] // Launches a process + fn test_open_diff() { + open_diff( + Path::new("/tmp/a.txt"), + Path::new("/tmp/b.txt"), + Some("echo"), + ); + } + + #[test] + #[ignore] // Launches a process + fn test_open_project() { + open_project(Path::new("/tmp"), Some("echo")); + } +} diff --git a/crates/git-ops/src/lib.rs b/crates/git-ops/src/lib.rs new file mode 100644 index 00000000..7b5e8b4f --- /dev/null +++ b/crates/git-ops/src/lib.rs @@ -0,0 +1,17 @@ +pub mod error; +pub mod types; +pub mod exec; +pub mod core; +pub mod worktree; +pub mod checkpoint; +pub mod pr; +pub mod ide; +pub mod no_window; + +pub use error::GitOpsError; +pub use types::*; +pub use core::*; +pub use worktree::{worktree_add, worktree_remove, worktree_prune, worktree_list, WorktreeEntry}; + +#[cfg(test)] +pub(crate) mod test_util; diff --git a/crates/git-ops/src/no_window.rs b/crates/git-ops/src/no_window.rs new file mode 100644 index 00000000..b625cf99 --- /dev/null +++ b/crates/git-ops/src/no_window.rs @@ -0,0 +1,33 @@ +//! Apply the Windows `CREATE_NO_WINDOW` flag to spawned child processes so the +//! Tauri GUI app doesn't flash a console window when running git/grep/bash etc. +//! No-op on macOS and Linux — code is `#[cfg(windows)]`-gated. + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +/// Apply `CREATE_NO_WINDOW` to a `tokio::process::Command` on Windows. +/// Returns the command for fluent chaining. +#[cfg(windows)] +pub fn no_window_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { + cmd.creation_flags(CREATE_NO_WINDOW) +} + +/// No-op on non-Windows platforms. +#[cfg(not(windows))] +pub fn no_window_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { + cmd +} + +/// Apply `CREATE_NO_WINDOW` to a `std::process::Command` on Windows. +/// Returns the command for fluent chaining. +#[cfg(windows)] +pub fn no_window_std(cmd: &mut std::process::Command) -> &mut std::process::Command { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW) +} + +/// No-op on non-Windows platforms. +#[cfg(not(windows))] +pub fn no_window_std(cmd: &mut std::process::Command) -> &mut std::process::Command { + cmd +} diff --git a/crates/git-ops/src/pr.rs b/crates/git-ops/src/pr.rs new file mode 100644 index 00000000..d659385e --- /dev/null +++ b/crates/git-ops/src/pr.rs @@ -0,0 +1,339 @@ +use std::path::Path; + +use crate::error::GitOpsError; +use crate::exec::run_git; +use crate::types::PrOutput; + +/// Parse a GitHub remote URL into (owner, repo). +/// Handles HTTPS and SSH formats: +/// - `https://github.com/owner/repo.git` +/// - `https://github.com/owner/repo` +/// - `git@github.com:owner/repo.git` +/// - `git@github.com:owner/repo` +pub fn parse_remote_url(url: &str) -> Result<(String, String), GitOpsError> { + let url = url.trim(); + + // SSH format: git@github.com:owner/repo.git + if let Some(rest) = url.strip_prefix("git@github.com:") { + let rest = rest.strip_suffix(".git").unwrap_or(rest); + let parts: Vec<&str> = rest.splitn(2, '/').collect(); + if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { + return Ok((parts[0].to_string(), parts[1].to_string())); + } + return Err(GitOpsError::InvalidRemoteUrl(url.to_string())); + } + + // HTTPS format: https://github.com/owner/repo.git + // Use strip_prefix for an exact match — a substring check would accept + // crafted URLs like "https://evil.com/redirect?to=github.com/owner/repo". + let https_prefix = url + .strip_prefix("https://github.com/") + .or_else(|| url.strip_prefix("http://github.com/")); + if let Some(after) = https_prefix { + let after = after.strip_suffix(".git").unwrap_or(after); + let parts: Vec<&str> = after.splitn(2, '/').collect(); + if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { + return Ok((parts[0].to_string(), parts[1].to_string())); + } + } + + Err(GitOpsError::InvalidRemoteUrl(url.to_string())) +} + +/// Resolve the `gh` CLI binary path. +/// GUI apps often don't inherit the user's shell PATH, so package-manager-installed +/// binaries like `gh` won't be found via a bare `Command::new("gh")`. +/// We check well-known install locations per platform before falling back to a bare name. +fn resolve_gh() -> String { + #[cfg(target_os = "windows")] + let candidates: &[&str] = &[ + r"C:\Program Files\GitHub CLI\gh.exe", + r"C:\Program Files (x86)\GitHub CLI\gh.exe", + ]; + + #[cfg(not(target_os = "windows"))] + let candidates: &[&str] = &[ + "/opt/homebrew/bin/gh", // macOS ARM Homebrew + "/usr/local/bin/gh", // macOS Intel Homebrew / Linux linuxbrew + "/usr/bin/gh", // Linux system package managers (apt, dnf, etc.) + "/snap/bin/gh", // Ubuntu snap + "/home/linuxbrew/.linuxbrew/bin/gh", // Linux Homebrew (default prefix) + ]; + + for path in candidates { + if std::path::Path::new(path).exists() { + return path.to_string(); + } + } + + // Also check user-local linuxbrew on Linux + #[cfg(target_os = "linux")] + if let Ok(home) = std::env::var("HOME") { + let linuxbrew = format!("{home}/.linuxbrew/bin/gh"); + if std::path::Path::new(&linuxbrew).exists() { + return linuxbrew; + } + } + + // Fallback — may work if PATH is set correctly (e.g. launched from terminal) + #[cfg(target_os = "windows")] + return "gh.exe".to_string(); + + #[cfg(not(target_os = "windows"))] + return "gh".to_string(); +} + +/// Get GitHub auth token. Tries: +/// 1. `GITHUB_TOKEN` environment variable +/// 2. `gh auth token` CLI command +pub async fn auth_token() -> Result { + // Try env var first + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + if !token.is_empty() { + return Ok(token); + } + } + + // Try gh CLI + let gh = resolve_gh(); + let mut cmd = tokio::process::Command::new(&gh); + cmd.args(["auth", "token"]); + crate::no_window::no_window_tokio(&mut cmd); + let output = cmd.output().await; + + match output { + Ok(out) if out.status.success() => { + let token = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !token.is_empty() { + return Ok(token); + } + Err(GitOpsError::NoAuthToken) + } + _ => Err(GitOpsError::NoAuthToken), + } +} + +/// Check whether a branch exists on the remote. +pub async fn branch_exists_on_remote( + repo_path: &Path, + branch: &str, + remote: &str, +) -> Result { + let output = run_git(repo_path, &["ls-remote", "--heads", remote, branch]).await?; + // ls-remote prints one line per matching ref; empty output means no match + Ok(!output.stdout.trim().is_empty()) +} + +/// Create a pull request on GitHub. +pub async fn create( + repo_path: &Path, + title: &str, + body: &str, + branch: &str, + base: &str, +) -> Result { + // Get remote URL + let remote_output = run_git(repo_path, &["remote", "get-url", "origin"]).await?; + let remote_url = remote_output.stdout.trim(); + + let (owner, repo) = parse_remote_url(remote_url)?; + + // Verify branch is pushed before making the API call + if !branch_exists_on_remote(repo_path, branch, "origin").await? { + return Err(GitOpsError::GitCommand { + exit_code: 1, + stderr: format!( + "Branch '{branch}' not found on remote 'origin'. Push it first with: git push -u origin {branch}" + ), + stdout: String::new(), + }); + } + + let token = auth_token().await?; + + let client = reqwest::Client::new(); + let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls"); + + let body_json = serde_json::json!({ + "title": title, + "body": body, + "head": branch, + "base": base, + }); + + let response = client + .post(&url) + .header("Authorization", format!("token {token}")) + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "git-ops") + .json(&body_json) + .send() + .await?; + + let status = response.status().as_u16(); + let response_body: serde_json::Value = response.json().await?; + + if status == 422 { + // PR already exists — try to find the existing PR URL + let existing = find_existing_pr(repo_path, branch, &owner, &repo, &token).await; + if let Some(pr) = existing { + return Ok(pr); + } + return Err(GitOpsError::GitHubApi { + status, + body: response_body.to_string(), + }); + } + + if status != 201 { + return Err(GitOpsError::GitHubApi { + status, + body: response_body.to_string(), + }); + } + + let pr_url = response_body["html_url"] + .as_str() + .unwrap_or("") + .to_string(); + let number = response_body["number"].as_u64().unwrap_or(0); + + Ok(PrOutput { + url: pr_url, + number, + }) +} + +/// Find an existing open PR for a branch. +async fn find_existing_pr( + _repo_path: &Path, + branch: &str, + owner: &str, + repo: &str, + token: &str, +) -> Option { + let client = reqwest::Client::new(); + let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls?head={owner}:{branch}&state=open"); + + let response = client + .get(&url) + .header("Authorization", format!("token {token}")) + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "git-ops") + .send() + .await + .ok()?; + + let prs: serde_json::Value = response.json().await.ok()?; + let first = prs.as_array()?.first()?; + + Some(PrOutput { + url: first["html_url"].as_str()?.to_string(), + number: first["number"].as_u64().unwrap_or(0), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Mutex to serialize tests that mutate the GITHUB_TOKEN env var. + /// Rust runs tests in parallel within a process, so concurrent + /// set_var / remove_var on the same env var causes data races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn test_parse_https_url() { + let (owner, repo) = parse_remote_url("https://github.com/acme/myrepo.git").unwrap(); + assert_eq!(owner, "acme"); + assert_eq!(repo, "myrepo"); + } + + #[test] + fn test_parse_https_url_no_git_suffix() { + let (owner, repo) = parse_remote_url("https://github.com/acme/myrepo").unwrap(); + assert_eq!(owner, "acme"); + assert_eq!(repo, "myrepo"); + } + + #[test] + fn test_parse_ssh_url() { + let (owner, repo) = parse_remote_url("git@github.com:acme/myrepo.git").unwrap(); + assert_eq!(owner, "acme"); + assert_eq!(repo, "myrepo"); + } + + #[test] + fn test_parse_ssh_url_no_git_suffix() { + let (owner, repo) = parse_remote_url("git@github.com:acme/myrepo").unwrap(); + assert_eq!(owner, "acme"); + assert_eq!(repo, "myrepo"); + } + + #[test] + fn test_parse_invalid_url() { + let result = parse_remote_url("not-a-url"); + assert!(matches!(result, Err(GitOpsError::InvalidRemoteUrl(_)))); + } + + #[test] + fn test_parse_empty_url() { + let result = parse_remote_url(""); + assert!(matches!(result, Err(GitOpsError::InvalidRemoteUrl(_)))); + } + + #[test] + fn test_parse_github_url_missing_repo() { + let result = parse_remote_url("https://github.com/acme/"); + assert!(matches!(result, Err(GitOpsError::InvalidRemoteUrl(_)))); + } + + #[test] + fn test_parse_spoofed_url_rejected() { + // A URL that merely contains "github.com/" as a substring should NOT match + let result = parse_remote_url("https://evil.com/redirect?to=github.com/owner/repo"); + assert!(matches!(result, Err(GitOpsError::InvalidRemoteUrl(_)))); + } + + #[test] + fn test_parse_subdomain_spoofed_url_rejected() { + let result = parse_remote_url("https://not-github.com/github.com/owner/repo"); + assert!(matches!(result, Err(GitOpsError::InvalidRemoteUrl(_)))); + } + + #[tokio::test] + async fn test_auth_token_from_env() { + // Hold mutex to prevent other tests from seeing our env var mutation + let _lock = ENV_MUTEX.lock().unwrap(); + + let original = std::env::var("GITHUB_TOKEN").ok(); + std::env::set_var("GITHUB_TOKEN", "test-token-123"); + + let token = auth_token().await.unwrap(); + assert_eq!(token, "test-token-123"); + + // Restore + match original { + Some(val) => std::env::set_var("GITHUB_TOKEN", val), + None => std::env::remove_var("GITHUB_TOKEN"), + } + } + + #[tokio::test] + async fn test_branch_exists_on_remote_no_remote() { + // In a repo with no remote, ls-remote should fail + let dir = tempfile::tempdir().unwrap(); + crate::test_util::init_repo_with_commit(dir.path()).await; + + let result = branch_exists_on_remote(dir.path(), "main", "origin").await; + // Should error because there's no remote named "origin" + assert!(result.is_err()); + } + + #[tokio::test] + #[ignore] // Requires real GitHub token and repo + async fn test_create_pr() { + // This test would need a real repo with a remote + } +} diff --git a/crates/git-ops/src/test_util.rs b/crates/git-ops/src/test_util.rs new file mode 100644 index 00000000..66d8eaff --- /dev/null +++ b/crates/git-ops/src/test_util.rs @@ -0,0 +1,41 @@ +use std::path::Path; +use std::fs; +use tokio::process::Command; + +pub async fn init_repo(dir: &Path) { + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .await + .unwrap(); +} + +pub async fn init_repo_with_commit(dir: &Path) { + init_repo(dir).await; + fs::write(dir.join("README.md"), "# Hello").unwrap(); + Command::new("git") + .args(["add", "-A"]) + .current_dir(dir) + .output() + .await + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(dir) + .output() + .await + .unwrap(); +} diff --git a/crates/git-ops/src/types.rs b/crates/git-ops/src/types.rs new file mode 100644 index 00000000..374ae9cf --- /dev/null +++ b/crates/git-ops/src/types.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +/// Raw output from a git command. +#[derive(Debug, Clone)] +pub struct GitOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// A file's status in git. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileStatus { + pub path: String, + /// One of "M", "A", "D", "R", "?" etc. + pub status: String, +} + +/// Output of `git status`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusOutput { + pub branch: String, + pub ahead: u32, + pub behind: u32, + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, + pub raw: String, +} + +/// Output of `git diff`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffOutput { + pub diff: String, + pub files_changed: u32, + pub insertions: u32, + pub deletions: u32, + pub stat: String, +} + +/// Output of `git commit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitOutput { + pub sha: String, + pub message: String, + pub files_changed: u32, +} + +/// Output of `git push`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushOutput { + pub remote: String, + pub branch: String, + pub raw: String, +} + +/// A git branch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchInfo { + pub name: String, + pub is_current: bool, + pub upstream: Option, +} + +/// A git log entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub sha: String, + pub message: String, + pub author: String, + pub date: String, +} + +/// Output of PR creation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrOutput { + pub url: String, + pub number: u64, +} diff --git a/crates/git-ops/src/worktree.rs b/crates/git-ops/src/worktree.rs new file mode 100644 index 00000000..1986a5cf --- /dev/null +++ b/crates/git-ops/src/worktree.rs @@ -0,0 +1,208 @@ +use std::path::Path; + +use crate::error::GitOpsError; +use crate::exec::run_git; + +/// Add a git worktree. +pub async fn worktree_add( + repo_path: &Path, + worktree_path: &Path, + branch: &str, + create_branch: bool, + start_point: Option<&str>, +) -> Result<(), GitOpsError> { + let worktree_str = worktree_path.display().to_string(); + let mut args = vec!["worktree", "add"]; + if create_branch { + args.extend_from_slice(&["-b", branch, &worktree_str]); + } else { + args.extend_from_slice(&[&worktree_str, branch]); + } + // Start point: create branch FROM this ref (e.g., "main", "develop") + if let Some(sp) = start_point { + args.push(sp); + } + run_git(repo_path, &args).await?; + Ok(()) +} + +/// Remove a git worktree. +pub async fn worktree_remove(repo_path: &Path, worktree_path: &Path) -> Result<(), GitOpsError> { + let worktree_str = worktree_path.display().to_string(); + run_git(repo_path, &["worktree", "remove", &worktree_str, "--force"]).await?; + Ok(()) +} + +/// A parsed entry from `git worktree list --porcelain`. +#[derive(Debug, Clone)] +pub struct WorktreeEntry { + pub path: std::path::PathBuf, + pub branch: Option, + pub bare: bool, +} + +/// List all git worktrees by parsing `git worktree list --porcelain`. +pub async fn worktree_list(repo_path: &Path) -> Result, GitOpsError> { + let output = run_git(repo_path, &["worktree", "list", "--porcelain"]).await?; + Ok(parse_worktree_porcelain(&output.stdout)) +} + +/// Parse porcelain output from `git worktree list --porcelain`. +fn parse_worktree_porcelain(output: &str) -> Vec { + let mut entries = Vec::new(); + let mut path: Option = None; + let mut branch: Option = None; + let mut bare = false; + + for line in output.lines() { + if let Some(p) = line.strip_prefix("worktree ") { + // Flush previous entry + if let Some(prev_path) = path.take() { + entries.push(WorktreeEntry { path: prev_path, branch: branch.take(), bare }); + bare = false; + } + path = Some(std::path::PathBuf::from(p)); + } else if let Some(b) = line.strip_prefix("branch refs/heads/") { + branch = Some(b.to_string()); + } else if line == "bare" { + bare = true; + } + // Skip HEAD, detached, prunable lines — we don't need them + } + // Flush last entry + if let Some(prev_path) = path { + entries.push(WorktreeEntry { path: prev_path, branch: branch.take(), bare }); + } + entries +} + +/// Prune stale worktree entries. +pub async fn worktree_prune(repo_path: &Path) -> Result<(), GitOpsError> { + run_git(repo_path, &["worktree", "prune"]).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + use tokio::process::Command; + use crate::test_util::init_repo_with_commit; + + #[tokio::test] + async fn test_worktree_add_new_branch() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let wt_path = dir.path().join("worktree-feature"); + worktree_add(dir.path(), &wt_path, "feature-wt", true, None) + .await + .unwrap(); + + // Worktree dir should exist + assert!(wt_path.exists()); + // Should have a README.md from the parent + assert!(wt_path.join("README.md").exists()); + } + + #[tokio::test] + async fn test_worktree_add_existing_branch() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + // Create a branch first + Command::new("git") + .args(["branch", "existing-branch"]) + .current_dir(dir.path()) + .output() + .await + .unwrap(); + + let wt_path = dir.path().join("worktree-existing"); + worktree_add(dir.path(), &wt_path, "existing-branch", false, None) + .await + .unwrap(); + + assert!(wt_path.exists()); + } + + #[tokio::test] + async fn test_worktree_remove() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let wt_path = dir.path().join("worktree-remove"); + worktree_add(dir.path(), &wt_path, "remove-branch", true, None) + .await + .unwrap(); + assert!(wt_path.exists()); + + worktree_remove(dir.path(), &wt_path).await.unwrap(); + assert!(!wt_path.exists()); + } + + #[test] + fn test_parse_worktree_porcelain() { + let output = "\ +worktree /Users/me/project +HEAD abc123 +branch refs/heads/main + +worktree /Users/me/project/.agent-worktrees/session-1 +HEAD def456 +branch refs/heads/agent/fix-bug-abc12345 + +worktree /Users/me/project/.agent-worktrees/session-2 +HEAD 789012 +detached + +"; + let entries = parse_worktree_porcelain(output); + assert_eq!(entries.len(), 3); + + assert_eq!(entries[0].path, std::path::PathBuf::from("/Users/me/project")); + assert_eq!(entries[0].branch.as_deref(), Some("main")); + assert!(!entries[0].bare); + + assert_eq!(entries[1].branch.as_deref(), Some("agent/fix-bug-abc12345")); + + // Detached worktree has no branch + assert!(entries[2].branch.is_none()); + } + + #[tokio::test] + async fn test_worktree_list() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let wt_path = dir.path().join("wt-list-test"); + worktree_add(dir.path(), &wt_path, "list-branch", true, None) + .await + .unwrap(); + + let entries = worktree_list(dir.path()).await.unwrap(); + // At least 2: the main repo + the worktree we created + assert!(entries.len() >= 2); + // macOS: /tmp is a symlink to /private/tmp, git canonicalizes paths + let wt_canon = wt_path.canonicalize().unwrap(); + assert!(entries.iter().any(|e| e.path == wt_canon)); + } + + #[tokio::test] + async fn test_worktree_prune_after_manual_delete() { + let dir = tempdir().unwrap(); + init_repo_with_commit(dir.path()).await; + + let wt_path = dir.path().join("worktree-prune"); + worktree_add(dir.path(), &wt_path, "prune-branch", true, None) + .await + .unwrap(); + + // Manually delete the worktree directory + fs::remove_dir_all(&wt_path).unwrap(); + + // Prune should succeed (cleans up stale entries) + worktree_prune(dir.path()).await.unwrap(); + } +} From 7300eb4f77029fef630caa0d72c2cd9441c87528 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Mon, 1 Jun 2026 18:53:37 +0530 Subject: [PATCH 04/26] Add LLM gateway service Lift the Go gateway (OpenAI-compatible API with Anthropic, OpenAI-compat, and OpenAI-responses provider adapters) into services/gateway, with a server entrypoint that builds the router and config from environment variables. Add the root Go module and Bazel build (rules_go + gazelle). --- .bazelversion | 1 + BUILD.bazel | 9 + MODULE.bazel | 18 + MODULE.bazel.lock | 557 +++++++++++++ go.mod | 54 ++ go.sum | 114 +++ services/gateway/cmd/server/BUILD.bazel | 24 + services/gateway/cmd/server/main.go | 72 ++ services/gateway/config/BUILD.bazel | 9 + services/gateway/config/config.go | 132 +++ services/gateway/controllers/BUILD.bazel | 39 + .../controllers/completions_controller.go | 232 ++++++ .../completions_controller_test.go | 185 +++++ .../gateway/controllers/health_controller.go | 21 + .../gateway/controllers/models_controller.go | 34 + .../controllers/models_controller_test.go | 75 ++ services/gateway/models/dto/BUILD.bazel | 23 + services/gateway/models/dto/anthropic.go | 157 ++++ services/gateway/models/dto/models.go | 15 + services/gateway/models/dto/openai.go | 153 ++++ .../gateway/models/dto/openai_responses.go | 123 +++ services/gateway/models/dto/openai_test.go | 66 ++ services/gateway/services/BUILD.bazel | 31 + services/gateway/services/impl/BUILD.bazel | 37 + .../services/impl/anthropic_adapter.go | 483 +++++++++++ .../services/impl/anthropic_adapter_test.go | 778 ++++++++++++++++++ .../gateway/services/impl/anthropic_stream.go | 286 +++++++ .../services/impl/anthropic_stream_test.go | 338 ++++++++ .../services/impl/openai_compat_adapter.go | 105 +++ .../impl/openai_compat_adapter_test.go | 130 +++ .../services/impl/openai_responses_adapter.go | 262 ++++++ .../impl/openai_responses_adapter_test.go | 433 ++++++++++ .../services/impl/openai_responses_stream.go | 266 ++++++ .../impl/openai_responses_stream_test.go | 220 +++++ services/gateway/services/provider_adapter.go | 17 + services/gateway/services/router.go | 91 ++ services/gateway/services/router_test.go | 140 ++++ services/gateway/services/sse_parser.go | 79 ++ services/gateway/services/sse_parser_test.go | 187 +++++ 39 files changed, 5996 insertions(+) create mode 100644 .bazelversion create mode 100644 BUILD.bazel create mode 100644 MODULE.bazel create mode 100644 MODULE.bazel.lock create mode 100644 go.mod create mode 100644 go.sum create mode 100644 services/gateway/cmd/server/BUILD.bazel create mode 100644 services/gateway/cmd/server/main.go create mode 100644 services/gateway/config/BUILD.bazel create mode 100644 services/gateway/config/config.go create mode 100644 services/gateway/controllers/BUILD.bazel create mode 100644 services/gateway/controllers/completions_controller.go create mode 100644 services/gateway/controllers/completions_controller_test.go create mode 100644 services/gateway/controllers/health_controller.go create mode 100644 services/gateway/controllers/models_controller.go create mode 100644 services/gateway/controllers/models_controller_test.go create mode 100644 services/gateway/models/dto/BUILD.bazel create mode 100644 services/gateway/models/dto/anthropic.go create mode 100644 services/gateway/models/dto/models.go create mode 100644 services/gateway/models/dto/openai.go create mode 100644 services/gateway/models/dto/openai_responses.go create mode 100644 services/gateway/models/dto/openai_test.go create mode 100644 services/gateway/services/BUILD.bazel create mode 100644 services/gateway/services/impl/BUILD.bazel create mode 100644 services/gateway/services/impl/anthropic_adapter.go create mode 100644 services/gateway/services/impl/anthropic_adapter_test.go create mode 100644 services/gateway/services/impl/anthropic_stream.go create mode 100644 services/gateway/services/impl/anthropic_stream_test.go create mode 100644 services/gateway/services/impl/openai_compat_adapter.go create mode 100644 services/gateway/services/impl/openai_compat_adapter_test.go create mode 100644 services/gateway/services/impl/openai_responses_adapter.go create mode 100644 services/gateway/services/impl/openai_responses_adapter_test.go create mode 100644 services/gateway/services/impl/openai_responses_stream.go create mode 100644 services/gateway/services/impl/openai_responses_stream_test.go create mode 100644 services/gateway/services/provider_adapter.go create mode 100644 services/gateway/services/router.go create mode 100644 services/gateway/services/router_test.go create mode 100644 services/gateway/services/sse_parser.go create mode 100644 services/gateway/services/sse_parser_test.go diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 00000000..6d289079 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.5.0 diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 00000000..8d7eccb4 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,9 @@ +load("@bazel_gazelle//:def.bzl", "gazelle") + +# gazelle:prefix github.com/TransformerOptimus/SuperCoder +# resty.dev/v3's library target is //:resty, not the version-suffix default //:v3 +# gazelle:resolve go resty.dev/v3 @dev_resty_v3//:resty +# gazelle:exclude v1 +# gazelle:exclude crates +# gazelle:exclude apps +gazelle(name = "gazelle") diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 00000000..ec3f6718 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,18 @@ +"""SuperCoder — Bazel module (Go side). Rust crates build with native cargo.""" + +module( + name = "supercoder", + version = "0.1.0", +) + +bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go") +bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle") + +go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk") +go_sdk.download(version = "1.25.6") + +go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps") +go_deps.from_file(go_mod = "//:go.mod") +use_repo(go_deps, "com_github_gin_gonic_gin", "com_github_knadh_koanf_providers_env", "com_github_knadh_koanf_v2", "com_github_stretchr_testify", "dev_resty_v3", "org_uber_go_zap") + +# use_repo(go_deps, ...) entries are managed by `bazel mod tidy`. diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock new file mode 100644 index 00000000..1af6a74c --- /dev/null +++ b/MODULE.bazel.lock @@ -0,0 +1,557 @@ +{ + "lockFileVersion": 24, + "registryFileHashes": { + "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", + "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", + "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", + "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", + "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", + "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", + "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", + "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", + "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc", + "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", + "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", + "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", + "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", + "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/source.json": "f121b43eeefc7c29efbd51b83d08631e2347297c95aac9764a701f2a6a2bb953", + "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", + "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0", + "https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc", + "https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6", + "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", + "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", + "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4", + "https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85", + "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", + "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", + "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", + "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", + "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", + "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", + "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", + "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", + "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2.bcr.1/MODULE.bazel": "52f4126f63a2f0bbf36b99c2a87648f08467a4eaf92ba726bc7d6a500bbf770c", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", + "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", + "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", + "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", + "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", + "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", + "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", + "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", + "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", + "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", + "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", + "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", + "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", + "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", + "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/source.json": "4bb4fed7f5499775d495739f785a5494a1f854645fa1bac5de131264f5acdf01", + "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", + "https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a", + "https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae", + "https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6", + "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", + "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", + "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", + "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", + "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", + "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", + "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", + "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", + "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", + "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", + "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", + "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", + "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", + "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", + "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", + "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", + "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", + "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", + "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", + "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", + "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", + "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", + "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", + "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3", + "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", + "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", + "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", + "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", + "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", + "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", + "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" + }, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { + "general": { + "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", + "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_github_jetbrains_kotlin_git": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", + "attributes": { + "urls": [ + "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" + ], + "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" + } + }, + "com_github_jetbrains_kotlin": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", + "attributes": { + "git_repository_name": "com_github_jetbrains_kotlin_git", + "compiler_version": "1.9.23" + } + }, + "com_github_google_ksp": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", + "attributes": { + "urls": [ + "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" + ], + "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", + "strip_version": "1.9.23-1.0.20" + } + }, + "com_github_pinterest_ktlint": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", + "urls": [ + "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" + ], + "executable": true + } + }, + "rules_android": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", + "strip_prefix": "rules_android-0.1.1", + "urls": [ + "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_kotlin+", + "bazel_tools", + "bazel_tools" + ] + ] + } + } + }, + "facts": { + "@@rules_go+//go:extensions.bzl%go_sdk": { + "1.25.0": { + "aix_ppc64": [ + "go1.25.0.aix-ppc64.tar.gz", + "e5234a7dac67bc86c528fe9752fc9d63557918627707a733ab4cac1a6faed2d4" + ], + "darwin_amd64": [ + "go1.25.0.darwin-amd64.tar.gz", + "5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef" + ], + "darwin_arm64": [ + "go1.25.0.darwin-arm64.tar.gz", + "544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c" + ], + "dragonfly_amd64": [ + "go1.25.0.dragonfly-amd64.tar.gz", + "5ed3cf9a810a1483822538674f1336c06b51aa1b94d6d545a1a0319a48177120" + ], + "freebsd_386": [ + "go1.25.0.freebsd-386.tar.gz", + "abea5d5c6697e6b5c224731f2158fe87c602996a2a233ac0c4730cd57bf8374e" + ], + "freebsd_amd64": [ + "go1.25.0.freebsd-amd64.tar.gz", + "86e6fe0a29698d7601c4442052dac48bd58d532c51cccb8f1917df648138730b" + ], + "freebsd_arm": [ + "go1.25.0.freebsd-arm.tar.gz", + "d90b78e41921f72f30e8bbc81d9dec2cff7ff384a33d8d8debb24053e4336bfe" + ], + "freebsd_arm64": [ + "go1.25.0.freebsd-arm64.tar.gz", + "451d0da1affd886bfb291b7c63a6018527b269505db21ce6e14724f22ab0662e" + ], + "freebsd_riscv64": [ + "go1.25.0.freebsd-riscv64.tar.gz", + "7b565f76bd8bda46549eeaaefe0e53b251e644c230577290c0f66b1ecdb3cdbe" + ], + "illumos_amd64": [ + "go1.25.0.illumos-amd64.tar.gz", + "b1e1fdaab1ad25aa1c08d7a36c97d45d74b98b89c3f78c6d2145f77face54a2c" + ], + "linux_386": [ + "go1.25.0.linux-386.tar.gz", + "8c602dd9d99bc9453b3995d20ce4baf382cc50855900a0ece5de9929df4a993a" + ], + "linux_amd64": [ + "go1.25.0.linux-amd64.tar.gz", + "2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613" + ], + "linux_arm64": [ + "go1.25.0.linux-arm64.tar.gz", + "05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae" + ], + "linux_armv6l": [ + "go1.25.0.linux-armv6l.tar.gz", + "a5a8f8198fcf00e1e485b8ecef9ee020778bf32a408a4e8873371bfce458cd09" + ], + "linux_loong64": [ + "go1.25.0.linux-loong64.tar.gz", + "cab86b1cf761b1cb3bac86a8877cfc92e7b036fc0d3084123d77013d61432afc" + ], + "linux_mips": [ + "go1.25.0.linux-mips.tar.gz", + "d66b6fb74c3d91b9829dc95ec10ca1f047ef5e89332152f92e136cf0e2da5be1" + ], + "linux_mips64": [ + "go1.25.0.linux-mips64.tar.gz", + "4082e4381a8661bc2a839ff94ba3daf4f6cde20f8fb771b5b3d4762dc84198a2" + ], + "linux_mips64le": [ + "go1.25.0.linux-mips64le.tar.gz", + "70002c299ec7f7175ac2ef673b1b347eecfa54ae11f34416a6053c17f855afcc" + ], + "linux_mipsle": [ + "go1.25.0.linux-mipsle.tar.gz", + "b00a3a39eff099f6df9f1c7355bf28e4589d0586f42d7d4a394efb763d145a73" + ], + "linux_ppc64": [ + "go1.25.0.linux-ppc64.tar.gz", + "df166f33bd98160662560a72ff0b4ba731f969a80f088922bddcf566a88c1ec1" + ], + "linux_ppc64le": [ + "go1.25.0.linux-ppc64le.tar.gz", + "0f18a89e7576cf2c5fa0b487a1635d9bcbf843df5f110e9982c64df52a983ad0" + ], + "linux_riscv64": [ + "go1.25.0.linux-riscv64.tar.gz", + "c018ff74a2c48d55c8ca9b07c8e24163558ffec8bea08b326d6336905d956b67" + ], + "linux_s390x": [ + "go1.25.0.linux-s390x.tar.gz", + "34e5a2e19f2292fbaf8783e3a241e6e49689276aef6510a8060ea5ef54eee408" + ], + "netbsd_386": [ + "go1.25.0.netbsd-386.tar.gz", + "f8586cdb7aa855657609a5c5f6dbf523efa00c2bbd7c76d3936bec80aa6c0aba" + ], + "netbsd_amd64": [ + "go1.25.0.netbsd-amd64.tar.gz", + "ae8dc1469385b86a157a423bb56304ba45730de8a897615874f57dd096db2c2a" + ], + "netbsd_arm": [ + "go1.25.0.netbsd-arm.tar.gz", + "1ff7e4cc764425fc9dd6825eaee79d02b3c7cafffbb3691687c8d672ade76cb7" + ], + "netbsd_arm64": [ + "go1.25.0.netbsd-arm64.tar.gz", + "e1b310739f26724216aa6d7d7208c4031f9ff54c9b5b9a796ddc8bebcb4a5f16" + ], + "openbsd_386": [ + "go1.25.0.openbsd-386.tar.gz", + "4802a9b20e533da91adb84aab42e94aa56cfe3e5475d0550bed3385b182e69d8" + ], + "openbsd_amd64": [ + "go1.25.0.openbsd-amd64.tar.gz", + "c016cd984bebe317b19a4f297c4f50def120dc9788490540c89f28e42f1dabe1" + ], + "openbsd_arm": [ + "go1.25.0.openbsd-arm.tar.gz", + "a1e31d0bf22172ddde42edf5ec811ef81be43433df0948ece52fecb247ccfd8d" + ], + "openbsd_arm64": [ + "go1.25.0.openbsd-arm64.tar.gz", + "343ea8edd8c218196e15a859c6072d0dd3246fbbb168481ab665eb4c4140458d" + ], + "openbsd_ppc64": [ + "go1.25.0.openbsd-ppc64.tar.gz", + "694c14da1bcaeb5e3332d49bdc2b6d155067648f8fe1540c5de8f3cf8e157154" + ], + "openbsd_riscv64": [ + "go1.25.0.openbsd-riscv64.tar.gz", + "aa510ad25cf54c06cd9c70b6d80ded69cb20188ac6e1735655eef29ff7e7885f" + ], + "plan9_386": [ + "go1.25.0.plan9-386.tar.gz", + "46f8cef02086cf04bf186c5912776b56535178d4cb319cd19c9fdbdd29231986" + ], + "plan9_amd64": [ + "go1.25.0.plan9-amd64.tar.gz", + "29b34391d84095e44608a228f63f2f88113a37b74a79781353ec043dfbcb427b" + ], + "plan9_arm": [ + "go1.25.0.plan9-arm.tar.gz", + "0a047107d13ebe7943aaa6d54b1d7bbd2e45e68ce449b52915a818da715799c2" + ], + "solaris_amd64": [ + "go1.25.0.solaris-amd64.tar.gz", + "9977f9e4351984364a3b2b78f8b88bfd1d339812356d5237678514594b7d3611" + ], + "windows_386": [ + "go1.25.0.windows-386.zip", + "df9f39db82a803af0db639e3613a36681ab7a42866b1384b3f3a1045663961a7" + ], + "windows_amd64": [ + "go1.25.0.windows-amd64.zip", + "89efb4f9b30812eee083cc1770fdd2913c14d301064f6454851428f9707d190b" + ], + "windows_arm64": [ + "go1.25.0.windows-arm64.zip", + "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c" + ] + }, + "1.25.6": { + "aix_ppc64": [ + "go1.25.6.aix-ppc64.tar.gz", + "13c8bca505dd902091304da8abfacaf3512f40c3faefae70db33337d9a42c90e" + ], + "darwin_amd64": [ + "go1.25.6.darwin-amd64.tar.gz", + "e2b5b237f5c262931b8e280ac4b8363f156e19bfad5270c099998932819670b7" + ], + "darwin_arm64": [ + "go1.25.6.darwin-arm64.tar.gz", + "984521ae978a5377c7d782fd2dd953291840d7d3d0bd95781a1f32f16d94a006" + ], + "dragonfly_amd64": [ + "go1.25.6.dragonfly-amd64.tar.gz", + "6fdcdd4f769fe73a9c5602eb25533954903520f2a2a1953415ec4f8abf5bda52" + ], + "freebsd_386": [ + "go1.25.6.freebsd-386.tar.gz", + "be22b65ded1d4015d7d9d328284c985932771d120a371c7df41b2d4d1a91e943" + ], + "freebsd_amd64": [ + "go1.25.6.freebsd-amd64.tar.gz", + "61e1d50e332359474ff6dcf4bc0bd34ba2d2cf4ef649593a5faa527f0ab84e2b" + ], + "freebsd_arm": [ + "go1.25.6.freebsd-arm.tar.gz", + "546c2c6e325e72531bf6c8122a2360db8f8381e2dc1e8d147ecb0cb49b5f5f93" + ], + "freebsd_arm64": [ + "go1.25.6.freebsd-arm64.tar.gz", + "648484146702dd58db0e2c3d15bda3560340d149ed574936e63285a823116b77" + ], + "freebsd_riscv64": [ + "go1.25.6.freebsd-riscv64.tar.gz", + "663d7a9532bb4ac03c7a36b13b677b36d71031cd757b8acaee085e36c9ec8bc2" + ], + "illumos_amd64": [ + "go1.25.6.illumos-amd64.tar.gz", + "c6adb151f8f50a25ef5a3f7b1be67155045daa766261e686ea210b93b46bbbd5" + ], + "linux_386": [ + "go1.25.6.linux-386.tar.gz", + "59fe62eee3cca65332acef3ebe9b6ff3272467e0a08bf7f68f96334902bf23b9" + ], + "linux_amd64": [ + "go1.25.6.linux-amd64.tar.gz", + "f022b6aad78e362bcba9b0b94d09ad58c5a70c6ba3b7582905fababf5fe0181a" + ], + "linux_arm64": [ + "go1.25.6.linux-arm64.tar.gz", + "738ef87d79c34272424ccdf83302b7b0300b8b096ed443896089306117943dd5" + ], + "linux_armv6l": [ + "go1.25.6.linux-armv6l.tar.gz", + "679f0e70b27c637116791e3c98afbf8c954deb2cd336364944d014f8e440e2ae" + ], + "linux_loong64": [ + "go1.25.6.linux-loong64.tar.gz", + "433fe54d8797700b44fc4f1d085f9cd50ab3511b9b484fdfbb7b6c32a2be2486" + ], + "linux_mips": [ + "go1.25.6.linux-mips.tar.gz", + "a5beaf2d135b8e9a2f3d91fa7e7d3761ffc97630484168bbc9a21f3901119c11" + ], + "linux_mips64": [ + "go1.25.6.linux-mips64.tar.gz", + "f2d72c1ac315d453f429f48900f43cd8d0aa296a2b82fa90dba7dfb907483fd8" + ], + "linux_mips64le": [ + "go1.25.6.linux-mips64le.tar.gz", + "9b808ef978fd6414edd16736daa4a601c7e2dadff3bd640ade8a976535c974d4" + ], + "linux_mipsle": [ + "go1.25.6.linux-mipsle.tar.gz", + "4e0b190b05c8359455d96d379c751d403554dcadf6765932845b2886e555bfd6" + ], + "linux_ppc64": [ + "go1.25.6.linux-ppc64.tar.gz", + "5d0f479023b1481c9188cc066eca1293e6f8a67a882a6d93afafccfb51981476" + ], + "linux_ppc64le": [ + "go1.25.6.linux-ppc64le.tar.gz", + "bee02dbe034b12b839ae7807a85a61c13bee09ee38f2eeba2074bd26c0c0ab73" + ], + "linux_riscv64": [ + "go1.25.6.linux-riscv64.tar.gz", + "82a6b989afda1681ecb1f4fa96f1006484f42643eb5e76bed58f7f97316bf84b" + ], + "linux_s390x": [ + "go1.25.6.linux-s390x.tar.gz", + "3d97cc5670a0da9cb177037782129f0bf499ecb47abc40488248548abd2c2c35" + ], + "netbsd_386": [ + "go1.25.6.netbsd-386.tar.gz", + "eb526fff2568fc9938d6eda6f0f50449661c693fcd89ab6f84e5e77e0a98d99b" + ], + "netbsd_amd64": [ + "go1.25.6.netbsd-amd64.tar.gz", + "959d786e3384403ac9d957c04d71da905b02f457406ca123662cbd4688f9ce6e" + ], + "netbsd_arm": [ + "go1.25.6.netbsd-arm.tar.gz", + "fe6c3957f7feaf17ac72ca27590cc4914c19162fc0912869048cb3dc92f5c3fd" + ], + "netbsd_arm64": [ + "go1.25.6.netbsd-arm64.tar.gz", + "ddb5ec67fc4a0510b23560b7c01413bd9dde513cebfb5441a93e934f7e0c6853" + ], + "openbsd_386": [ + "go1.25.6.openbsd-386.tar.gz", + "167a18ff7db53f1652f3a65c905056bc14e7ab4319357498d0af998a83f457a9" + ], + "openbsd_amd64": [ + "go1.25.6.openbsd-amd64.tar.gz", + "06ec42383ff1e17abc0472e0a92eb028cb40b16ea09e2a86f80fbe60912d62de" + ], + "openbsd_arm": [ + "go1.25.6.openbsd-arm.tar.gz", + "751df8eadd0f3d7be8ea6cda3af1e2e942099f6c97abcc0cfb5c8a0ac8e0cf3f" + ], + "openbsd_arm64": [ + "go1.25.6.openbsd-arm64.tar.gz", + "d9828a6162c0c0fdb2d7e9dc8285c43b18a3dab62bf5e83b5891a4384f3157ad" + ], + "openbsd_ppc64": [ + "go1.25.6.openbsd-ppc64.tar.gz", + "73090f93dc861f2be9dc06d8209f32cd7ce7864b9b3e28f0cd54a9e031672699" + ], + "openbsd_riscv64": [ + "go1.25.6.openbsd-riscv64.tar.gz", + "6d4932cb639c1172cf5861b031bd0a24f7341ef579aac15b392779e10c69343b" + ], + "plan9_386": [ + "go1.25.6.plan9-386.tar.gz", + "b9db67922a94abe580e7bde9172eee2c223ade914cd12790d955a24554c134d5" + ], + "plan9_amd64": [ + "go1.25.6.plan9-amd64.tar.gz", + "aa1ff9aa3e1ed09ecb21d09d736997d2de9f373fea9402815b3221946d17dcd5" + ], + "plan9_arm": [ + "go1.25.6.plan9-arm.tar.gz", + "94ec04501527876a542960096f0199495cbd9f9103b229d5299382aa51d9cc32" + ], + "solaris_amd64": [ + "go1.25.6.solaris-amd64.tar.gz", + "9a1e89979be591b44e63be766c6571f5dc27b5fc3b79965c943186fcdaca0386" + ], + "windows_386": [ + "go1.25.6.windows-386.zip", + "873da5cec02b6657ecd5b85e562a38fb5faf1b6e9ea81b2eb0b9a9b5aea5cb35" + ], + "windows_amd64": [ + "go1.25.6.windows-amd64.zip", + "19b4733b727ba5c611b5656187f3ac367d278d64c3d4199a845e39c0fdac5335" + ], + "windows_arm64": [ + "go1.25.6.windows-arm64.zip", + "8f2d8e6dd0849a2ec0ade1683bcfb7809e64d264a4273d8437841000a28ffb60" + ] + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..1ba2a5c1 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module github.com/TransformerOptimus/SuperCoder + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/knadh/koanf/providers/env v1.0.0 + github.com/knadh/koanf/v2 v2.1.2 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.27.1 + resty.dev/v3 v3.0.0-beta.6 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..6a17601b --- /dev/null +++ b/go.sum @@ -0,0 +1,114 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0= +github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU= +resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/services/gateway/cmd/server/BUILD.bazel b/services/gateway/cmd/server/BUILD.bazel new file mode 100644 index 00000000..c0453734 --- /dev/null +++ b/services/gateway/cmd/server/BUILD.bazel @@ -0,0 +1,24 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "server_lib", + srcs = ["main.go"], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/cmd/server", + visibility = ["//visibility:private"], + deps = [ + "//services/gateway/config", + "//services/gateway/controllers", + "//services/gateway/services", + "//services/gateway/services/impl", + "@com_github_gin_gonic_gin//:gin", + "@com_github_knadh_koanf_providers_env//:env", + "@com_github_knadh_koanf_v2//:koanf", + "@org_uber_go_zap//:zap", + ], +) + +go_binary( + name = "server", + embed = [":server_lib"], + visibility = ["//visibility:public"], +) diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go new file mode 100644 index 00000000..f239e8f4 --- /dev/null +++ b/services/gateway/cmd/server/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "log" + "os" + "strings" + + "github.com/gin-gonic/gin" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/config" + "github.com/TransformerOptimus/SuperCoder/services/gateway/controllers" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services/impl" +) + +func main() { + logger, err := zap.NewProduction() + if err != nil { + log.Fatalf("failed to init logger: %v", err) + } + defer logger.Sync() + + // Config from env vars: GW_ where "__" maps to ".". + // e.g. GW_gateway__defaults__provider=anthropic + // GW_gateway__providers__anthropic__adapter=anthropic + // GW_gateway__providers__anthropic__base__url=https://api.anthropic.com + // GW_gateway__providers__anthropic__api__key=sk-... + k := koanf.New(".") + if err := k.Load(env.Provider("GW_", ".", func(s string) string { + return strings.ReplaceAll(strings.TrimPrefix(s, "GW_"), "__", ".") + }), nil); err != nil { + logger.Fatal("failed to load config from env", zap.Error(err)) + } + cfg := config.NewGatewayConfig(k) + + // Build the model→provider router from configured providers. + router := services.NewRouter(logger, cfg.DefaultProvider()) + for _, p := range cfg.Providers() { + var adapter services.ProviderAdapter + switch p.Adapter { + case "anthropic": + adapter = impl.NewAnthropicAdapter(p.BaseURL, p.Version, p.DefaultMaxTokens, p.APIKey, logger) + case "openai-responses": + adapter = impl.NewOpenAIResponsesAdapter(p.BaseURL, p.APIKey, logger) + default: // "openai-compat" and unknown adapters + adapter = impl.NewOpenAICompatAdapter(p.Name, p.BaseURL, p.APIKey, p.Models, logger) + } + router.RegisterProvider(p.Name, adapter, p.Models) + } + + health := controllers.NewGatewayHealthController() + completions := controllers.NewCompletionsController(router, logger) + models := controllers.NewModelsController(cfg) + + r := gin.New() + r.Use(gin.Recovery()) + r.GET("/api/health", health.Health) + r.POST("/api/v1/chat/completions", completions.HandleCompletion) + r.GET("/api/v1/models", models.ListModels) + + addr := os.Getenv("GATEWAY_ADDR") + if addr == "" { + addr = ":8080" + } + logger.Info("gateway listening", zap.String("addr", addr)) + if err := r.Run(addr); err != nil { + logger.Fatal("server exited", zap.Error(err)) + } +} diff --git a/services/gateway/config/BUILD.bazel b/services/gateway/config/BUILD.bazel new file mode 100644 index 00000000..66060cb5 --- /dev/null +++ b/services/gateway/config/BUILD.bazel @@ -0,0 +1,9 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "config", + srcs = ["config.go"], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/config", + visibility = ["//visibility:public"], + deps = ["@com_github_knadh_koanf_v2//:koanf"], +) diff --git a/services/gateway/config/config.go b/services/gateway/config/config.go new file mode 100644 index 00000000..225c5ebe --- /dev/null +++ b/services/gateway/config/config.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/knadh/koanf/v2" +) + +// ModelEntry represents a model available through the gateway. +type ModelEntry struct { + ID string + DisplayName string + Provider string + ContextWindow int + SupportsImages bool +} + +// GatewayConfig provides gateway configuration via Koanf. +type GatewayConfig interface { + DefaultProvider() string + Providers() []ProviderEntry + Models() []ModelEntry +} + +// ProviderEntry represents a configured LLM provider. +type ProviderEntry struct { + Name string + Adapter string + BaseURL string + Models []string + Version string + DefaultMaxTokens int + APIKey string +} + +type gatewayConfigImpl struct { + config *koanf.Koanf +} + +func NewGatewayConfig(config *koanf.Koanf) GatewayConfig { + return &gatewayConfigImpl{config: config} +} + +func (c *gatewayConfigImpl) DefaultProvider() string { + return c.config.String("gateway.defaults.provider") +} + +func (c *gatewayConfigImpl) Models() []ModelEntry { + raw := c.config.Get("gateway.models") + if raw == nil { + return nil + } + + items, ok := raw.([]map[string]any) + if !ok { + // Try []any (koanf sometimes returns this type) + rawSlice, ok2 := raw.([]any) + if !ok2 { + return nil + } + var entries []ModelEntry + for _, item := range rawSlice { + if m, ok := item.(map[string]any); ok { + entries = append(entries, parseModelEntry(m)) + } + } + return entries + } + + entries := make([]ModelEntry, 0, len(items)) + for _, m := range items { + entries = append(entries, parseModelEntry(m)) + } + return entries +} + +func parseModelEntry(m map[string]any) ModelEntry { + entry := ModelEntry{} + if v, ok := m["id"].(string); ok { + entry.ID = v + } + if v, ok := m["provider"].(string); ok { + entry.Provider = v + } + if display, ok := m["display"].(map[string]any); ok { + if v, ok := display["name"].(string); ok { + entry.DisplayName = v + } + } + if ctx, ok := m["context"].(map[string]any); ok { + if v, ok := ctx["window"].(int); ok { + entry.ContextWindow = v + } + } + if v, ok := m["supports_images"].(bool); ok { + entry.SupportsImages = v + } + return entry +} + +func (c *gatewayConfigImpl) Providers() []ProviderEntry { + var entries []ProviderEntry + + // Extract unique provider names from flat koanf keys like "gateway.providers.anthropic.adapter" + const prefix = "gateway.providers." + seen := make(map[string]bool) + for _, key := range c.config.Keys() { + if !strings.HasPrefix(key, prefix) { + continue + } + rest := strings.TrimPrefix(key, prefix) + name, _, _ := strings.Cut(rest, ".") + if name == "" || seen[name] { + continue + } + seen[name] = true + + p := fmt.Sprintf("gateway.providers.%s", name) + entry := ProviderEntry{ + Name: name, + Adapter: c.config.String(p + ".adapter"), + BaseURL: c.config.String(p + ".base.url"), + Models: c.config.Strings(p + ".models"), + Version: c.config.String(p + ".version"), + DefaultMaxTokens: c.config.Int(p + ".default.max.tokens"), + APIKey: c.config.String(p + ".api.key"), + } + entries = append(entries, entry) + } + return entries +} diff --git a/services/gateway/controllers/BUILD.bazel b/services/gateway/controllers/BUILD.bazel new file mode 100644 index 00000000..bfe20a2f --- /dev/null +++ b/services/gateway/controllers/BUILD.bazel @@ -0,0 +1,39 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "controllers", + srcs = [ + "completions_controller.go", + "health_controller.go", + "models_controller.go", + ], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/controllers", + visibility = ["//visibility:public"], + deps = [ + "//services/gateway/config", + "//services/gateway/models/dto", + "//services/gateway/services", + "@com_github_gin_gonic_gin//:gin", + "@dev_resty_v3//:resty", + "@org_uber_go_zap//:zap", + ], +) + +go_test( + name = "controllers_test", + srcs = [ + "completions_controller_test.go", + "models_controller_test.go", + ], + embed = [":controllers"], + deps = [ + "//services/gateway/config", + "//services/gateway/models/dto", + "//services/gateway/services", + "//services/gateway/services/impl", + "@com_github_gin_gonic_gin//:gin", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@org_uber_go_zap//:zap", + ], +) diff --git a/services/gateway/controllers/completions_controller.go b/services/gateway/controllers/completions_controller.go new file mode 100644 index 00000000..d0ecd0ec --- /dev/null +++ b/services/gateway/controllers/completions_controller.go @@ -0,0 +1,232 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "resty.dev/v3" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services" +) + +// CompletionsController handles POST /v1/chat/completions. +type CompletionsController struct { + router *services.Router + client *resty.Client + logger *zap.Logger +} + +func NewCompletionsController(router *services.Router, logger *zap.Logger) *CompletionsController { + client := resty.New(). + SetDoNotParseResponse(true) + + return &CompletionsController{ + router: router, + client: client, + logger: logger.Named("controllers.completions"), + } +} + +func (ctrl *CompletionsController) HandleCompletion(c *gin.Context) { + var req dto.ChatCompletionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: "invalid request body: " + err.Error(), + Type: "invalid_request_error", + }, + }) + return + } + + if req.Model == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: "model is required", + Type: "invalid_request_error", + }, + }) + return + } + + // Route to provider adapter + providerOverride := c.GetHeader("X-Provider") + adapter, err := ctrl.router.Route(req.Model, providerOverride) + if err != nil { + ctrl.logger.Error("routing failed", zap.Error(err), zap.String("model", req.Model)) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: err.Error(), + Type: "invalid_request_error", + }, + }) + return + } + + // Resolve API key: server-side config takes priority, then caller header + apiKey := adapter.ConfiguredAPIKey() + if apiKey == "" { + apiKey = extractAPIKey(c.GetHeader("Authorization")) + } + if apiKey == "" { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: "missing API key: provide Authorization header or configure server-side key", + Type: "authentication_error", + }, + }) + return + } + + ctrl.logger.Info("gateway request", + zap.String("model", req.Model), + zap.String("provider", adapter.Name()), + zap.String("user_id", c.GetHeader("X-USER-ID")), + zap.String("workspace_id", c.GetHeader("X-Workspace-ID")), + ) + + // Translate request + url, headers, body, err := adapter.TranslateRequest(&req, apiKey) + if err != nil { + ctrl.logger.Error("translation failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: "failed to translate request: " + err.Error(), + Type: "server_error", + }, + }) + return + } + + // Make provider request + providerReq := ctrl.client.R(). + SetContext(c.Request.Context()). + SetBody(body) + + for k, v := range headers { + providerReq.SetHeader(k, v) + } + + resp, err := providerReq.Post(url) + if err != nil { + ctrl.logger.Error("provider request failed", zap.Error(err), zap.String("url", sanitizeURL(url))) + c.JSON(http.StatusBadGateway, dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: "provider request failed: " + err.Error(), + Type: "server_error", + }, + }) + return + } + defer resp.Body.Close() + + // Check for error status + if resp.StatusCode() >= 400 { + ctrl.handleProviderError(c, resp) + return + } + + // Stream SSE response + ctrl.streamResponse(c, adapter, resp) +} + +func (ctrl *CompletionsController) handleProviderError(c *gin.Context, resp *resty.Response) { + var errBody json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { + c.JSON(resp.StatusCode(), dto.ErrorResponse{ + Error: dto.ErrorDetail{ + Message: fmt.Sprintf("provider returned status %d", resp.StatusCode()), + Type: "server_error", + }, + }) + return + } + + c.Data(resp.StatusCode(), "application/json", errBody) +} + +func (ctrl *CompletionsController) streamResponse(c *gin.Context, adapter services.ProviderAdapter, resp *resty.Response) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + chunks := make(chan *dto.ChatCompletionChunk, 32) + + go func() { + defer func() { + if r := recover(); r != nil { + ctrl.logger.Error("stream goroutine panic", zap.Any("panic", r)) + } + }() + if err := adapter.TranslateStream(c.Request.Context(), resp.Body, chunks); err != nil { + if !strings.Contains(err.Error(), "response body closed") { + ctrl.logger.Error("stream translation error", zap.Error(err)) + } + } + }() + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + +loop: + for { + select { + case chunk, ok := <-chunks: + if !ok { + break loop + } + if chunk == nil { + fmt.Fprint(c.Writer, "data: [DONE]\n\n") + c.Writer.Flush() + break loop + } + if chunk.Object == "ping" { + fmt.Fprint(c.Writer, ": ping\n\n") + c.Writer.Flush() + ticker.Reset(15 * time.Second) + continue + } + + data, err := json.Marshal(chunk) + if err != nil { + ctrl.logger.Error("failed to marshal chunk", zap.Error(err)) + break loop + } + + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + ticker.Reset(15 * time.Second) + case <-ticker.C: + fmt.Fprint(c.Writer, ": keepalive\n\n") + c.Writer.Flush() + } + } +} + +func extractAPIKey(header string) string { + if strings.HasPrefix(header, "Bearer ") { + return strings.TrimPrefix(header, "Bearer ") + } + return header +} + +// sanitizeURL strips query params and fragment from a URL before logging +// to avoid leaking auth tokens or sensitive parameters. +func sanitizeURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/services/gateway/controllers/completions_controller_test.go b/services/gateway/controllers/completions_controller_test.go new file mode 100644 index 00000000..0c0fff0d --- /dev/null +++ b/services/gateway/controllers/completions_controller_test.go @@ -0,0 +1,185 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services/impl" +) + +func setupRouter(t *testing.T) (*gin.Engine, *services.Router, *httptest.Server) { + t.Helper() + gin.SetMode(gin.TestMode) + + // Mock Anthropic server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("expected http.Flusher") + } + + fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"model\":\"claude-test\",\"usage\":{\"input_tokens\":5}}}\n\n") + flusher.Flush() + + fmt.Fprint(w, "event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") + flusher.Flush() + + fmt.Fprint(w, "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n") + flusher.Flush() + + fmt.Fprint(w, "event: content_block_stop\ndata: {\"index\":0}\n\n") + flusher.Flush() + + fmt.Fprint(w, "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n") + flusher.Flush() + + fmt.Fprint(w, "event: message_stop\ndata: {}\n\n") + flusher.Flush() + })) + + logger := zap.NewNop() + router := services.NewRouter(logger, "anthropic") + + adapter := impl.NewAnthropicAdapter(mockServer.URL, "2023-06-01", 8192, "", logger) + router.RegisterProvider("anthropic", adapter, []string{"claude-"}) + + ctrl := NewCompletionsController(router, logger) + r := gin.New() + r.POST("/v1/chat/completions", ctrl.HandleCompletion) + + return r, router, mockServer +} + +func TestHandleCompletion_Success(t *testing.T) { + r, _, mockServer := setupRouter(t) + defer mockServer.Close() + + body := `{"model":"claude-test","messages":[{"role":"user","content":"Hi"}],"stream":true,"max_completion_tokens":50}` + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + + respBody := w.Body.String() + assert.Contains(t, respBody, "data: ") + assert.Contains(t, respBody, "data: [DONE]") + + // Parse first chunk — should have role + lines := strings.Split(respBody, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data: {") { + var chunk dto.ChatCompletionChunk + err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk) + require.NoError(t, err) + assert.Equal(t, "msg_test", chunk.ID) + assert.Equal(t, "chat.completion.chunk", chunk.Object) + break + } + } +} + +func TestHandleCompletion_InvalidJSON(t *testing.T) { + r, _, mockServer := setupRouter(t) + defer mockServer.Close() + + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var errResp dto.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Equal(t, "invalid_request_error", errResp.Error.Type) +} + +func TestHandleCompletion_MissingModel(t *testing.T) { + r, _, mockServer := setupRouter(t) + defer mockServer.Close() + + body := `{"messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var errResp dto.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Contains(t, errResp.Error.Message, "model is required") +} + +func TestHandleCompletion_UnknownProvider(t *testing.T) { + r, _, mockServer := setupRouter(t) + defer mockServer.Close() + + body := `{"model":"claude-test","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Provider", "nonexistent") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var errResp dto.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Contains(t, errResp.Error.Message, "unknown provider override") +} + +func TestHandleCompletion_ProviderError(t *testing.T) { + gin.SetMode(gin.TestMode) + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":{"message":"invalid api key","type":"authentication_error"}}`) + })) + defer errorServer.Close() + + logger := zap.NewNop() + router := services.NewRouter(logger, "anthropic") + adapter := impl.NewAnthropicAdapter(errorServer.URL, "2023-06-01", 8192, "", logger) + router.RegisterProvider("anthropic", adapter, []string{"claude-"}) + + ctrl := NewCompletionsController(router, logger) + r := gin.New() + r.POST("/v1/chat/completions", ctrl.HandleCompletion) + + body := `{"model":"claude-test","messages":[{"role":"user","content":"Hi"}],"stream":true}` + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer bad-key") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestExtractAPIKey(t *testing.T) { + assert.Equal(t, "sk-123", extractAPIKey("Bearer sk-123")) + assert.Equal(t, "raw-key", extractAPIKey("raw-key")) + assert.Equal(t, "", extractAPIKey("")) +} diff --git a/services/gateway/controllers/health_controller.go b/services/gateway/controllers/health_controller.go new file mode 100644 index 00000000..0203dfb3 --- /dev/null +++ b/services/gateway/controllers/health_controller.go @@ -0,0 +1,21 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GatewayHealthController handles GET /health for the gateway. +type GatewayHealthController struct{} + +func NewGatewayHealthController() *GatewayHealthController { + return &GatewayHealthController{} +} + +func (h *GatewayHealthController) Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "healthy", + "service": "gateway", + }) +} diff --git a/services/gateway/controllers/models_controller.go b/services/gateway/controllers/models_controller.go new file mode 100644 index 00000000..4ab790f2 --- /dev/null +++ b/services/gateway/controllers/models_controller.go @@ -0,0 +1,34 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/config" + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +// ModelsController handles GET /v1/models. +type ModelsController struct { + config config.GatewayConfig +} + +func NewModelsController(cfg config.GatewayConfig) *ModelsController { + return &ModelsController{config: cfg} +} + +func (ctrl *ModelsController) ListModels(c *gin.Context) { + entries := ctrl.config.Models() + models := make([]dto.ModelInfo, len(entries)) + for i, e := range entries { + models[i] = dto.ModelInfo{ + ID: e.ID, + DisplayName: e.DisplayName, + Provider: e.Provider, + ContextWindow: e.ContextWindow, + SupportsImages: e.SupportsImages, + } + } + c.JSON(http.StatusOK, dto.ModelsResponse{Models: models}) +} diff --git a/services/gateway/controllers/models_controller_test.go b/services/gateway/controllers/models_controller_test.go new file mode 100644 index 00000000..d2c5635a --- /dev/null +++ b/services/gateway/controllers/models_controller_test.go @@ -0,0 +1,75 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/config" + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +type stubGatewayConfig struct { + models []config.ModelEntry +} + +func (s *stubGatewayConfig) DefaultProvider() string { return "" } +func (s *stubGatewayConfig) Providers() []config.ProviderEntry { return nil } +func (s *stubGatewayConfig) Models() []config.ModelEntry { return s.models } + +func TestListModels(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &stubGatewayConfig{ + models: []config.ModelEntry{ + {ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6", Provider: "anthropic", ContextWindow: 1000000}, + {ID: "gpt-5", DisplayName: "GPT-5", Provider: "openai", ContextWindow: 400000}, + }, + } + + ctrl := NewModelsController(cfg) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + + ctrl.ListModels(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp dto.ModelsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + require.Len(t, resp.Models, 2) + assert.Equal(t, "claude-opus-4-6", resp.Models[0].ID) + assert.Equal(t, "Claude Opus 4.6", resp.Models[0].DisplayName) + assert.Equal(t, "anthropic", resp.Models[0].Provider) + assert.Equal(t, 1000000, resp.Models[0].ContextWindow) + assert.Equal(t, "gpt-5", resp.Models[1].ID) + assert.Equal(t, 400000, resp.Models[1].ContextWindow) +} + +func TestListModelsEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctrl := NewModelsController(&stubGatewayConfig{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + + ctrl.ListModels(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp dto.ModelsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Empty(t, resp.Models) +} diff --git a/services/gateway/models/dto/BUILD.bazel b/services/gateway/models/dto/BUILD.bazel new file mode 100644 index 00000000..000f1646 --- /dev/null +++ b/services/gateway/models/dto/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "dto", + srcs = [ + "anthropic.go", + "models.go", + "openai.go", + "openai_responses.go", + ], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto", + visibility = ["//visibility:public"], +) + +go_test( + name = "dto_test", + srcs = ["openai_test.go"], + embed = [":dto"], + deps = [ + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/services/gateway/models/dto/anthropic.go b/services/gateway/models/dto/anthropic.go new file mode 100644 index 00000000..f8a65e9a --- /dev/null +++ b/services/gateway/models/dto/anthropic.go @@ -0,0 +1,157 @@ +package dto + +import "encoding/json" + +// AnthropicRequest is the outbound request to Anthropic's Messages API. +type AnthropicRequest struct { + Model string `json:"model"` + System interface{} `json:"system,omitempty"` // string or []AnthropicSystemBlock + Messages []AnthropicMessage `json:"messages"` + Tools []AnthropicTool `json:"tools,omitempty"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + Thinking *AnthropicThinkingConfig `json:"thinking,omitempty"` + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` // top-level automatic caching +} + +type AnthropicCacheControl struct { + Type string `json:"type"` // "ephemeral" + TTL string `json:"ttl,omitempty"` // "5m" or "1h" +} + +// AnthropicSystemBlock is the array form of the system field, needed for per-block cache_control. +type AnthropicSystemBlock struct { + Type string `json:"type"` // "text" + Text string `json:"text"` + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` +} + +type AnthropicThinkingConfig struct { + Type string `json:"type"` + BudgetTokens int `json:"budget_tokens"` +} + +type AnthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// AnthropicContentBlock is a union type for content blocks in Anthropic messages. +type AnthropicContentBlock struct { + Type string `json:"type"` + + // text block + Text string `json:"text,omitempty"` + + // tool_use block + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + + // tool_result block + ToolUseID string `json:"tool_use_id,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` + + // thinking block + Thinking string `json:"thinking,omitempty"` + + // image block + Source *AnthropicImageSource `json:"source,omitempty"` + + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` +} + +type AnthropicImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +type AnthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema"` + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` +} + +type AnthropicToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// Anthropic SSE event data types + +type MessageStartData struct { + Type string `json:"type"` + Message MessageStartMessage `json:"message"` +} + +type MessageStartMessage struct { + ID string `json:"id"` + Model string `json:"model"` + Usage *MessageStartUsage `json:"usage,omitempty"` +} + +type MessageStartUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` +} + +type ContentBlockStartData struct { + Index int `json:"index"` + ContentBlock ContentBlockStartBlock `json:"content_block"` +} + +type ContentBlockStartBlock struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Text string `json:"text,omitempty"` +} + +type ContentBlockDeltaData struct { + Index int `json:"index"` + Delta ContentBlockDeltaDelta `json:"delta"` +} + +type ContentBlockDeltaDelta struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +type ContentBlockStopData struct { + Index int `json:"index"` +} + +type MessageDeltaData struct { + Delta MessageDeltaDelta `json:"delta"` + Usage *MessageDeltaUsage `json:"usage,omitempty"` +} + +type MessageDeltaDelta struct { + StopReason string `json:"stop_reason,omitempty"` +} + +type MessageDeltaUsage struct { + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` +} + +type AnthropicErrorData struct { + Type string `json:"type"` + Error AnthropicErrorDetail `json:"error"` +} + +type AnthropicErrorDetail struct { + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/services/gateway/models/dto/models.go b/services/gateway/models/dto/models.go new file mode 100644 index 00000000..e185a7d0 --- /dev/null +++ b/services/gateway/models/dto/models.go @@ -0,0 +1,15 @@ +package dto + +// ModelInfo represents a single model in the GET /v1/models response. +type ModelInfo struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + SupportsImages bool `json:"supports_images"` +} + +// ModelsResponse is the response body for GET /v1/models. +type ModelsResponse struct { + Models []ModelInfo `json:"models"` +} diff --git a/services/gateway/models/dto/openai.go b/services/gateway/models/dto/openai.go new file mode 100644 index 00000000..2677d0bc --- /dev/null +++ b/services/gateway/models/dto/openai.go @@ -0,0 +1,153 @@ +package dto + +import ( + "encoding/json" + "fmt" +) + +// ChatCompletionRequest is the OpenAI Chat Completions API request format. +// This is the gateway's input AND output wire format. +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + Stop json.RawMessage `json:"stop,omitempty"` + Thinking *ThinkingConfig `json:"thinking,omitempty"` + ChatTemplateArgs json.RawMessage `json:"chat_template_args,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` // Anthropic auto-caching hint + PromptCacheKey string `json:"prompt_cache_key,omitempty"` // OpenAI routing affinity +} + +// CacheControl triggers prompt-cache breakpoints on Anthropic. Ignored by OpenAI. +type CacheControl struct { + Type string `json:"type"` // "ephemeral" + TTL string `json:"ttl,omitempty"` // "5m" or "1h" +} + +// PromptTokensDetails carries cache-token accounting in Usage responses. +type PromptTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +type ThinkingConfig struct { + Type string `json:"type,omitempty"` + BudgetTokens int `json:"budget_tokens,omitempty"` +} + +// Message represents a chat message. Content is polymorphic: string or []ContentBlock. +type Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` +} + +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` +} + +type ImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +type ToolCall struct { + Index *int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function FunctionCall `json:"function"` +} + +type FunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments"` +} + +type Tool struct { + Type string `json:"type"` + Function FunctionDef `json:"function"` + CacheControl *CacheControl `json:"cache_control,omitempty"` // set on last tool to cache tool-definitions prefix +} + +type FunctionDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +// ChatCompletionChunk is an SSE response chunk in Chat Completions format. +type ChatCompletionChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Choices []ChunkChoice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` +} + +type ChunkChoice struct { + Index int `json:"index"` + Delta ChunkDelta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type ChunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` +} + +// ErrorResponse is the OpenAI-format error response. +type ErrorResponse struct { + Error ErrorDetail `json:"error"` +} + +type ErrorDetail struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code,omitempty"` +} + +// ParseMessageContent detects whether content is a string or []ContentBlock. +func ParseMessageContent(raw json.RawMessage) (string, []ContentBlock, error) { + if len(raw) == 0 { + return "", nil, nil + } + + // Try string first + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, nil, nil + } + + // Try array of content blocks + var blocks []ContentBlock + if err := json.Unmarshal(raw, &blocks); err == nil { + return "", blocks, nil + } + + return "", nil, fmt.Errorf("content is neither string nor []ContentBlock") +} diff --git a/services/gateway/models/dto/openai_responses.go b/services/gateway/models/dto/openai_responses.go new file mode 100644 index 00000000..710c5b26 --- /dev/null +++ b/services/gateway/models/dto/openai_responses.go @@ -0,0 +1,123 @@ +package dto + +import "encoding/json" + +// ResponsesRequest is the outbound request to OpenAI's Responses API. +type ResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Instructions string `json:"instructions,omitempty"` + Tools []ResponsesTool `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Store bool `json:"store"` + Reasoning *ResponsesReasoning `json:"reasoning,omitempty"` +} + +type ResponsesReasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type ResponsesTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + +// ResponsesInputMessage is a message item in the input array. +type ResponsesInputMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// FunctionCallItem represents a function_call input item. +type FunctionCallItem struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// FunctionCallOutputItem represents a function_call_output input item. +type FunctionCallOutputItem struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output string `json:"output"` +} + +// Responses API SSE event types + +type ResponseCreatedEvent struct { + Response ResponseCreatedData `json:"response"` +} + +type ResponseCreatedData struct { + ID string `json:"id"` + Model string `json:"model"` +} + +type OutputItemAddedEvent struct { + OutputIndex int `json:"output_index"` + Item OutputItemAddedItem `json:"item"` +} + +type OutputItemAddedItem struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + CallID string `json:"call_id,omitempty"` +} + +type OutputTextDeltaEvent struct { + OutputIndex int `json:"output_index"` + ContentIndex int `json:"content_index"` + Delta string `json:"delta"` +} + +type FunctionCallArgsDeltaEvent struct { + OutputIndex int `json:"output_index"` + Delta string `json:"delta"` +} + +type ReasoningSummaryDeltaEvent struct { + OutputIndex int `json:"output_index"` + Delta string `json:"delta"` +} + +type ResponseCompletedEvent struct { + Response ResponseCompletedResponse `json:"response"` +} + +type ResponseCompletedResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Output json.RawMessage `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` +} + +type ResponsesUsage struct { + InputTokens int `json:"input_tokens"` + InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"` + OutputTokens int `json:"output_tokens"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` +} + +type ResponsesInputTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` +} + +type ResponseErrorEvent struct { + Code string `json:"code,omitempty"` + Message string `json:"message"` +} + +// ResponseOutputItem is used to inspect output items in response.completed +type ResponseOutputItem struct { + Type string `json:"type"` +} diff --git a/services/gateway/models/dto/openai_test.go b/services/gateway/models/dto/openai_test.go new file mode 100644 index 00000000..bf533388 --- /dev/null +++ b/services/gateway/models/dto/openai_test.go @@ -0,0 +1,66 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMessageContent_String(t *testing.T) { + raw := json.RawMessage(`"Hello world"`) + text, blocks, err := ParseMessageContent(raw) + require.NoError(t, err) + assert.Equal(t, "Hello world", text) + assert.Nil(t, blocks) +} + +func TestParseMessageContent_Array(t *testing.T) { + raw := json.RawMessage(`[{"type":"text","text":"Hello"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]`) + text, blocks, err := ParseMessageContent(raw) + require.NoError(t, err) + assert.Empty(t, text) + require.Len(t, blocks, 2) + assert.Equal(t, "text", blocks[0].Type) + assert.Equal(t, "Hello", blocks[0].Text) + assert.Equal(t, "image_url", blocks[1].Type) + require.NotNil(t, blocks[1].ImageURL) + assert.Equal(t, "data:image/png;base64,abc", blocks[1].ImageURL.URL) +} + +func TestParseMessageContent_Empty(t *testing.T) { + text, blocks, err := ParseMessageContent(nil) + require.NoError(t, err) + assert.Empty(t, text) + assert.Nil(t, blocks) +} + +func TestParseMessageContent_EmptyRaw(t *testing.T) { + text, blocks, err := ParseMessageContent(json.RawMessage{}) + require.NoError(t, err) + assert.Empty(t, text) + assert.Nil(t, blocks) +} + +func TestParseMessageContent_Invalid(t *testing.T) { + raw := json.RawMessage(`12345`) + _, _, err := ParseMessageContent(raw) + assert.Error(t, err) +} + +func TestParseMessageContent_EmptyString(t *testing.T) { + raw := json.RawMessage(`""`) + text, blocks, err := ParseMessageContent(raw) + require.NoError(t, err) + assert.Equal(t, "", text) + assert.Nil(t, blocks) +} + +func TestParseMessageContent_EmptyArray(t *testing.T) { + raw := json.RawMessage(`[]`) + text, blocks, err := ParseMessageContent(raw) + require.NoError(t, err) + assert.Empty(t, text) + assert.Empty(t, blocks) +} diff --git a/services/gateway/services/BUILD.bazel b/services/gateway/services/BUILD.bazel new file mode 100644 index 00000000..79d7ff4b --- /dev/null +++ b/services/gateway/services/BUILD.bazel @@ -0,0 +1,31 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "services", + srcs = [ + "provider_adapter.go", + "router.go", + "sse_parser.go", + ], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/services", + visibility = ["//visibility:public"], + deps = [ + "//services/gateway/models/dto", + "@org_uber_go_zap//:zap", + ], +) + +go_test( + name = "services_test", + srcs = [ + "router_test.go", + "sse_parser_test.go", + ], + embed = [":services"], + deps = [ + "//services/gateway/models/dto", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@org_uber_go_zap//:zap", + ], +) diff --git a/services/gateway/services/impl/BUILD.bazel b/services/gateway/services/impl/BUILD.bazel new file mode 100644 index 00000000..678c698d --- /dev/null +++ b/services/gateway/services/impl/BUILD.bazel @@ -0,0 +1,37 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "impl", + srcs = [ + "anthropic_adapter.go", + "anthropic_stream.go", + "openai_compat_adapter.go", + "openai_responses_adapter.go", + "openai_responses_stream.go", + ], + importpath = "github.com/TransformerOptimus/SuperCoder/services/gateway/services/impl", + visibility = ["//visibility:public"], + deps = [ + "//services/gateway/models/dto", + "//services/gateway/services", + "@org_uber_go_zap//:zap", + ], +) + +go_test( + name = "impl_test", + srcs = [ + "anthropic_adapter_test.go", + "anthropic_stream_test.go", + "openai_compat_adapter_test.go", + "openai_responses_adapter_test.go", + "openai_responses_stream_test.go", + ], + embed = [":impl"], + deps = [ + "//services/gateway/models/dto", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@org_uber_go_zap//:zap", + ], +) diff --git a/services/gateway/services/impl/anthropic_adapter.go b/services/gateway/services/impl/anthropic_adapter.go new file mode 100644 index 00000000..f40a3bc1 --- /dev/null +++ b/services/gateway/services/impl/anthropic_adapter.go @@ -0,0 +1,483 @@ +package impl + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strings" + + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +// AnthropicAdapter translates Chat Completions format to/from Anthropic Messages API. +type AnthropicAdapter struct { + baseURL string + version string + defaultMaxTokens int + apiKey string + logger *zap.Logger +} + +func NewAnthropicAdapter(baseURL, version string, defaultMaxTokens int, apiKey string, logger *zap.Logger) *AnthropicAdapter { + if defaultMaxTokens == 0 { + defaultMaxTokens = 8192 + } + return &AnthropicAdapter{ + baseURL: strings.TrimRight(baseURL, "/"), + version: version, + defaultMaxTokens: defaultMaxTokens, + apiKey: apiKey, + logger: logger.Named("gateway.anthropic"), + } +} + +func (a *AnthropicAdapter) ConfiguredAPIKey() string { + return a.apiKey +} + +func (a *AnthropicAdapter) Name() string { + return "anthropic" +} + +func (a *AnthropicAdapter) MatchesModel(model string) bool { + return strings.HasPrefix(model, "claude-") +} + +func (a *AnthropicAdapter) TranslateRequest(req *dto.ChatCompletionRequest, apiKey string) (string, map[string]string, []byte, error) { + anthropicReq := dto.AnthropicRequest{ + Model: req.Model, + Stream: true, + } + + if system := extractSystemMessages(req.Messages); system != nil { + anthropicReq.System = system + } + + if req.CacheControl != nil { + anthropicReq.CacheControl = toAnthropicCacheControl(req.CacheControl) + } + + // Translate messages + msgs, err := translateMessages(req.Messages) + if err != nil { + return "", nil, nil, fmt.Errorf("translate messages: %w", err) + } + anthropicReq.Messages = msgs + + // Max tokens + if req.MaxCompletionTokens != nil { + anthropicReq.MaxTokens = *req.MaxCompletionTokens + } else { + anthropicReq.MaxTokens = a.defaultMaxTokens + } + + // Temperature + if req.Temperature != nil { + temp := *req.Temperature + anthropicReq.Temperature = &temp + } + + // Stop sequences + if len(req.Stop) > 0 { + stopSeqs, err := parseStopSequences(req.Stop) + if err == nil && len(stopSeqs) > 0 { + anthropicReq.StopSequences = stopSeqs + } + } + + // Tools + if len(req.Tools) > 0 { + anthropicReq.Tools = translateTools(req.Tools) + } + + // Tool choice + if len(req.ToolChoice) > 0 { + tc, err := translateToolChoice(req.ToolChoice) + if err == nil && tc != nil { + if req.ParallelToolCalls != nil && !*req.ParallelToolCalls { + tc.DisableParallelToolUse = true + } + anthropicReq.ToolChoice = tc + } + } + + // Thinking + if req.Thinking != nil && req.Thinking.BudgetTokens > 0 { + anthropicReq.Thinking = &dto.AnthropicThinkingConfig{ + Type: "enabled", + BudgetTokens: req.Thinking.BudgetTokens, + } + // Anthropic requires temperature=1 with thinking + temp := 1.0 + anthropicReq.Temperature = &temp + } + + body, err := json.Marshal(anthropicReq) + if err != nil { + return "", nil, nil, fmt.Errorf("marshal request: %w", err) + } + + url := a.baseURL + "/v1/messages" + headers := map[string]string{ + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": a.version, + } + + return url, headers, body, nil +} + +// extractSystemMessages returns nil, a plain string, or []AnthropicSystemBlock +// (block-array form used when any block has cache_control). +func extractSystemMessages(messages []dto.Message) interface{} { + var blocks []dto.AnthropicSystemBlock + var anyHasCacheControl bool + + for _, msg := range messages { + if msg.Role != "system" { + continue + } + text, contentBlocks, _ := dto.ParseMessageContent(msg.Content) + if len(contentBlocks) > 0 { + for _, b := range contentBlocks { + if b.Type != "text" || b.Text == "" { + continue + } + block := dto.AnthropicSystemBlock{Type: "text", Text: b.Text} + if b.CacheControl != nil { + block.CacheControl = toAnthropicCacheControl(b.CacheControl) + anyHasCacheControl = true + } + blocks = append(blocks, block) + } + } else if text != "" { + blocks = append(blocks, dto.AnthropicSystemBlock{Type: "text", Text: text}) + } + } + + if len(blocks) == 0 { + return nil + } + if anyHasCacheControl { + return blocks + } + // No cache markers — fold back to plain string for back-compat. + parts := make([]string, 0, len(blocks)) + for _, b := range blocks { + parts = append(parts, b.Text) + } + return strings.Join(parts, "\n\n") +} + +func toAnthropicCacheControl(src *dto.CacheControl) *dto.AnthropicCacheControl { + if src == nil { + return nil + } + return &dto.AnthropicCacheControl{Type: src.Type, TTL: src.TTL} +} + +func translateMessages(messages []dto.Message) ([]dto.AnthropicMessage, error) { + var result []dto.AnthropicMessage + var pendingToolResults []dto.AnthropicContentBlock + + for _, msg := range messages { + if msg.Role == "system" { + continue + } + + if msg.Role == "tool" { + text, _, _ := dto.ParseMessageContent(msg.Content) + block := dto.AnthropicContentBlock{ + Type: "tool_result", + ToolUseID: sanitizeToolID(msg.ToolCallID), + } + if text != "" { + contentBytes, err := json.Marshal(text) + if err != nil { + return nil, fmt.Errorf("marshal tool result content: %w", err) + } + block.Content = contentBytes + } + pendingToolResults = append(pendingToolResults, block) + continue + } + + // Flush pending tool results as a user message before non-tool messages + if len(pendingToolResults) > 0 { + var err error + result, err = flushToolResults(result, pendingToolResults) + if err != nil { + return nil, err + } + pendingToolResults = nil + } + + switch msg.Role { + case "user": + amsg, err := translateUserMessage(msg) + if err != nil { + return nil, err + } + result = appendWithAdjacency(result, amsg) + + case "assistant": + amsg, err := translateAssistantMessage(msg) + if err != nil { + return nil, err + } + result = appendWithAdjacency(result, amsg) + } + } + + // Flush any remaining tool results + if len(pendingToolResults) > 0 { + var err error + result, err = flushToolResults(result, pendingToolResults) + if err != nil { + return nil, err + } + } + + return result, nil +} + +func flushToolResults(result []dto.AnthropicMessage, toolResults []dto.AnthropicContentBlock) ([]dto.AnthropicMessage, error) { + contentBytes, err := json.Marshal(toolResults) + if err != nil { + return nil, fmt.Errorf("marshal tool results: %w", err) + } + msg := dto.AnthropicMessage{ + Role: "user", + Content: contentBytes, + } + return appendWithAdjacency(result, msg), nil +} + +func translateUserMessage(msg dto.Message) (dto.AnthropicMessage, error) { + text, blocks, err := dto.ParseMessageContent(msg.Content) + if err != nil { + return dto.AnthropicMessage{}, err + } + + if len(blocks) > 0 { + var contentBlocks []dto.AnthropicContentBlock + for _, block := range blocks { + switch block.Type { + case "text": + contentBlocks = append(contentBlocks, dto.AnthropicContentBlock{ + Type: "text", + Text: block.Text, + CacheControl: toAnthropicCacheControl(block.CacheControl), + }) + case "image_url": + if block.ImageURL != nil { + imgBlock, err := translateImageBlock(block.ImageURL.URL) + if err != nil { + return dto.AnthropicMessage{}, err + } + imgBlock.CacheControl = toAnthropicCacheControl(block.CacheControl) + contentBlocks = append(contentBlocks, imgBlock) + } + } + } + contentBytes, err := json.Marshal(contentBlocks) + if err != nil { + return dto.AnthropicMessage{}, fmt.Errorf("marshal user content blocks: %w", err) + } + return dto.AnthropicMessage{Role: "user", Content: contentBytes}, nil + } + + contentBytes, err := json.Marshal(text) + if err != nil { + return dto.AnthropicMessage{}, fmt.Errorf("marshal user text content: %w", err) + } + return dto.AnthropicMessage{Role: "user", Content: contentBytes}, nil +} + +func translateImageBlock(url string) (dto.AnthropicContentBlock, error) { + mediaType, data, err := parseDataURI(url) + if err != nil { + return dto.AnthropicContentBlock{}, fmt.Errorf("parse image data URI: %w", err) + } + return dto.AnthropicContentBlock{ + Type: "image", + Source: &dto.AnthropicImageSource{ + Type: "base64", + MediaType: mediaType, + Data: data, + }, + }, nil +} + +var toolIDPattern = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +func sanitizeToolID(id string) string { + return toolIDPattern.ReplaceAllString(id, "_") +} + +func translateAssistantMessage(msg dto.Message) (dto.AnthropicMessage, error) { + var contentBlocks []dto.AnthropicContentBlock + + // Add thinking blocks if present + if len(msg.Thinking) > 0 { + var thinking string + if err := json.Unmarshal(msg.Thinking, &thinking); err == nil && thinking != "" { + contentBlocks = append(contentBlocks, dto.AnthropicContentBlock{ + Type: "thinking", + Thinking: thinking, + }) + } + } + + // Add text content + text, _, _ := dto.ParseMessageContent(msg.Content) + if text != "" { + contentBlocks = append(contentBlocks, dto.AnthropicContentBlock{ + Type: "text", + Text: text, + }) + } + + // Convert tool calls to tool_use blocks + for _, tc := range msg.ToolCalls { + var input json.RawMessage + if tc.Function.Arguments != "" && json.Valid([]byte(tc.Function.Arguments)) { + input = json.RawMessage(tc.Function.Arguments) + } else { + input = json.RawMessage("{}") + } + contentBlocks = append(contentBlocks, dto.AnthropicContentBlock{ + Type: "tool_use", + ID: sanitizeToolID(tc.ID), + Name: tc.Function.Name, + Input: input, + }) + } + + if len(contentBlocks) == 0 { + contentBytes, err := json.Marshal("") + if err != nil { + return dto.AnthropicMessage{}, fmt.Errorf("marshal empty assistant content: %w", err) + } + return dto.AnthropicMessage{Role: "assistant", Content: contentBytes}, nil + } + + contentBytes, err := json.Marshal(contentBlocks) + if err != nil { + return dto.AnthropicMessage{}, fmt.Errorf("marshal assistant content blocks: %w", err) + } + return dto.AnthropicMessage{Role: "assistant", Content: contentBytes}, nil +} + +// appendWithAdjacency merges consecutive same-role messages (Anthropic requires alternation). +func appendWithAdjacency(result []dto.AnthropicMessage, msg dto.AnthropicMessage) []dto.AnthropicMessage { + if len(result) == 0 || result[len(result)-1].Role != msg.Role { + return append(result, msg) + } + + // Merge content arrays + last := &result[len(result)-1] + lastBlocks := parseContentBlocks(last.Content) + newBlocks := parseContentBlocks(msg.Content) + merged := append(lastBlocks, newBlocks...) + mergedBytes, err := json.Marshal(merged) + if err != nil { + // Best effort: append as separate message rather than losing data + return append(result, msg) + } + last.Content = mergedBytes + return result +} + +func parseContentBlocks(raw json.RawMessage) []dto.AnthropicContentBlock { + var blocks []dto.AnthropicContentBlock + if err := json.Unmarshal(raw, &blocks); err != nil { + // It might be a string + var s string + if err := json.Unmarshal(raw, &s); err == nil && s != "" { + return []dto.AnthropicContentBlock{{Type: "text", Text: s}} + } + return nil + } + return blocks +} + +func translateTools(tools []dto.Tool) []dto.AnthropicTool { + var result []dto.AnthropicTool + for _, t := range tools { + result = append(result, dto.AnthropicTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + CacheControl: toAnthropicCacheControl(t.CacheControl), + }) + } + return result +} + +func translateToolChoice(raw json.RawMessage) (*dto.AnthropicToolChoice, error) { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + switch s { + case "auto": + return &dto.AnthropicToolChoice{Type: "auto"}, nil + case "required": + return &dto.AnthropicToolChoice{Type: "any"}, nil + case "none": + return nil, nil + default: + return &dto.AnthropicToolChoice{Type: "auto"}, nil + } + } + + var obj struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &obj); err == nil && obj.Function.Name != "" { + return &dto.AnthropicToolChoice{ + Type: "tool", + Name: obj.Function.Name, + }, nil + } + + return &dto.AnthropicToolChoice{Type: "auto"}, nil +} + +func parseStopSequences(raw json.RawMessage) ([]string, error) { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return []string{s}, nil + } + + var arr []string + if err := json.Unmarshal(raw, &arr); err != nil { + return nil, err + } + return arr, nil +} + +func parseDataURI(uri string) (mediaType, data string, err error) { + // Format: data:{media_type};base64,{data} + if !strings.HasPrefix(uri, "data:") { + return "", "", fmt.Errorf("not a data URI") + } + rest := strings.TrimPrefix(uri, "data:") + parts := strings.SplitN(rest, ";base64,", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid data URI format") + } + // Validate base64 + if _, err := base64.StdEncoding.DecodeString(parts[1]); err != nil { + // Try raw base64 (no padding) + if _, err := base64.RawStdEncoding.DecodeString(parts[1]); err != nil { + return "", "", fmt.Errorf("invalid base64 data: %w", err) + } + } + return parts[0], parts[1], nil +} diff --git a/services/gateway/services/impl/anthropic_adapter_test.go b/services/gateway/services/impl/anthropic_adapter_test.go new file mode 100644 index 00000000..25c52bde --- /dev/null +++ b/services/gateway/services/impl/anthropic_adapter_test.go @@ -0,0 +1,778 @@ +package impl + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +func TestAnthropicAdapter_Name(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + assert.Equal(t, "anthropic", adapter.Name()) +} + +func TestAnthropicAdapter_MatchesModel(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + assert.True(t, adapter.MatchesModel("claude-sonnet-4-6")) + assert.True(t, adapter.MatchesModel("claude-opus-4-6")) + assert.False(t, adapter.MatchesModel("gpt-5.4")) +} + +func TestAnthropicAdapter_BasicRequest(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Stream: true, + } + + url, headers, body, err := adapter.TranslateRequest(req, "test-key") + require.NoError(t, err) + + assert.Equal(t, "https://api.anthropic.com/v1/messages", url) + assert.Equal(t, "test-key", headers["x-api-key"]) + assert.Equal(t, "2023-06-01", headers["anthropic-version"]) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, "claude-sonnet-4-6", anthropicReq.Model) + assert.True(t, anthropicReq.Stream) + assert.Equal(t, 8192, anthropicReq.MaxTokens) + assert.Len(t, anthropicReq.Messages, 1) +} + +func TestAnthropicAdapter_SystemPrompt(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "system", Content: json.RawMessage(`"You are a helpful assistant."`)}, + {Role: "system", Content: json.RawMessage(`"Be concise."`)}, + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, "You are a helpful assistant.\n\nBe concise.", anthropicReq.System) + assert.Len(t, anthropicReq.Messages, 1) + assert.Equal(t, "user", anthropicReq.Messages[0].Role) +} + +func TestAnthropicAdapter_MaxTokens(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + maxTokens := 4096 + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + MaxCompletionTokens: &maxTokens, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, 4096, anthropicReq.MaxTokens) +} + +func TestAnthropicAdapter_Temperature(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + temp := 0.7 + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Temperature: &temp, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.Temperature) + assert.InDelta(t, 0.7, *anthropicReq.Temperature, 0.001) +} + +func TestAnthropicAdapter_ToolCalls(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"What is the weather?"`)}, + }, + Tools: []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{ + Name: "get_weather", + Description: "Get the weather", + Parameters: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string"}}}`), + }, + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.Len(t, anthropicReq.Tools, 1) + assert.Equal(t, "get_weather", anthropicReq.Tools[0].Name) + assert.Equal(t, "Get the weather", anthropicReq.Tools[0].Description) +} + +func TestAnthropicAdapter_ToolChoice(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + tests := []struct { + name string + toolChoice json.RawMessage + expected string + }{ + {"auto", json.RawMessage(`"auto"`), "auto"}, + {"required", json.RawMessage(`"required"`), "any"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + ToolChoice: tt.toolChoice, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.ToolChoice) + assert.Equal(t, tt.expected, anthropicReq.ToolChoice.Type) + }) + } +} + +func TestAnthropicAdapter_DisableParallelToolCalls(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + parallel := false + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + ToolChoice: json.RawMessage(`"auto"`), + ParallelToolCalls: ¶llel, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.ToolChoice) + assert.True(t, anthropicReq.ToolChoice.DisableParallelToolUse) +} + +func TestAnthropicAdapter_AssistantWithToolCalls(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"What is the weather?"`)}, + { + Role: "assistant", + Content: json.RawMessage(`"Let me check the weather."`), + ToolCalls: []dto.ToolCall{ + { + ID: "call_123", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"NYC"}`, + }, + }, + }, + }, + { + Role: "tool", + Content: json.RawMessage(`"72°F and sunny"`), + ToolCallID: "call_123", + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + + // Should have: user, assistant (with text + tool_use), user (with tool_result) + require.Len(t, anthropicReq.Messages, 3) + assert.Equal(t, "user", anthropicReq.Messages[0].Role) + assert.Equal(t, "assistant", anthropicReq.Messages[1].Role) + assert.Equal(t, "user", anthropicReq.Messages[2].Role) + + // Check assistant message has tool_use block + var assistantBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[1].Content, &assistantBlocks)) + require.Len(t, assistantBlocks, 2) + assert.Equal(t, "text", assistantBlocks[0].Type) + assert.Equal(t, "tool_use", assistantBlocks[1].Type) + assert.Equal(t, "call_123", assistantBlocks[1].ID) + + // Check tool_result + var toolBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[2].Content, &toolBlocks)) + require.Len(t, toolBlocks, 1) + assert.Equal(t, "tool_result", toolBlocks[0].Type) + assert.Equal(t, "call_123", toolBlocks[0].ToolUseID) +} + +func TestAnthropicAdapter_Thinking(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Solve this problem"`)}, + }, + Thinking: &dto.ThinkingConfig{ + Type: "enabled", + BudgetTokens: 10000, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.Thinking) + assert.Equal(t, "enabled", anthropicReq.Thinking.Type) + assert.Equal(t, 10000, anthropicReq.Thinking.BudgetTokens) + // Temperature must be 1.0 with thinking + require.NotNil(t, anthropicReq.Temperature) + assert.InDelta(t, 1.0, *anthropicReq.Temperature, 0.001) +} + +func TestAnthropicAdapter_StopSequences(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Stop: json.RawMessage(`["STOP", "END"]`), + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, []string{"STOP", "END"}, anthropicReq.StopSequences) +} + +func TestAnthropicAdapter_StopSequencesString(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Stop: json.RawMessage(`"STOP"`), + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, []string{"STOP"}, anthropicReq.StopSequences) +} + +func TestAnthropicAdapter_MessageAdjacency(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + // Two consecutive user messages should be merged + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + {Role: "user", Content: json.RawMessage(`"How are you?"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Len(t, anthropicReq.Messages, 1, "consecutive user messages should be merged") +} + +func TestAnthropicAdapter_ImageContent(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + { + Role: "user", + Content: json.RawMessage(`[ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}} + ]`), + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.Len(t, anthropicReq.Messages, 1) + + var blocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[0].Content, &blocks)) + require.Len(t, blocks, 2) + assert.Equal(t, "text", blocks[0].Type) + assert.Equal(t, "image", blocks[1].Type) + require.NotNil(t, blocks[1].Source) + assert.Equal(t, "base64", blocks[1].Source.Type) + assert.Equal(t, "image/png", blocks[1].Source.MediaType) +} + +func TestAnthropicAdapter_DefaultMaxTokens(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 0, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Equal(t, 8192, anthropicReq.MaxTokens, "should default to 8192") +} + +func TestExtractSystemMessages(t *testing.T) { + messages := []dto.Message{ + {Role: "system", Content: json.RawMessage(`"Part 1"`)}, + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + {Role: "system", Content: json.RawMessage(`"Part 2"`)}, + } + + result := extractSystemMessages(messages) + assert.Equal(t, "Part 1\n\nPart 2", result) +} + +func TestParseDataURI(t *testing.T) { + mediaType, data, err := parseDataURI("data:image/png;base64,iVBORw0KGgo=") + require.NoError(t, err) + assert.Equal(t, "image/png", mediaType) + assert.Equal(t, "iVBORw0KGgo=", data) +} + +func TestParseDataURI_Invalid(t *testing.T) { + _, _, err := parseDataURI("https://example.com/image.png") + assert.Error(t, err) +} + +func TestMapAnthropicStopReason(t *testing.T) { + assert.Equal(t, "stop", mapAnthropicStopReason("end_turn")) + assert.Equal(t, "tool_calls", mapAnthropicStopReason("tool_use")) + assert.Equal(t, "length", mapAnthropicStopReason("max_tokens")) + assert.Equal(t, "stop", mapAnthropicStopReason("stop_sequence")) + assert.Equal(t, "stop", mapAnthropicStopReason("unknown")) +} + +func TestAnthropicAdapter_MultipleToolCalls(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Get weather and time"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + {ID: "call_1", Type: "function", Function: dto.FunctionCall{Name: "get_weather", Arguments: `{"city":"NYC"}`}}, + {ID: "call_2", Type: "function", Function: dto.FunctionCall{Name: "get_time", Arguments: `{"tz":"EST"}`}}, + }, + }, + {Role: "tool", Content: json.RawMessage(`"72°F"`), ToolCallID: "call_1"}, + {Role: "tool", Content: json.RawMessage(`"3:00 PM"`), ToolCallID: "call_2"}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + + // user, assistant (2 tool_use blocks), user (2 tool_result blocks) + require.Len(t, anthropicReq.Messages, 3) + + var assistantBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[1].Content, &assistantBlocks)) + assert.Len(t, assistantBlocks, 2) + assert.Equal(t, "tool_use", assistantBlocks[0].Type) + assert.Equal(t, "tool_use", assistantBlocks[1].Type) + + var toolBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[2].Content, &toolBlocks)) + assert.Len(t, toolBlocks, 2) + assert.Equal(t, "call_1", toolBlocks[0].ToolUseID) + assert.Equal(t, "call_2", toolBlocks[1].ToolUseID) +} + +func TestAnthropicAdapter_NilContent(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hi"`)}, + {Role: "assistant", Content: nil, ToolCalls: []dto.ToolCall{ + {ID: "call_1", Type: "function", Function: dto.FunctionCall{Name: "fn", Arguments: "{}"}}, + }}, + {Role: "tool", Content: json.RawMessage(`"result"`), ToolCallID: "call_1"}, + {Role: "user", Content: json.RawMessage(`"thanks"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.GreaterOrEqual(t, len(anthropicReq.Messages), 3) +} + +func TestAnthropicAdapter_ToolChoiceNone(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hi"`)}, + }, + ToolChoice: json.RawMessage(`"none"`), + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Nil(t, anthropicReq.ToolChoice, "none should result in nil tool_choice") +} + +func TestAnthropicAdapter_ToolChoiceSpecificFunction(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hi"`)}, + }, + ToolChoice: json.RawMessage(`{"type":"function","function":{"name":"get_weather"}}`), + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.ToolChoice) + assert.Equal(t, "tool", anthropicReq.ToolChoice.Type) + assert.Equal(t, "get_weather", anthropicReq.ToolChoice.Name) +} + +func TestAnthropicAdapter_EmptyToolsArray(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hi"`)}, + }, + Tools: []dto.Tool{}, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + assert.Nil(t, anthropicReq.Tools) +} + +func TestAnthropicAdapter_AssistantThinkingInHistory(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Solve 2+2"`)}, + { + Role: "assistant", + Content: json.RawMessage(`"4"`), + Thinking: json.RawMessage(`"Let me calculate: 2+2=4"`), + }, + {Role: "user", Content: json.RawMessage(`"And 3+3?"`)}, + }, + Thinking: &dto.ThinkingConfig{Type: "enabled", BudgetTokens: 10000}, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + + // Assistant message should have thinking + text blocks + var blocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[1].Content, &blocks)) + require.Len(t, blocks, 2) + assert.Equal(t, "thinking", blocks[0].Type) + assert.Equal(t, "Let me calculate: 2+2=4", blocks[0].Thinking) + assert.Equal(t, "text", blocks[1].Type) +} + +func TestAnthropicAdapter_TruncatedToolCallArguments(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Do something"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + {ID: "call_1", Type: "function", Function: dto.FunctionCall{Name: "run_tool", Arguments: `{"city":"NY`}}, + }, + }, + {Role: "tool", Content: json.RawMessage(`"result"`), ToolCallID: "call_1"}, + {Role: "user", Content: json.RawMessage(`"ok"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + + var assistantBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[1].Content, &assistantBlocks)) + require.Len(t, assistantBlocks, 1) + assert.Equal(t, "tool_use", assistantBlocks[0].Type) + assert.JSONEq(t, `{}`, string(assistantBlocks[0].Input)) +} + +func TestSanitizeToolID(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"call_123", "call_123"}, + {"call-abc_def", "call-abc_def"}, + {"call!@#$%", "call_____"}, + {"", ""}, + {"toolu_01ABC", "toolu_01ABC"}, + {"call::123", "call__123"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, sanitizeToolID(tt.input)) + }) + } +} + +func TestAnthropicAdapter_ToolIDSanitization(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Do something"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + {ID: "call!@#abc", Type: "function", Function: dto.FunctionCall{Name: "run_tool", Arguments: `{"a":"b"}`}}, + }, + }, + {Role: "tool", Content: json.RawMessage(`"result"`), ToolCallID: "call!@#abc"}, + {Role: "user", Content: json.RawMessage(`"ok"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + + var assistantBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[1].Content, &assistantBlocks)) + require.Len(t, assistantBlocks, 1) + assert.Equal(t, "tool_use", assistantBlocks[0].Type) + assert.Equal(t, "call___abc", assistantBlocks[0].ID) + + var toolResultBlocks []dto.AnthropicContentBlock + require.NoError(t, json.Unmarshal(anthropicReq.Messages[2].Content, &toolResultBlocks)) + require.GreaterOrEqual(t, len(toolResultBlocks), 1) + assert.Equal(t, "tool_result", toolResultBlocks[0].Type) + assert.Equal(t, "call___abc", toolResultBlocks[0].ToolUseID) +} + +// Prompt caching tests (Stage 2) + +func TestAnthropicAdapter_CacheControlOnLastTool(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"hi"`)}, + }, + Tools: []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{Name: "t1", Description: "first", Parameters: json.RawMessage(`{}`)}, + }, + { + Type: "function", + Function: dto.FunctionDef{Name: "t2", Description: "last", Parameters: json.RawMessage(`{}`)}, + CacheControl: &dto.CacheControl{Type: "ephemeral"}, + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.Len(t, anthropicReq.Tools, 2) + assert.Nil(t, anthropicReq.Tools[0].CacheControl, "first tool must not carry cache_control") + require.NotNil(t, anthropicReq.Tools[1].CacheControl, "last tool must carry cache_control") + assert.Equal(t, "ephemeral", anthropicReq.Tools[1].CacheControl.Type) +} + +func TestAnthropicAdapter_CacheControlOnSystemBlock(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + // System content is an array of ContentBlocks; only the first carries cache_control. + systemContent, _ := json.Marshal([]dto.ContentBlock{ + {Type: "text", Text: "static rules", CacheControl: &dto.CacheControl{Type: "ephemeral"}}, + {Type: "text", Text: "dynamic env"}, + }) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "system", Content: systemContent}, + {Role: "user", Content: json.RawMessage(`"hi"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + // Parse as a generic map to inspect the polymorphic `system` field. + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &raw)) + require.Contains(t, raw, "system") + + var blocks []dto.AnthropicSystemBlock + require.NoError(t, json.Unmarshal(raw["system"], &blocks), + "system must serialize as block array when any block carries cache_control") + require.Len(t, blocks, 2) + assert.Equal(t, "static rules", blocks[0].Text) + require.NotNil(t, blocks[0].CacheControl) + assert.Equal(t, "ephemeral", blocks[0].CacheControl.Type) + assert.Equal(t, "dynamic env", blocks[1].Text) + assert.Nil(t, blocks[1].CacheControl) +} + +func TestAnthropicAdapter_TopLevelCacheControl(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + CacheControl: &dto.CacheControl{Type: "ephemeral"}, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var anthropicReq dto.AnthropicRequest + require.NoError(t, json.Unmarshal(body, &anthropicReq)) + require.NotNil(t, anthropicReq.CacheControl) + assert.Equal(t, "ephemeral", anthropicReq.CacheControl.Type) +} + +func TestAnthropicAdapter_LegacyStringSystem_NoCacheControl(t *testing.T) { + // Regression: plain-string system with no cache markers must still serialize + // as a STRING (not an array) — preserves wire-compat with pre-caching callers. + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "claude-sonnet-4-6", + Messages: []dto.Message{ + {Role: "system", Content: json.RawMessage(`"You are helpful."`)}, + {Role: "user", Content: json.RawMessage(`"hi"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "key") + require.NoError(t, err) + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &raw)) + + // Must be a JSON string, not an array + var asString string + require.NoError(t, json.Unmarshal(raw["system"], &asString), + "system must remain a plain JSON string when no cache_control is used") + assert.Equal(t, "You are helpful.", asString) +} diff --git a/services/gateway/services/impl/anthropic_stream.go b/services/gateway/services/impl/anthropic_stream.go new file mode 100644 index 00000000..843935af --- /dev/null +++ b/services/gateway/services/impl/anthropic_stream.go @@ -0,0 +1,286 @@ +package impl + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services" +) + +type anthropicStreamState struct { + messageID string + model string + inputTokens int + cacheCreationTokens int + cacheReadTokens int + currentBlockType string + toolCallIndex int + blockToToolIndex map[int]int + blockToToolID map[int]string + hasEmittedRole bool +} + +// TranslateStream reads Anthropic SSE events and sends Chat Completions chunks. +func (a *AnthropicAdapter) TranslateStream(ctx context.Context, providerBody io.Reader, chunks chan<- *dto.ChatCompletionChunk) error { + defer close(chunks) + + state := &anthropicStreamState{ + blockToToolIndex: make(map[int]int), + blockToToolID: make(map[int]string), + } + + return services.ParseSSEStream(ctx, providerBody, func(evt services.SSEEvent) error { + return a.handleAnthropicEvent(state, evt, chunks) + }) +} + +func (a *AnthropicAdapter) handleAnthropicEvent(state *anthropicStreamState, evt services.SSEEvent, chunks chan<- *dto.ChatCompletionChunk) error { + handler := func() error { + switch evt.Event { + case "message_start": + return a.handleMessageStart(state, evt.Data, chunks) + case "content_block_start": + return a.handleContentBlockStart(state, evt.Data, chunks) + case "content_block_delta": + return a.handleContentBlockDelta(state, evt.Data, chunks) + case "content_block_stop": + state.currentBlockType = "" + return nil + case "message_delta": + return a.handleMessageDelta(state, evt.Data, chunks) + case "message_stop": + chunks <- nil // signals [DONE] + return nil + case "ping": + chunks <- &dto.ChatCompletionChunk{Object: "ping"} + return nil + case "error": + return a.handleError(state, evt.Data, chunks) + default: + return nil + } + } + + if err := handler(); err != nil { + a.logger.Error("stream event handling failed", + zap.String("event_type", evt.Event), + zap.Int("payload_bytes", len(evt.Data)), + zap.Error(err), + ) + return err + } + return nil +} + +func (a *AnthropicAdapter) handleMessageStart(state *anthropicStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var msg dto.MessageStartData + if err := json.Unmarshal(data, &msg); err != nil { + return fmt.Errorf("unmarshal message_start: %w", err) + } + + state.messageID = msg.Message.ID + state.model = msg.Message.Model + if msg.Message.Usage != nil { + state.inputTokens = msg.Message.Usage.InputTokens + state.cacheCreationTokens = msg.Message.Usage.CacheCreationInputTokens + state.cacheReadTokens = msg.Message.Usage.CacheReadInputTokens + } + + if !state.hasEmittedRole { + state.hasEmittedRole = true + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Role: "assistant"}, + }}, + } + } + + return nil +} + +func (a *AnthropicAdapter) handleContentBlockStart(state *anthropicStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var block dto.ContentBlockStartData + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("unmarshal content_block_start: %w", err) + } + + state.currentBlockType = block.ContentBlock.Type + + switch block.ContentBlock.Type { + case "tool_use": + idx := state.toolCallIndex + state.blockToToolIndex[block.Index] = idx + state.blockToToolID[block.Index] = block.ContentBlock.ID + state.toolCallIndex++ + + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{ + ToolCalls: []dto.ToolCall{{ + Index: intPtr(idx), + ID: block.ContentBlock.ID, + Type: "function", + Function: dto.FunctionCall{ + Name: block.ContentBlock.Name, + Arguments: "", + }, + }}, + }, + }}, + } + case "thinking": + // Record type, deltas will follow + case "text": + // Record type, deltas will follow + } + + return nil +} + +func (a *AnthropicAdapter) handleContentBlockDelta(state *anthropicStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var delta dto.ContentBlockDeltaData + if err := json.Unmarshal(data, &delta); err != nil { + return fmt.Errorf("unmarshal content_block_delta: %w", err) + } + + switch delta.Delta.Type { + case "text_delta": + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Content: delta.Delta.Text}, + }}, + } + + case "input_json_delta": + toolIdx, ok := state.blockToToolIndex[delta.Index] + if !ok { + toolIdx = 0 + } + toolID := state.blockToToolID[delta.Index] + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{ + ToolCalls: []dto.ToolCall{{ + Index: intPtr(toolIdx), + ID: toolID, + Function: dto.FunctionCall{ + Arguments: delta.Delta.PartialJSON, + }, + }}, + }, + }}, + } + + case "thinking_delta": + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Thinking: delta.Delta.Thinking}, + }}, + } + + case "signature_delta": + // Drop + } + + return nil +} + +func (a *AnthropicAdapter) handleMessageDelta(state *anthropicStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var msgDelta dto.MessageDeltaData + if err := json.Unmarshal(data, &msgDelta); err != nil { + return fmt.Errorf("unmarshal message_delta: %w", err) + } + + finishReason := mapAnthropicStopReason(msgDelta.Delta.StopReason) + + var usage *dto.Usage + if msgDelta.Usage != nil { + // Prefer non-zero delta values over message_start values. + if msgDelta.Usage.CacheCreationInputTokens > 0 { + state.cacheCreationTokens = msgDelta.Usage.CacheCreationInputTokens + } + if msgDelta.Usage.CacheReadInputTokens > 0 { + state.cacheReadTokens = msgDelta.Usage.CacheReadInputTokens + } + // Anthropic's input_tokens excludes cached tokens. Include cache_read + // + cache_creation in prompt/total so all providers report consistent + // totals (OpenAI already includes cached in prompt_tokens). + totalInput := state.inputTokens + state.cacheReadTokens + state.cacheCreationTokens + usage = &dto.Usage{ + PromptTokens: totalInput, + CompletionTokens: msgDelta.Usage.OutputTokens, + TotalTokens: totalInput + msgDelta.Usage.OutputTokens, + } + if state.cacheCreationTokens > 0 || state.cacheReadTokens > 0 { + usage.PromptTokensDetails = &dto.PromptTokensDetails{ + CachedTokens: state.cacheReadTokens, + CacheCreationTokens: state.cacheCreationTokens, + } + } + } + + chunks <- &dto.ChatCompletionChunk{ + ID: state.messageID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{}, + FinishReason: &finishReason, + }}, + Usage: usage, + } + + return nil +} + +func (a *AnthropicAdapter) handleError(state *anthropicStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var errData dto.AnthropicErrorData + if err := json.Unmarshal(data, &errData); err != nil { + return fmt.Errorf("unmarshal error: %w", err) + } + + return fmt.Errorf("anthropic error: %s - %s", errData.Error.Type, errData.Error.Message) +} + +func intPtr(i int) *int { return &i } + +func mapAnthropicStopReason(reason string) string { + switch reason { + case "end_turn": + return "stop" + case "tool_use": + return "tool_calls" + case "max_tokens": + return "length" + case "stop_sequence": + return "stop" + default: + return "stop" + } +} diff --git a/services/gateway/services/impl/anthropic_stream_test.go b/services/gateway/services/impl/anthropic_stream_test.go new file mode 100644 index 00000000..d7bc1b02 --- /dev/null +++ b/services/gateway/services/impl/anthropic_stream_test.go @@ -0,0 +1,338 @@ +package impl + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +func collectChunks(t *testing.T, adapter *AnthropicAdapter, sseData string) []*dto.ChatCompletionChunk { + t.Helper() + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + return result +} + +func TestAnthropicStream_TextResponse(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_123","model":"claude-sonnet-4-6","usage":{"input_tokens":10}}} + +event: content_block_start +data: {"index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"text_delta","text":" world"}} + +event: content_block_stop +data: {"index":0} + +event: message_delta +data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + +event: message_stop +data: {} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Should have: role chunk, "Hello", " world", finish chunk, nil (DONE) + require.GreaterOrEqual(t, len(result), 4) + + // First chunk: role + assert.Equal(t, "assistant", result[0].Choices[0].Delta.Role) + assert.Equal(t, "msg_123", result[0].ID) + + // Text deltas + assert.Equal(t, "Hello", result[1].Choices[0].Delta.Content) + assert.Equal(t, " world", result[2].Choices[0].Delta.Content) + + // Finish chunk + require.NotNil(t, result[3].Choices[0].FinishReason) + assert.Equal(t, "stop", *result[3].Choices[0].FinishReason) + require.NotNil(t, result[3].Usage) + assert.Equal(t, 10, result[3].Usage.PromptTokens) + assert.Equal(t, 5, result[3].Usage.CompletionTokens) + + // DONE sentinel + assert.Nil(t, result[4]) +} + +func TestAnthropicStream_ToolUse(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_456","model":"claude-sonnet-4-6","usage":{"input_tokens":15}}} + +event: content_block_start +data: {"index":0,"content_block":{"type":"tool_use","id":"toolu_123","name":"get_weather"}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"input_json_delta","partial_json":"{\"loc"}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"input_json_delta","partial_json":"ation\":\"NYC\"}"}} + +event: content_block_stop +data: {"index":0} + +event: message_delta +data: {"delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}} + +event: message_stop +data: {} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + require.GreaterOrEqual(t, len(result), 4) + + // Role chunk + assert.Equal(t, "assistant", result[0].Choices[0].Delta.Role) + + // Tool call start + require.Len(t, result[1].Choices[0].Delta.ToolCalls, 1) + assert.Equal(t, "toolu_123", result[1].Choices[0].Delta.ToolCalls[0].ID) + assert.Equal(t, "get_weather", result[1].Choices[0].Delta.ToolCalls[0].Function.Name) + + // Finish + finishChunk := result[len(result)-2] + require.NotNil(t, finishChunk.Choices[0].FinishReason) + assert.Equal(t, "tool_calls", *finishChunk.Choices[0].FinishReason) +} + +func TestAnthropicStream_ThinkingDeltas(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_789","model":"claude-sonnet-4-6","usage":{"input_tokens":10}}} + +event: content_block_start +data: {"index":0,"content_block":{"type":"thinking"}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"thinking_delta","thinking":"Let me think..."}} + +event: content_block_stop +data: {"index":0} + +event: content_block_start +data: {"index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"index":1,"delta":{"type":"text_delta","text":"Here's my answer"}} + +event: content_block_stop +data: {"index":1} + +event: message_delta +data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":30}} + +event: message_stop +data: {} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Find thinking chunk + var foundThinking bool + for _, chunk := range result { + if chunk != nil && len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Thinking != "" { + assert.Equal(t, "Let me think...", chunk.Choices[0].Delta.Thinking) + foundThinking = true + } + } + assert.True(t, foundThinking, "should have a thinking delta chunk") +} + +func TestAnthropicStream_Ping(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: ping +data: {} + +event: message_start +data: {"type":"message_start","message":{"id":"msg_100","model":"claude-sonnet-4-6","usage":{"input_tokens":5}}} + +event: message_delta +data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Ping should be forwarded as sentinel, followed by role + finish + done + require.GreaterOrEqual(t, len(result), 4) + assert.Equal(t, "ping", result[0].Object) + assert.Equal(t, "assistant", result[1].Choices[0].Delta.Role) +} + +func TestAnthropicStream_MaxTokens(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_200","model":"claude-sonnet-4-6","usage":{"input_tokens":5}}} + +event: message_delta +data: {"delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":100}} + +event: message_stop +data: {} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Find finish chunk + for _, chunk := range result { + if chunk != nil && len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != nil { + assert.Equal(t, "length", *chunk.Choices[0].FinishReason) + return + } + } + t.Fatal("no finish chunk with length reason found") +} + +// Prompt caching telemetry tests (Stage 2) + +func TestAnthropicStream_CacheTokensSurfacedInUsage(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_cache","model":"claude-sonnet-4-6","usage":{"input_tokens":12,"cache_creation_input_tokens":500,"cache_read_input_tokens":3000}}} + +event: content_block_start +data: {"index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"text_delta","text":"cached!"}} + +event: content_block_stop +data: {"index":0} + +event: message_delta +data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}} + +event: message_stop +data: {} + +` + + result := collectChunks(t, adapter, sseData) + + // Locate the final usage-bearing chunk + var finalUsage *dto.Usage + for _, c := range result { + if c != nil && c.Usage != nil { + finalUsage = c.Usage + } + } + require.NotNil(t, finalUsage, "expected a Usage chunk") + require.NotNil(t, finalUsage.PromptTokensDetails, + "PromptTokensDetails must be populated when cache activity occurred") + assert.Equal(t, 3000, finalUsage.PromptTokensDetails.CachedTokens, + "cache_read_input_tokens should map to cached_tokens") + assert.Equal(t, 500, finalUsage.PromptTokensDetails.CacheCreationTokens, + "cache_creation_input_tokens should map to cache_creation_tokens") +} + +func TestAnthropicStream_NoCacheActivity_OmitsDetails(t *testing.T) { + adapter := NewAnthropicAdapter("https://api.anthropic.com", "2023-06-01", 8192, "", zap.NewNop()) + + // Same shape as the basic text response test — zero cache fields. + sseData := `event: message_start +data: {"type":"message_start","message":{"id":"msg_nocache","model":"claude-sonnet-4-6","usage":{"input_tokens":10}}} + +event: content_block_start +data: {"index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"index":0} + +event: message_delta +data: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}} + +event: message_stop +data: {} + +` + + result := collectChunks(t, adapter, sseData) + + var finalUsage *dto.Usage + for _, c := range result { + if c != nil && c.Usage != nil { + finalUsage = c.Usage + } + } + require.NotNil(t, finalUsage) + assert.Nil(t, finalUsage.PromptTokensDetails, + "PromptTokensDetails must be nil when no cache activity occurred") +} diff --git a/services/gateway/services/impl/openai_compat_adapter.go b/services/gateway/services/impl/openai_compat_adapter.go new file mode 100644 index 00000000..f79ca4ee --- /dev/null +++ b/services/gateway/services/impl/openai_compat_adapter.go @@ -0,0 +1,105 @@ +package impl + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +// OpenAICompatAdapter is a pure passthrough for any OpenAI-compatible endpoint +// (vLLM, Ollama, TGI, etc). No translation — just proxies Chat Completions requests. +type OpenAICompatAdapter struct { + baseURL string + name string + apiKey string + prefixes []string + logger *zap.Logger +} + +func NewOpenAICompatAdapter(name, baseURL, apiKey string, prefixes []string, logger *zap.Logger) *OpenAICompatAdapter { + return &OpenAICompatAdapter{ + baseURL: strings.TrimRight(baseURL, "/"), + name: name, + apiKey: apiKey, + prefixes: prefixes, + logger: logger.Named("gateway.openai-compat." + name), + } +} + +func (o *OpenAICompatAdapter) ConfiguredAPIKey() string { + return o.apiKey +} + +func (o *OpenAICompatAdapter) Name() string { + return o.name +} + +func (o *OpenAICompatAdapter) MatchesModel(model string) bool { + for _, p := range o.prefixes { + if strings.HasPrefix(model, p) { + return true + } + } + return false +} + +func (o *OpenAICompatAdapter) TranslateRequest(req *dto.ChatCompletionRequest, apiKey string) (string, map[string]string, []byte, error) { + body, err := json.Marshal(req) + if err != nil { + return "", nil, nil, fmt.Errorf("marshal request: %w", err) + } + + url := o.baseURL + "/chat/completions" + headers := map[string]string{ + "Content-Type": "application/json", + } + if apiKey != "" { + headers["Authorization"] = "Bearer " + apiKey + } + + return url, headers, body, nil +} + +// TranslateStream parses standard OpenAI SSE and forwards chunks unchanged. +func (o *OpenAICompatAdapter) TranslateStream(ctx context.Context, providerBody io.Reader, chunks chan<- *dto.ChatCompletionChunk) error { + defer close(chunks) + + scanner := bufio.NewScanner(providerBody) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + chunks <- nil + return nil + } + + var chunk dto.ChatCompletionChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + o.logger.Warn("failed to parse chunk", zap.Error(err)) + continue + } + + chunks <- &chunk + } + + return scanner.Err() +} diff --git a/services/gateway/services/impl/openai_compat_adapter_test.go b/services/gateway/services/impl/openai_compat_adapter_test.go new file mode 100644 index 00000000..0d94c79b --- /dev/null +++ b/services/gateway/services/impl/openai_compat_adapter_test.go @@ -0,0 +1,130 @@ +package impl + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +func TestOpenAICompatAdapter_Name(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-"}, zap.NewNop()) + assert.Equal(t, "local", adapter.Name()) +} + +func TestOpenAICompatAdapter_MatchesModel(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-", "deepseek-"}, zap.NewNop()) + assert.True(t, adapter.MatchesModel("llama-3.1")) + assert.True(t, adapter.MatchesModel("deepseek-v2")) + assert.False(t, adapter.MatchesModel("gpt-5.4")) +} + +func TestOpenAICompatAdapter_PassthroughRequest(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-"}, zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "llama-3.1", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Stream: true, + } + + url, headers, body, err := adapter.TranslateRequest(req, "") + require.NoError(t, err) + + assert.Equal(t, "http://localhost:11434/v1/chat/completions", url) + assert.Equal(t, "application/json", headers["Content-Type"]) + _, hasAuth := headers["Authorization"] + assert.False(t, hasAuth, "no auth header when key is empty") + + // Body should be the original request marshaled as-is + var roundTripped dto.ChatCompletionRequest + require.NoError(t, json.Unmarshal(body, &roundTripped)) + assert.Equal(t, "llama-3.1", roundTripped.Model) + assert.True(t, roundTripped.Stream) +} + +func TestOpenAICompatAdapter_WithAPIKey(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-"}, zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "llama-3.1", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + _, headers, _, err := adapter.TranslateRequest(req, "my-key") + require.NoError(t, err) + assert.Equal(t, "Bearer my-key", headers["Authorization"]) +} + +func TestOpenAICompatAdapter_StreamPassthrough(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-"}, zap.NewNop()) + + sseData := `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","model":"llama-3.1","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","model":"llama-3.1","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]} + +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","model":"llama-3.1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // role, "Hi", finish, nil (DONE) + require.Len(t, result, 4) + + assert.Equal(t, "assistant", result[0].Choices[0].Delta.Role) + assert.Equal(t, "Hi", result[1].Choices[0].Delta.Content) + require.NotNil(t, result[2].Choices[0].FinishReason) + assert.Equal(t, "stop", *result[2].Choices[0].FinishReason) + assert.Nil(t, result[3]) +} + +// Verify cached_tokens survives passthrough. + +func TestOpenAICompatAdapter_PreservesCachedTokens(t *testing.T) { + adapter := NewOpenAICompatAdapter("local", "http://localhost:11434/v1", "", []string{"llama-"}, zap.NewNop()) + + sseData := `data: {"id":"c1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"c1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2006,"completion_tokens":300,"total_tokens":2306,"prompt_tokens_details":{"cached_tokens":1920}}} + +data: [DONE] + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var finalUsage *dto.Usage + for c := range chunks { + if c != nil && c.Usage != nil { + finalUsage = c.Usage + } + } + require.NotNil(t, finalUsage) + require.NotNil(t, finalUsage.PromptTokensDetails, + "passthrough must not drop prompt_tokens_details") + assert.Equal(t, 1920, finalUsage.PromptTokensDetails.CachedTokens) +} diff --git a/services/gateway/services/impl/openai_responses_adapter.go b/services/gateway/services/impl/openai_responses_adapter.go new file mode 100644 index 00000000..b681beca --- /dev/null +++ b/services/gateway/services/impl/openai_responses_adapter.go @@ -0,0 +1,262 @@ +package impl + +import ( + "encoding/json" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +// OpenAIResponsesAdapter translates Chat Completions format to/from OpenAI Responses API. +type OpenAIResponsesAdapter struct { + baseURL string + apiKey string + logger *zap.Logger +} + +func NewOpenAIResponsesAdapter(baseURL, apiKey string, logger *zap.Logger) *OpenAIResponsesAdapter { + return &OpenAIResponsesAdapter{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + logger: logger.Named("gateway.openai-responses"), + } +} + +func (o *OpenAIResponsesAdapter) ConfiguredAPIKey() string { + return o.apiKey +} + +func (o *OpenAIResponsesAdapter) Name() string { + return "openai" +} + +func (o *OpenAIResponsesAdapter) MatchesModel(model string) bool { + prefixes := []string{"gpt-", "o1-", "o3-", "o4-", "chatgpt-"} + for _, p := range prefixes { + if strings.HasPrefix(model, p) { + return true + } + } + return false +} + +func (o *OpenAIResponsesAdapter) TranslateRequest(req *dto.ChatCompletionRequest, apiKey string) (string, map[string]string, []byte, error) { + respReq := dto.ResponsesRequest{ + Model: req.Model, + Stream: true, + Store: false, + } + + // Extract system messages → instructions + var instructions []string + var inputItems []json.RawMessage + + for _, msg := range req.Messages { + if msg.Role == "system" { + text, _, _ := dto.ParseMessageContent(msg.Content) + if text != "" { + instructions = append(instructions, text) + } + continue + } + + items, err := translateMessageToResponsesInput(msg) + if err != nil { + return "", nil, nil, fmt.Errorf("translate message: %w", err) + } + inputItems = append(inputItems, items...) + } + + if len(instructions) > 0 { + respReq.Instructions = strings.Join(instructions, "\n\n") + } + + inputBytes, err := json.Marshal(inputItems) + if err != nil { + return "", nil, nil, fmt.Errorf("marshal input: %w", err) + } + respReq.Input = inputBytes + + // Tools + if len(req.Tools) > 0 { + for _, t := range req.Tools { + respReq.Tools = append(respReq.Tools, dto.ResponsesTool{ + Type: "function", + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }) + } + } + + // Max tokens + if req.MaxCompletionTokens != nil { + respReq.MaxOutputTokens = req.MaxCompletionTokens + } + + // Temperature + if req.Temperature != nil { + respReq.Temperature = req.Temperature + } + + // Tool choice + if len(req.ToolChoice) > 0 { + respReq.ToolChoice = translateToolChoiceForResponses(req.ToolChoice) + } + + // Thinking → reasoning + if req.Thinking != nil && req.Thinking.BudgetTokens > 0 { + effort := "high" + if req.Thinking.BudgetTokens < 5000 { + effort = "low" + } else if req.Thinking.BudgetTokens < 20000 { + effort = "medium" + } + respReq.Reasoning = &dto.ResponsesReasoning{ + Effort: effort, + Summary: "auto", + } + } + + body, err := json.Marshal(respReq) + if err != nil { + return "", nil, nil, fmt.Errorf("marshal request: %w", err) + } + + url := o.baseURL + "/responses" + headers := map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + apiKey, + } + + return url, headers, body, nil +} + +func translateMessageToResponsesInput(msg dto.Message) ([]json.RawMessage, error) { + var items []json.RawMessage + + switch msg.Role { + case "user": + item := map[string]any{ + "role": "user", + } + text, blocks, _ := dto.ParseMessageContent(msg.Content) + if len(blocks) > 0 { + var contentItems []map[string]any + for _, b := range blocks { + switch b.Type { + case "text": + contentItems = append(contentItems, map[string]any{ + "type": "input_text", + "text": b.Text, + }) + case "image_url": + if b.ImageURL != nil { + contentItems = append(contentItems, map[string]any{ + "type": "input_image", + "image_url": b.ImageURL.URL, + }) + } + } + } + item["content"] = contentItems + } else { + item["content"] = text + } + raw, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("marshal user input item: %w", err) + } + items = append(items, raw) + + case "assistant": + text, _, _ := dto.ParseMessageContent(msg.Content) + if text != "" { + raw, err := json.Marshal(map[string]any{ + "role": "assistant", + "content": text, + }) + if err != nil { + return nil, fmt.Errorf("marshal assistant input item: %w", err) + } + items = append(items, raw) + } + // Convert tool_calls to function_call items + for _, tc := range msg.ToolCalls { + args := tc.Function.Arguments + if args == "" || !json.Valid([]byte(args)) { + args = "{}" + } + raw, err := json.Marshal(dto.FunctionCallItem{ + Type: "function_call", + CallID: sanitizeToolID(tc.ID), + Name: tc.Function.Name, + Arguments: args, + }) + if err != nil { + return nil, fmt.Errorf("marshal function call item: %w", err) + } + items = append(items, raw) + } + + case "tool": + text, _, _ := dto.ParseMessageContent(msg.Content) + raw, err := json.Marshal(dto.FunctionCallOutputItem{ + Type: "function_call_output", + CallID: sanitizeToolID(msg.ToolCallID), + Output: text, + }) + if err != nil { + return nil, fmt.Errorf("marshal function call output item: %w", err) + } + items = append(items, raw) + + case "developer": + text, _, _ := dto.ParseMessageContent(msg.Content) + raw, err := json.Marshal(map[string]any{ + "role": "developer", + "content": text, + }) + if err != nil { + return nil, fmt.Errorf("marshal developer input item: %w", err) + } + items = append(items, raw) + } + + return items, nil +} + +func translateToolChoiceForResponses(raw json.RawMessage) json.RawMessage { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + // "auto", "required", "none" pass through directly as strings + result, err := json.Marshal(s) + if err != nil { + return raw + } + return result + } + + // Object form: {type:"function", function:{name:"X"}} → {type:"function", name:"X"} + var obj struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &obj); err == nil && obj.Function.Name != "" { + result, err := json.Marshal(map[string]string{ + "type": "function", + "name": obj.Function.Name, + }) + if err != nil { + return raw + } + return result + } + + return raw +} diff --git a/services/gateway/services/impl/openai_responses_adapter_test.go b/services/gateway/services/impl/openai_responses_adapter_test.go new file mode 100644 index 00000000..50439356 --- /dev/null +++ b/services/gateway/services/impl/openai_responses_adapter_test.go @@ -0,0 +1,433 @@ +package impl + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +func TestOpenAIResponsesAdapter_Name(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + assert.Equal(t, "openai", adapter.Name()) +} + +func TestOpenAIResponsesAdapter_MatchesModel(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + assert.True(t, adapter.MatchesModel("gpt-5.4")) + assert.True(t, adapter.MatchesModel("o3-mini")) + assert.True(t, adapter.MatchesModel("o4-mini")) + assert.False(t, adapter.MatchesModel("claude-sonnet-4-6")) +} + +func TestOpenAIResponsesAdapter_BasicRequest(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Stream: true, + } + + url, headers, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + assert.Equal(t, "https://api.openai.com/v1/responses", url) + assert.Equal(t, "Bearer sk-test", headers["Authorization"]) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + assert.Equal(t, "gpt-5.4", respReq.Model) + assert.True(t, respReq.Stream) + assert.False(t, respReq.Store) +} + +func TestOpenAIResponsesAdapter_SystemInstructions(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "system", Content: json.RawMessage(`"You are helpful."`)}, + {Role: "system", Content: json.RawMessage(`"Be concise."`)}, + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + assert.Equal(t, "You are helpful.\n\nBe concise.", respReq.Instructions) +} + +func TestOpenAIResponsesAdapter_ToolCalls(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"What's the weather?"`)}, + }, + Tools: []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{ + Name: "get_weather", + Description: "Get weather", + Parameters: json.RawMessage(`{"type":"object"}`), + }, + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + require.Len(t, respReq.Tools, 1) + assert.Equal(t, "function", respReq.Tools[0].Type) + assert.Equal(t, "get_weather", respReq.Tools[0].Name) +} + +func TestOpenAIResponsesAdapter_ToolResults(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"What's the weather?"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + { + ID: "call_abc", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"city":"NYC"}`, + }, + }, + }, + }, + { + Role: "tool", + Content: json.RawMessage(`"72°F"`), + ToolCallID: "call_abc", + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + // Input should contain: user message, function_call item, function_call_output item + var items []json.RawMessage + require.NoError(t, json.Unmarshal(respReq.Input, &items)) + require.Len(t, items, 3) + + // Check function_call item + var fcItem dto.FunctionCallItem + require.NoError(t, json.Unmarshal(items[1], &fcItem)) + assert.Equal(t, "function_call", fcItem.Type) + assert.Equal(t, "call_abc", fcItem.CallID) + + // Check function_call_output item + var fcoItem dto.FunctionCallOutputItem + require.NoError(t, json.Unmarshal(items[2], &fcoItem)) + assert.Equal(t, "function_call_output", fcoItem.Type) + assert.Equal(t, "call_abc", fcoItem.CallID) + assert.Equal(t, "72°F", fcoItem.Output) +} + +func TestOpenAIResponsesAdapter_MaxTokens(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + maxTokens := 1024 + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + MaxCompletionTokens: &maxTokens, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + require.NotNil(t, respReq.MaxOutputTokens) + assert.Equal(t, 1024, *respReq.MaxOutputTokens) +} + +func TestOpenAIResponsesAdapter_Thinking(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "o3", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Solve this"`)}, + }, + Thinking: &dto.ThinkingConfig{ + BudgetTokens: 30000, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + require.NotNil(t, respReq.Reasoning) + assert.Equal(t, "high", respReq.Reasoning.Effort) + assert.Equal(t, "auto", respReq.Reasoning.Summary) +} + +func TestOpenAIResponsesAdapter_ThinkingMedium(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "o3", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Thinking: &dto.ThinkingConfig{ + BudgetTokens: 10000, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + require.NotNil(t, respReq.Reasoning) + assert.Equal(t, "medium", respReq.Reasoning.Effort) +} + +func TestOpenAIResponsesAdapter_ToolChoiceString(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + ToolChoice: json.RawMessage(`"auto"`), + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var tc string + require.NoError(t, json.Unmarshal(respReq.ToolChoice, &tc)) + assert.Equal(t, "auto", tc) +} + +func TestOpenAIResponsesAdapter_ToolChoiceObject(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + ToolChoice: json.RawMessage(`{"type":"function","function":{"name":"get_weather"}}`), + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var tc map[string]string + require.NoError(t, json.Unmarshal(respReq.ToolChoice, &tc)) + assert.Equal(t, "function", tc["type"]) + assert.Equal(t, "get_weather", tc["name"]) +} + +func TestOpenAIResponsesAdapter_ThinkingLow(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "o3", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Thinking: &dto.ThinkingConfig{BudgetTokens: 2000}, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + require.NotNil(t, respReq.Reasoning) + assert.Equal(t, "low", respReq.Reasoning.Effort) +} + +func TestOpenAIResponsesAdapter_DeveloperRole(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "developer", Content: json.RawMessage(`"You must always respond in JSON."`)}, + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var items []json.RawMessage + require.NoError(t, json.Unmarshal(respReq.Input, &items)) + require.Len(t, items, 2) + + var devMsg map[string]any + require.NoError(t, json.Unmarshal(items[0], &devMsg)) + assert.Equal(t, "developer", devMsg["role"]) +} + +func TestOpenAIResponsesAdapter_ImageContent(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + { + Role: "user", + Content: json.RawMessage(`[ + {"type":"text","text":"What's this?"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}} + ]`), + }, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var items []json.RawMessage + require.NoError(t, json.Unmarshal(respReq.Input, &items)) + require.Len(t, items, 1) + + var msg map[string]any + require.NoError(t, json.Unmarshal(items[0], &msg)) + content := msg["content"].([]any) + require.Len(t, content, 2) + + imgItem := content[1].(map[string]any) + assert.Equal(t, "input_image", imgItem["type"]) + assert.Equal(t, "data:image/png;base64,abc123", imgItem["image_url"]) +} + +func TestOpenAIResponsesAdapter_TruncatedToolCallArguments(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Do something"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + {ID: "call_1", Type: "function", Function: dto.FunctionCall{Name: "run_tool", Arguments: `{"city":"NY`}}, + }, + }, + {Role: "tool", Content: json.RawMessage(`"result"`), ToolCallID: "call_1"}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var items []json.RawMessage + require.NoError(t, json.Unmarshal(respReq.Input, &items)) + require.Len(t, items, 3) + + var fcItem dto.FunctionCallItem + require.NoError(t, json.Unmarshal(items[1], &fcItem)) + assert.Equal(t, "function_call", fcItem.Type) + assert.Equal(t, "{}", fcItem.Arguments) +} + +func TestOpenAIResponsesAdapter_ToolIDSanitization(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Do something"`)}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + {ID: "call!@#abc", Type: "function", Function: dto.FunctionCall{Name: "run_tool", Arguments: `{"a":"b"}`}}, + }, + }, + {Role: "tool", Content: json.RawMessage(`"result"`), ToolCallID: "call!@#abc"}, + }, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + + var items []json.RawMessage + require.NoError(t, json.Unmarshal(respReq.Input, &items)) + require.Len(t, items, 3) + + var fcItem dto.FunctionCallItem + require.NoError(t, json.Unmarshal(items[1], &fcItem)) + assert.Equal(t, "call___abc", fcItem.CallID) + + var fcoItem dto.FunctionCallOutputItem + require.NoError(t, json.Unmarshal(items[2], &fcoItem)) + assert.Equal(t, "call___abc", fcoItem.CallID) +} + +func TestOpenAIResponsesAdapter_NoThinkingWhenZeroBudget(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + req := &dto.ChatCompletionRequest{ + Model: "gpt-5.4", + Messages: []dto.Message{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Thinking: &dto.ThinkingConfig{BudgetTokens: 0}, + } + + _, _, body, err := adapter.TranslateRequest(req, "sk-test") + require.NoError(t, err) + + var respReq dto.ResponsesRequest + require.NoError(t, json.Unmarshal(body, &respReq)) + assert.Nil(t, respReq.Reasoning, "zero budget should not set reasoning") +} diff --git a/services/gateway/services/impl/openai_responses_stream.go b/services/gateway/services/impl/openai_responses_stream.go new file mode 100644 index 00000000..4f7109f3 --- /dev/null +++ b/services/gateway/services/impl/openai_responses_stream.go @@ -0,0 +1,266 @@ +package impl + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" + "github.com/TransformerOptimus/SuperCoder/services/gateway/services" +) + +type responsesStreamState struct { + responseID string + model string + toolCallIndex int + itemIndexToToolIndex map[int]int + itemIndexToCallID map[int]string + hasEmittedRole bool +} + +// TranslateStream reads OpenAI Responses SSE events and sends Chat Completions chunks. +func (o *OpenAIResponsesAdapter) TranslateStream(ctx context.Context, providerBody io.Reader, chunks chan<- *dto.ChatCompletionChunk) error { + defer close(chunks) + + state := &responsesStreamState{ + itemIndexToToolIndex: make(map[int]int), + itemIndexToCallID: make(map[int]string), + } + + return services.ParseSSEStream(ctx, providerBody, func(evt services.SSEEvent) error { + return o.handleResponsesEvent(state, evt, chunks) + }) +} + +func (o *OpenAIResponsesAdapter) handleResponsesEvent(state *responsesStreamState, evt services.SSEEvent, chunks chan<- *dto.ChatCompletionChunk) error { + switch evt.Event { + case "response.created": + return o.handleResponseCreated(state, evt.Data, chunks) + case "response.output_item.added": + return o.handleOutputItemAdded(state, evt.Data, chunks) + case "response.output_text.delta": + return o.handleOutputTextDelta(state, evt.Data, chunks) + case "response.function_call_arguments.delta": + return o.handleFunctionCallArgsDelta(state, evt.Data, chunks) + case "response.reasoning_summary_text.delta": + return o.handleReasoningSummaryDelta(state, evt.Data, chunks) + case "response.completed": + return o.handleResponseCompleted(state, evt.Data, chunks) + case "response.failed": + return o.handleResponseFailed(state, evt.Data, chunks) + case "response.incomplete": + return o.handleResponseIncomplete(state, chunks) + default: + // Drop: content_part.added/done, output_item.done, etc. + return nil + } +} + +func (o *OpenAIResponsesAdapter) handleResponseCreated(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.ResponseCreatedEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.created: %w", err) + } + + state.responseID = evt.Response.ID + state.model = evt.Response.Model + + if !state.hasEmittedRole { + state.hasEmittedRole = true + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Role: "assistant"}, + }}, + } + } + + return nil +} + +func (o *OpenAIResponsesAdapter) handleOutputItemAdded(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.OutputItemAddedEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.output_item.added: %w", err) + } + + if evt.Item.Type == "function_call" { + idx := state.toolCallIndex + state.itemIndexToToolIndex[evt.OutputIndex] = idx + state.itemIndexToCallID[evt.OutputIndex] = evt.Item.CallID + state.toolCallIndex++ + + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{ + ToolCalls: []dto.ToolCall{{ + Index: intPtr(idx), + ID: evt.Item.CallID, + Type: "function", + Function: dto.FunctionCall{ + Name: evt.Item.Name, + Arguments: "", + }, + }}, + }, + }}, + } + } + + return nil +} + +func (o *OpenAIResponsesAdapter) handleOutputTextDelta(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.OutputTextDeltaEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.output_text.delta: %w", err) + } + + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Content: evt.Delta}, + }}, + } + + return nil +} + +func (o *OpenAIResponsesAdapter) handleFunctionCallArgsDelta(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.FunctionCallArgsDeltaEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.function_call_arguments.delta: %w", err) + } + + toolIdx := state.itemIndexToToolIndex[evt.OutputIndex] + callID := state.itemIndexToCallID[evt.OutputIndex] + + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{ + ToolCalls: []dto.ToolCall{{ + Index: intPtr(toolIdx), + ID: callID, + Function: dto.FunctionCall{ + Arguments: evt.Delta, + }, + }}, + }, + }}, + } + + return nil +} + +func (o *OpenAIResponsesAdapter) handleReasoningSummaryDelta(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.ReasoningSummaryDeltaEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.reasoning_summary_text.delta: %w", err) + } + + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{Thinking: evt.Delta}, + }}, + } + + return nil +} + +func (o *OpenAIResponsesAdapter) handleResponseCompleted(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.ResponseCompletedEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.completed: %w", err) + } + + finishReason := "stop" + + // Check output items for function_call type → finish_reason = "tool_calls" + if len(evt.Response.Output) > 0 { + var outputItems []dto.ResponseOutputItem + if err := json.Unmarshal(evt.Response.Output, &outputItems); err == nil { + for _, item := range outputItems { + if item.Type == "function_call" { + finishReason = "tool_calls" + break + } + } + } + } + + if evt.Response.Status == "incomplete" { + finishReason = "length" + } + + var usage *dto.Usage + if evt.Response.Usage != nil { + usage = &dto.Usage{ + PromptTokens: evt.Response.Usage.InputTokens, + CompletionTokens: evt.Response.Usage.OutputTokens, + TotalTokens: evt.Response.Usage.InputTokens + evt.Response.Usage.OutputTokens, + } + if evt.Response.Usage.InputTokensDetails != nil && evt.Response.Usage.InputTokensDetails.CachedTokens > 0 { + usage.PromptTokensDetails = &dto.PromptTokensDetails{ + CachedTokens: evt.Response.Usage.InputTokensDetails.CachedTokens, + } + } + } + + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{}, + FinishReason: &finishReason, + }}, + Usage: usage, + } + + chunks <- nil // signals [DONE] + return nil +} + +func (o *OpenAIResponsesAdapter) handleResponseFailed(state *responsesStreamState, data []byte, chunks chan<- *dto.ChatCompletionChunk) error { + var evt dto.ResponseErrorEvent + if err := json.Unmarshal(data, &evt); err != nil { + return fmt.Errorf("unmarshal response.failed: %w", err) + } + return fmt.Errorf("openai error: %s - %s", evt.Code, evt.Message) +} + +func (o *OpenAIResponsesAdapter) handleResponseIncomplete(state *responsesStreamState, chunks chan<- *dto.ChatCompletionChunk) error { + finishReason := "length" + chunks <- &dto.ChatCompletionChunk{ + ID: state.responseID, + Object: "chat.completion.chunk", + Model: state.model, + Choices: []dto.ChunkChoice{{ + Index: 0, + Delta: dto.ChunkDelta{}, + FinishReason: &finishReason, + }}, + } + chunks <- nil + return nil +} diff --git a/services/gateway/services/impl/openai_responses_stream_test.go b/services/gateway/services/impl/openai_responses_stream_test.go new file mode 100644 index 00000000..8010fa10 --- /dev/null +++ b/services/gateway/services/impl/openai_responses_stream_test.go @@ -0,0 +1,220 @@ +package impl + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +func TestOpenAIResponsesStream_TextResponse(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + sseData := `event: response.created +data: {"response":{"id":"resp_123","model":"gpt-5.4"}} + +event: response.output_item.added +data: {"output_index":0,"item":{"type":"message","id":"msg_1"}} + +event: response.output_text.delta +data: {"output_index":0,"content_index":0,"delta":"Hello"} + +event: response.output_text.delta +data: {"output_index":0,"content_index":0,"delta":" world"} + +event: response.completed +data: {"response":{"id":"resp_123","model":"gpt-5.4","status":"completed","output":[{"type":"message"}],"usage":{"input_tokens":10,"output_tokens":5}}} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // role, "Hello", " world", finish, nil + require.GreaterOrEqual(t, len(result), 4) + + assert.Equal(t, "assistant", result[0].Choices[0].Delta.Role) + assert.Equal(t, "resp_123", result[0].ID) + + assert.Equal(t, "Hello", result[1].Choices[0].Delta.Content) + assert.Equal(t, " world", result[2].Choices[0].Delta.Content) + + // Finish chunk + finishIdx := len(result) - 2 + require.NotNil(t, result[finishIdx].Choices[0].FinishReason) + assert.Equal(t, "stop", *result[finishIdx].Choices[0].FinishReason) + require.NotNil(t, result[finishIdx].Usage) + assert.Equal(t, 10, result[finishIdx].Usage.PromptTokens) + assert.Equal(t, 5, result[finishIdx].Usage.CompletionTokens) + + // DONE + assert.Nil(t, result[len(result)-1]) +} + +func TestOpenAIResponsesStream_FunctionCall(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + sseData := `event: response.created +data: {"response":{"id":"resp_456","model":"gpt-5.4"}} + +event: response.output_item.added +data: {"output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"get_weather"}} + +event: response.function_call_arguments.delta +data: {"output_index":0,"delta":"{\"city\":"} + +event: response.function_call_arguments.delta +data: {"output_index":0,"delta":"\"NYC\"}"} + +event: response.completed +data: {"response":{"id":"resp_456","model":"gpt-5.4","status":"completed","output":[{"type":"function_call"}],"usage":{"input_tokens":20,"output_tokens":15}}} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + require.GreaterOrEqual(t, len(result), 4) + + // Role + assert.Equal(t, "assistant", result[0].Choices[0].Delta.Role) + + // Tool call start + require.Len(t, result[1].Choices[0].Delta.ToolCalls, 1) + assert.Equal(t, "call_abc", result[1].Choices[0].Delta.ToolCalls[0].ID) + assert.Equal(t, "get_weather", result[1].Choices[0].Delta.ToolCalls[0].Function.Name) + + // Finish with tool_calls reason + finishIdx := len(result) - 2 + require.NotNil(t, result[finishIdx].Choices[0].FinishReason) + assert.Equal(t, "tool_calls", *result[finishIdx].Choices[0].FinishReason) +} + +func TestOpenAIResponsesStream_ReasoningSummary(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + sseData := `event: response.created +data: {"response":{"id":"resp_789","model":"o3"}} + +event: response.reasoning_summary_text.delta +data: {"output_index":0,"delta":"Thinking about..."} + +event: response.output_text.delta +data: {"output_index":0,"content_index":0,"delta":"Answer"} + +event: response.completed +data: {"response":{"id":"resp_789","model":"o3","status":"completed","output":[{"type":"message"}],"usage":{"input_tokens":5,"output_tokens":10,"reasoning_tokens":50}}} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Find thinking chunk + var foundThinking bool + for _, chunk := range result { + if chunk != nil && len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Thinking != "" { + assert.Equal(t, "Thinking about...", chunk.Choices[0].Delta.Thinking) + foundThinking = true + } + } + assert.True(t, foundThinking, "should have a thinking/reasoning delta chunk") +} + +func TestOpenAIResponsesStream_Incomplete(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + sseData := `event: response.created +data: {"response":{"id":"resp_inc","model":"gpt-5.4"}} + +event: response.incomplete +data: {"response":{"id":"resp_inc","model":"gpt-5.4","status":"incomplete"}} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var result []*dto.ChatCompletionChunk + for chunk := range chunks { + result = append(result, chunk) + } + + // Should have role, length finish, nil + require.GreaterOrEqual(t, len(result), 2) + + // Find finish with length + for _, chunk := range result { + if chunk != nil && len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != nil { + assert.Equal(t, "length", *chunk.Choices[0].FinishReason) + return + } + } + t.Fatal("no finish chunk with length reason found") +} + +// Verify input_tokens_details.cached_tokens survives /responses translation. + +func TestOpenAIResponsesStream_CachedTokensSurfaced(t *testing.T) { + adapter := NewOpenAIResponsesAdapter("https://api.openai.com/v1", "", zap.NewNop()) + + sseData := `event: response.created +data: {"response":{"id":"resp_cache","model":"gpt-5.4"}} + +event: response.output_item.added +data: {"output_index":0,"item":{"type":"message","id":"msg_1"}} + +event: response.output_text.delta +data: {"output_index":0,"content_index":0,"delta":"hi"} + +event: response.completed +data: {"response":{"id":"resp_cache","model":"gpt-5.4","status":"completed","output":[{"type":"message"}],"usage":{"input_tokens":2006,"input_tokens_details":{"cached_tokens":1920},"output_tokens":300}}} + +` + + chunks := make(chan *dto.ChatCompletionChunk, 64) + reader := strings.NewReader(sseData) + err := adapter.TranslateStream(context.Background(), reader, chunks) + require.NoError(t, err) + + var finalUsage *dto.Usage + for chunk := range chunks { + if chunk != nil && chunk.Usage != nil { + finalUsage = chunk.Usage + } + } + require.NotNil(t, finalUsage, "expected a Usage chunk in responses stream") + require.NotNil(t, finalUsage.PromptTokensDetails, + "PromptTokensDetails must be set when OpenAI reports cached_tokens") + assert.Equal(t, 1920, finalUsage.PromptTokensDetails.CachedTokens) + + assert.Equal(t, 0, finalUsage.PromptTokensDetails.CacheCreationTokens) +} diff --git a/services/gateway/services/provider_adapter.go b/services/gateway/services/provider_adapter.go new file mode 100644 index 00000000..37de709b --- /dev/null +++ b/services/gateway/services/provider_adapter.go @@ -0,0 +1,17 @@ +package services + +import ( + "context" + "io" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +// ProviderAdapter translates between OpenAI Chat Completions format and a specific LLM provider. +type ProviderAdapter interface { + TranslateRequest(req *dto.ChatCompletionRequest, apiKey string) (url string, headers map[string]string, body []byte, err error) + TranslateStream(ctx context.Context, providerBody io.Reader, chunks chan<- *dto.ChatCompletionChunk) error + MatchesModel(model string) bool + Name() string + ConfiguredAPIKey() string +} diff --git a/services/gateway/services/router.go b/services/gateway/services/router.go new file mode 100644 index 00000000..e39d983d --- /dev/null +++ b/services/gateway/services/router.go @@ -0,0 +1,91 @@ +package services + +import ( + "fmt" + "sort" + "strings" + "sync" + + "go.uber.org/zap" +) + +// ModelRoute maps a model prefix to a provider name. +type ModelRoute struct { + Prefix string + ProviderName string +} + +// Router performs config-driven model→provider routing. +type Router struct { + mu sync.RWMutex + providers map[string]ProviderAdapter + modelPrefixes []ModelRoute + defaultProvider string + logger *zap.Logger +} + +func NewRouter(logger *zap.Logger, defaultProvider string) *Router { + return &Router{ + providers: make(map[string]ProviderAdapter), + defaultProvider: defaultProvider, + logger: logger.Named("gateway.router"), + } +} + +// RegisterProvider adds a provider adapter with its model prefix routes. +func (r *Router) RegisterProvider(name string, adapter ProviderAdapter, prefixes []string) { + r.mu.Lock() + defer r.mu.Unlock() + r.providers[name] = adapter + for _, p := range prefixes { + r.modelPrefixes = append(r.modelPrefixes, ModelRoute{ + Prefix: p, + ProviderName: name, + }) + } + // Sort by prefix length descending for longest-prefix match + sort.Slice(r.modelPrefixes, func(i, j int) bool { + return len(r.modelPrefixes[i].Prefix) > len(r.modelPrefixes[j].Prefix) + }) +} + +// Route selects a provider adapter for the given model. +// providerOverride (from X-Provider header) takes precedence, then longest-prefix match, +// then falls back to default provider. +func (r *Router) Route(model, providerOverride string) (ProviderAdapter, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if providerOverride != "" { + if adapter, ok := r.providers[providerOverride]; ok { + r.logger.Debug("routed by provider override", + zap.String("model", model), + zap.String("provider", providerOverride), + ) + return adapter, nil + } + return nil, fmt.Errorf("unknown provider override: %s", providerOverride) + } + + for _, route := range r.modelPrefixes { + if strings.HasPrefix(model, route.Prefix) { + if adapter, ok := r.providers[route.ProviderName]; ok { + r.logger.Debug("routed by model prefix", + zap.String("model", model), + zap.String("prefix", route.Prefix), + zap.String("provider", route.ProviderName), + ) + return adapter, nil + } + } + } + + if adapter, ok := r.providers[r.defaultProvider]; ok { + r.logger.Debug("routed to default provider", + zap.String("model", model), + zap.String("provider", r.defaultProvider), + ) + return adapter, nil + } + + return nil, fmt.Errorf("no provider found for model: %s", model) +} diff --git a/services/gateway/services/router_test.go b/services/gateway/services/router_test.go new file mode 100644 index 00000000..ce450599 --- /dev/null +++ b/services/gateway/services/router_test.go @@ -0,0 +1,140 @@ +package services + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/TransformerOptimus/SuperCoder/services/gateway/models/dto" +) + +type mockAdapter struct { + name string +} + +func (m *mockAdapter) TranslateRequest(req *dto.ChatCompletionRequest, apiKey string) (string, map[string]string, []byte, error) { + return "", nil, nil, nil +} + +func (m *mockAdapter) TranslateStream(ctx context.Context, body io.Reader, chunks chan<- *dto.ChatCompletionChunk) error { + return nil +} + +func (m *mockAdapter) MatchesModel(model string) bool { + return false +} + +func (m *mockAdapter) Name() string { + return m.name +} + +func (m *mockAdapter) ConfiguredAPIKey() string { + return "" +} + +func TestRouterPrefixMatch(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "openai") + + anthropic := &mockAdapter{name: "anthropic"} + openai := &mockAdapter{name: "openai"} + + router.RegisterProvider("anthropic", anthropic, []string{"claude-"}) + router.RegisterProvider("openai", openai, []string{"gpt-5", "o3", "o4"}) + + tests := []struct { + model string + expected string + }{ + {"claude-sonnet-4-6", "anthropic"}, + {"claude-opus-4-6", "anthropic"}, + {"claude-haiku-4-5-20251001", "anthropic"}, + {"gpt-5.4", "openai"}, + {"gpt-5.4-mini", "openai"}, + {"o3", "openai"}, + {"o3-mini", "openai"}, + {"o4-mini", "openai"}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + adapter, err := router.Route(tt.model, "") + require.NoError(t, err) + assert.Equal(t, tt.expected, adapter.Name()) + }) + } +} + +func TestRouterDefaultFallback(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "openai") + + openai := &mockAdapter{name: "openai"} + router.RegisterProvider("openai", openai, []string{"gpt-5"}) + + adapter, err := router.Route("unknown-model", "") + require.NoError(t, err) + assert.Equal(t, "openai", adapter.Name()) +} + +func TestRouterProviderOverride(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "openai") + + anthropic := &mockAdapter{name: "anthropic"} + openai := &mockAdapter{name: "openai"} + + router.RegisterProvider("anthropic", anthropic, []string{"claude-"}) + router.RegisterProvider("openai", openai, []string{"gpt-5"}) + + // Override: send a claude model but force openai provider + adapter, err := router.Route("claude-sonnet-4-6", "openai") + require.NoError(t, err) + assert.Equal(t, "openai", adapter.Name()) +} + +func TestRouterUnknownOverride(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "openai") + + openai := &mockAdapter{name: "openai"} + router.RegisterProvider("openai", openai, []string{"gpt-5"}) + + _, err := router.Route("gpt-5.4", "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown provider override") +} + +func TestRouterNoProviders(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "nonexistent") + + _, err := router.Route("any-model", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no provider found") +} + +func TestRouterLongestPrefixMatch(t *testing.T) { + logger := zap.NewNop() + router := NewRouter(logger, "default") + + short := &mockAdapter{name: "short"} + long := &mockAdapter{name: "long"} + def := &mockAdapter{name: "default"} + + router.RegisterProvider("short", short, []string{"gpt-"}) + router.RegisterProvider("long", long, []string{"gpt-5"}) + router.RegisterProvider("default", def, []string{}) + + adapter, err := router.Route("gpt-5.4", "") + require.NoError(t, err) + assert.Equal(t, "long", adapter.Name(), "should match longer prefix 'gpt-5' over 'gpt-'") + + adapter, err = router.Route("gpt-4o", "") + require.NoError(t, err) + assert.Equal(t, "short", adapter.Name(), "should match 'gpt-' for gpt-4o") +} diff --git a/services/gateway/services/sse_parser.go b/services/gateway/services/sse_parser.go new file mode 100644 index 00000000..fd74c753 --- /dev/null +++ b/services/gateway/services/sse_parser.go @@ -0,0 +1,79 @@ +package services + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "strings" +) + +// SSEEvent represents a parsed Server-Sent Event. +type SSEEvent struct { + Event string + Data []byte +} + +// ParseSSEStream reads SSE events from reader, calling handler for each complete event. +// Returns on EOF, context cancellation, or handler error. +func ParseSSEStream(ctx context.Context, reader io.Reader, handler func(SSEEvent) error) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var eventType string + var dataLines [][]byte + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + + if line == "" { + // Blank line = event boundary + if len(dataLines) > 0 { + data := bytes.Join(dataLines, []byte("\n")) + evt := SSEEvent{ + Event: eventType, + Data: data, + } + if err := handler(evt); err != nil { + return err + } + } + eventType = "" + dataLines = nil + continue + } + + if strings.HasPrefix(line, "event:") { + eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + raw := strings.TrimPrefix(line, "data:") + raw = strings.TrimPrefix(raw, " ") + dataLines = append(dataLines, []byte(raw)) + } + // Ignore comments (lines starting with ':') and other fields + } + + // Process any remaining event data + if len(dataLines) > 0 { + data := bytes.Join(dataLines, []byte("\n")) + evt := SSEEvent{ + Event: eventType, + Data: data, + } + if err := handler(evt); err != nil { + return err + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("sse scanner error (last_event=%q): %w", eventType, err) + } + return nil +} diff --git a/services/gateway/services/sse_parser_test.go b/services/gateway/services/sse_parser_test.go new file mode 100644 index 00000000..ffb82f35 --- /dev/null +++ b/services/gateway/services/sse_parser_test.go @@ -0,0 +1,187 @@ +package services + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSSEStream_BasicEvent(t *testing.T) { + input := `event: message_start +data: {"type":"message_start"} + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "message_start", events[0].Event) + assert.Equal(t, `{"type":"message_start"}`, string(events[0].Data)) +} + +func TestParseSSEStream_MultipleEvents(t *testing.T) { + input := `event: first +data: {"n":1} + +event: second +data: {"n":2} + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 2) + assert.Equal(t, "first", events[0].Event) + assert.Equal(t, "second", events[1].Event) +} + +func TestParseSSEStream_DataWithoutEvent(t *testing.T) { + input := `data: {"plain":true} + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "", events[0].Event) + assert.Equal(t, `{"plain":true}`, string(events[0].Data)) +} + +func TestParseSSEStream_MultiLineData(t *testing.T) { + input := `event: multi +data: line1 +data: line2 + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "line1\nline2", string(events[0].Data)) +} + +func TestParseSSEStream_CommentsIgnored(t *testing.T) { + input := `: this is a comment +event: test +data: {"ok":true} + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "test", events[0].Event) +} + +func TestParseSSEStream_EventWithoutData(t *testing.T) { + // Event type set but no data lines — should not fire handler + input := `event: empty + +event: has_data +data: {"ok":true} + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "has_data", events[0].Event) +} + +func TestParseSSEStream_HandlerError(t *testing.T) { + input := `event: test +data: {"n":1} + +event: test +data: {"n":2} + +` + handlerErr := errors.New("stop processing") + var count int + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + count++ + return handlerErr + }) + assert.ErrorIs(t, err, handlerErr) + assert.Equal(t, 1, count, "should stop after first handler error") +} + +func TestParseSSEStream_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + input := `event: test +data: {"n":1} + +` + err := ParseSSEStream(ctx, strings.NewReader(input), func(evt SSEEvent) error { + t.Fatal("handler should not be called after context cancellation") + return nil + }) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestParseSSEStream_TrailingEventWithoutBlankLine(t *testing.T) { + // EOF without trailing blank line — should still process the event + input := `event: trailing +data: {"last":true}` + + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "trailing", events[0].Event) +} + +func TestParseSSEStream_EmptyInput(t *testing.T) { + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(""), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + assert.Empty(t, events) +} + +func TestParseSSEStream_DataSpaceHandling(t *testing.T) { + // "data: value" and "data:value" should both work + input := `data: with space + +data:no space + +` + var events []SSEEvent + err := ParseSSEStream(context.Background(), strings.NewReader(input), func(evt SSEEvent) error { + events = append(events, evt) + return nil + }) + require.NoError(t, err) + require.Len(t, events, 2) + assert.Equal(t, "with space", string(events[0].Data)) + assert.Equal(t, "no space", string(events[1].Data)) +} From 256fa29e41d4ce30c82fbdd6d6e783398b2cedf8 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Mon, 1 Jun 2026 19:31:16 +0530 Subject: [PATCH 05/26] Decouple agent crate from chat product - Rename thread_id -> session_id across persister trait and internals; make the persistence id non-optional - Merge load_ask_context into load_context(session_id); drop also_send_to_channel - Remove start_session tool, AgentResult::StartSession, AgentEvent::SessionStart and its yield arm - Decouple subagents: plain UUID session_id + parent_session_id stamped in metadata; drop PersisterFactory and {parent}-sub-{uuid} naming --- crates/agent/src/agent/ask_prompt.txt | 9 +- crates/agent/src/agent/loop_.rs | 218 +++++++------------------ crates/agent/src/agent/mod.rs | 5 +- crates/agent/src/persistence.rs | 100 ++++-------- crates/agent/src/session.rs | 50 +++--- crates/agent/src/subagents/tool.rs | 28 ++-- crates/agent/src/tool/ask_user.rs | 2 +- crates/agent/src/tool/mod.rs | 24 +-- crates/agent/src/tool/start_session.rs | 215 ------------------------ crates/agent/src/types.rs | 13 -- crates/agent/tests/integration.rs | 87 ++-------- 11 files changed, 154 insertions(+), 597 deletions(-) delete mode 100644 crates/agent/src/tool/start_session.rs diff --git a/crates/agent/src/agent/ask_prompt.txt b/crates/agent/src/agent/ask_prompt.txt index a8c17b49..646319cc 100644 --- a/crates/agent/src/agent/ask_prompt.txt +++ b/crates/agent/src/agent/ask_prompt.txt @@ -2,14 +2,13 @@ You are a coding assistant. You help users understand codebases, answer question You have READ-ONLY access to the codebase. You can read files, search for patterns, and explore the project structure, but you cannot make changes directly. -When the user wants you to write code, fix bugs, or make any changes to files, use the `start_session` tool to begin a coding session. The coding session gives you a separate git worktree with full write access. +When the user wants you to write code, fix bugs, or make changes, explain the approach and what you'd change — implementation happens separately in coding mode, not from here. # Available Tools - `read` — Read file contents (line-numbered) or list directory entries. Supports `offset` and `limit` for large files. - `glob` — Find files by glob pattern (e.g., `**/*.rs`, `src/**/*.ts`). Returns up to 100 results sorted by modification time (newest first). Respects .gitignore. - `grep` — Search file contents with regex. Returns up to 100 matches with file paths and line numbers. Supports `include` glob filter for file types. -- `start_session` — Start a coding session when the user wants changes made. Requires a project path and task summary. - `ask_user` — Ask the user a clarifying question when you need more information before proceeding. Optionally provide a list of choices. - `spawn_subagent` — Dispatch a specialist subagent (see "Default Subagents" below). @@ -45,11 +44,6 @@ If the user's message contains `@` (e.g. `@code-reviewer`) and `` ma - Use `include` to narrow by file type: `include: "*.rs"` or `include: "*.{ts,tsx}"`. - Results are capped at 100 matches. If too many, narrow with a more specific pattern or `include` filter. -## start_session -- Call when the user wants to write code, fix bugs, refactor, or make any file changes. -- Provide a clear, specific `task_summary` — it becomes the context for the coding session. -- The coding session runs in an isolated git worktree with full tool access. - ## ask_user - Use when the request is ambiguous, has multiple valid approaches, or you need the user to make a decision. - Ask all your questions at once rather than one at a time. @@ -61,7 +55,6 @@ If the user's message contains `@` (e.g. `@code-reviewer`) and `` ma 2. **Read before answering** — Always read the actual source code before answering questions about it. 3. **Parallelize** — Make independent tool calls in parallel. For example, read multiple files at once, or run grep + glob simultaneously. 4. **Be thorough** — When investigating a question, trace through the code. Follow imports, check callers, read tests. -5. **Start sessions for changes** — If the user wants code changes, call `start_session` with a clear task summary. Don't try to describe code changes in text. # Handling Large Output When a tool result says "Full output saved to /path. Use the read tool to view specific sections": diff --git a/crates/agent/src/agent/loop_.rs b/crates/agent/src/agent/loop_.rs index 49f0594c..529082fe 100644 --- a/crates/agent/src/agent/loop_.rs +++ b/crates/agent/src/agent/loop_.rs @@ -50,6 +50,10 @@ pub struct AgentLoop { /// Offset added to iteration counter for globally unique turn numbering /// across multiple AgentLoop invocations in the same thread. iteration_offset: u32, + /// When this loop is a subagent child, the parent's session id. Stamped into + /// every persisted message's metadata so child history stays linkable to the + /// parent. `None` for top-level loops. + parent_session_id: Option, } impl AgentLoop { @@ -122,6 +126,7 @@ impl AgentLoop { approval_handler: None, total_tokens_used: 0, iteration_offset: 0, + parent_session_id: None, } } @@ -133,14 +138,14 @@ impl AgentLoop { /// Attach a persister — spawns a background worker that writes messages in order. /// Panics (in debug builds) if called more than once on the same AgentLoop. - pub fn with_persister(mut self, persister: Arc, thread_id: Option) -> Self { + pub fn with_persister(mut self, persister: Arc, session_id: String) -> Self { debug_assert!(self.persist_tx.is_none(), "with_persister called twice on the same AgentLoop"); let (tx, mut rx) = mpsc::unbounded_channel::(); // Spawn a single ordered worker that drains messages sequentially. - // thread_id is captured once here — it never changes for the lifetime of an AgentLoop. + // session_id is captured once here — it never changes for the lifetime of an AgentLoop. let handle = tokio::spawn(async move { while let Some(item) = rx.recv().await { - if let Err(e) = persister.persist_message(&item.message, thread_id.as_deref()).await { + if let Err(e) = persister.persist_message(&item.message, &session_id).await { log::error!("Persist failed: {e}"); } } @@ -150,6 +155,14 @@ impl AgentLoop { self } + /// Mark this loop as a subagent child of `parent_session_id`. The id is + /// stamped into every persisted message's metadata so child history stays + /// linkable to the parent session. + pub fn with_parent_session_id(mut self, parent_session_id: String) -> Self { + self.parent_session_id = Some(parent_session_id); + self + } + /// Number of messages currently in the conversation context. pub fn message_count(&self) -> usize { self.messages.len() @@ -542,12 +555,12 @@ impl AgentLoop { }).await; } - // Check for yield_data (e.g., start_session, ask_user) — after results are persisted. + // Check for yield_data (e.g., save_plan, ask_user) — after results are persisted. // Note: TurnCompleted is intentionally NOT emitted on yield. Ask/Plan modes // (the only modes with yielding tools) have no file-modifying tools, // so turn_modified_files is always empty here. if let Some(ref data) = yield_data { - let yield_type = data["yield_type"].as_str().unwrap_or("start_session"); + let yield_type = data["yield_type"].as_str().unwrap_or_default(); match yield_type { "save_plan" => { @@ -591,30 +604,9 @@ impl AgentLoop { return Ok(AgentResult::AskUser { question, options }); } - _ => { - // start_session (existing behavior) - let project_path = data["project_path"] - .as_str() - .unwrap_or_default() - .to_string(); - let branch = data["branch"].as_str().map(String::from); - let task_summary = data["task_summary"] - .as_str() - .unwrap_or_default() - .to_string(); - - let _ = self.event_tx.send(AgentEvent::SessionStart { - session_id: self.session_id.clone(), - project_path: project_path.clone(), - branch: branch.clone(), - task_summary: task_summary.clone(), - }).await; - - return Ok(AgentResult::StartSession { - project_path, - branch, - task_summary, - }); + other => { + // Unknown yield type — log and ignore rather than acting on it. + log::warn!("[agent] ignoring unknown yield_type: {other:?}"); } } } @@ -653,8 +645,16 @@ impl AgentLoop { } /// Send a message to the ordered persistence worker. Non-blocking. - fn persist_fire_and_forget(&self, msg: AgentMessage) { + fn persist_fire_and_forget(&self, mut msg: AgentMessage) { if let Some(ref tx) = self.persist_tx { + // For subagent children, record the parent session id in metadata so + // child history stays linkable to the parent (replaces the old + // composite child-session naming scheme). + if let Some(ref parent) = self.parent_session_id { + if let Some(obj) = msg.metadata.as_object_mut() { + obj.insert("parent_session_id".to_string(), serde_json::json!(parent)); + } + } let _ = tx.send(PersistItem { message: msg }); } } @@ -681,7 +681,6 @@ impl AgentLoop { }, message_type, sender, - also_send_to_channel: false, turn_count, } } @@ -1275,10 +1274,6 @@ fn build_args_summary(tool_name: &str, args: &serde_json::Value, working_dir: &s let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("?"); format!("Creating PR: {title}") } - "start_session" => { - let summary = args.get("task_summary").and_then(|v| v.as_str()).unwrap_or("?"); - format!("Starting session: {summary}") - } "save_plan" => { let filename = args.get("filename").and_then(|v| v.as_str()).unwrap_or("plan.md"); format!("Saving plan: {filename}") @@ -2240,103 +2235,6 @@ mod tests { assert!(matches!(result, Err(AgentError::Cancelled))); } - // ════════════════════════════════════════════ - // start_session yield - // ════════════════════════════════════════════ - - /// Helper: create a temp dir with `git init` for start_session tests - fn make_git_repo_for_session() -> (tempfile::TempDir, String) { - let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - let path = dir.path().to_string_lossy().to_string(); - (dir, path) - } - - #[tokio::test] - async fn test_start_session_returns_start_session_result() { - use crate::tool::start_session::StartSessionTool; - - let (_dir, dir_str) = make_git_repo_for_session(); - let mut reg = ToolRegistry::new(); - reg.register(Arc::new(StartSessionTool)); - - let args = format!(r#"{{"project_path":"{}","task_summary":"Fix the login bug"}}"#, dir_str); - let mock = MockLlm::new(vec![Ok(tool_call_response( - "call_1", - "start_session", - &args, - ))]); - let (mut agent, _rx) = make_agent(mock, reg, None); - - let result = agent.run(ChatMessage::user("Fix the login bug")).await.unwrap(); - match result { - AgentResult::StartSession { - project_path, - branch, - task_summary, - } => { - assert_eq!(project_path, dir_str); - assert!(branch.is_none()); - assert_eq!(task_summary, "Fix the login bug"); - } - other => panic!("Expected StartSession, got {:?}", other), - } - } - - #[tokio::test] - async fn test_start_session_with_branch() { - use crate::tool::start_session::StartSessionTool; - - let (_dir, dir_str) = make_git_repo_for_session(); - let mut reg = ToolRegistry::new(); - reg.register(Arc::new(StartSessionTool)); - - let args = format!(r#"{{"project_path":"{}","branch":"fix/login","task_summary":"Fix login"}}"#, dir_str); - let mock = MockLlm::new(vec![Ok(tool_call_response( - "call_1", - "start_session", - &args, - ))]); - let (mut agent, _rx) = make_agent(mock, reg, None); - - let result = agent.run(ChatMessage::user("Fix login")).await.unwrap(); - match result { - AgentResult::StartSession { branch, .. } => { - assert_eq!(branch.as_deref(), Some("fix/login")); - } - other => panic!("Expected StartSession, got {:?}", other), - } - } - - #[tokio::test] - async fn test_start_session_without_branch() { - use crate::tool::start_session::StartSessionTool; - - let (_dir, dir_str) = make_git_repo_for_session(); - let mut reg = ToolRegistry::new(); - reg.register(Arc::new(StartSessionTool)); - - let args = format!(r#"{{"project_path":"{}","task_summary":"Refactor auth"}}"#, dir_str); - let mock = MockLlm::new(vec![Ok(tool_call_response( - "call_1", - "start_session", - &args, - ))]); - let (mut agent, _rx) = make_agent(mock, reg, None); - - let result = agent.run(ChatMessage::user("Refactor auth")).await.unwrap(); - match result { - AgentResult::StartSession { branch, .. } => { - assert!(branch.is_none()); - } - other => panic!("Expected StartSession, got {:?}", other), - } - } - // ════════════════════════════════════════════ // Persister integration // ════════════════════════════════════════════ @@ -2379,7 +2277,7 @@ mod tests { tx, "test-persist".into(), ) - .with_persister(Arc::clone(&persister) as Arc, Some("thread-1".into())); + .with_persister(Arc::clone(&persister) as Arc, "session-1".into()); let _result = agent.run(ChatMessage::user("ping")).await.unwrap(); drop(rx); @@ -2391,9 +2289,9 @@ mod tests { // Should have: user message, assistant (tool_call), tool result, assistant (done) assert!(msgs.len() >= 3, "Expected at least 3 persisted messages, got {}", msgs.len()); - // All should be in thread-1 - for (tid, _) in &msgs { - assert_eq!(tid.as_deref(), Some("thread-1")); + // All should be in session-1 + for (sid, _) in &msgs { + assert_eq!(sid, "session-1"); } } @@ -2405,13 +2303,10 @@ mod tests { #[async_trait::async_trait] impl MessagePersister for FailingPersister { - async fn persist_message(&self, _msg: &AgentMessage, _thread_id: Option<&str>) -> Result { + async fn persist_message(&self, _msg: &AgentMessage, _session_id: &str) -> Result { Err(PersistError::Storage("simulated failure".into())) } - async fn load_session_context(&self, _thread_id: &str) -> Result, PersistError> { - Ok(vec![]) - } - async fn load_ask_context(&self) -> Result, PersistError> { + async fn load_context(&self, _session_id: &str) -> Result, PersistError> { Ok(vec![]) } } @@ -2439,7 +2334,7 @@ mod tests { tx, "test-fail".into(), ) - .with_persister(Arc::new(FailingPersister), None); + .with_persister(Arc::new(FailingPersister), "test-fail".into()); // Should complete successfully despite persistence failures let result = agent.run(ChatMessage::user("Hi")).await.unwrap().unwrap_done(); @@ -2529,7 +2424,7 @@ mod tests { "test-compact".into(), ) .with_initial_context(initial_context) - .with_persister(Arc::clone(&persister) as Arc, Some("thread-compact".into())); + .with_persister(Arc::clone(&persister) as Arc, "thread-compact".into()); let result = agent.run(ChatMessage::user("test compaction")).await.unwrap().unwrap_done(); assert_eq!(result, "Done with compaction."); @@ -2687,7 +2582,7 @@ mod tests { tx, "test-ctx".into(), ) - .with_persister(Arc::clone(&persister) as Arc, Some("thread-ctx".into())) + .with_persister(Arc::clone(&persister) as Arc, "thread-ctx".into()) .with_initial_context(vec![ ChatMessage::user("What does main.rs do?"), ChatMessage::assistant(Some("It starts the server.".into()), None, None), @@ -2772,7 +2667,7 @@ mod tests { tx, "test-order".into(), ) - .with_persister(Arc::clone(&persister) as Arc, Some("thread-order".into())); + .with_persister(Arc::clone(&persister) as Arc, "thread-order".into()); let result = agent.run(ChatMessage::user("test ordering")).await.unwrap().unwrap_done(); assert_eq!(result, "Done."); @@ -2795,29 +2690,26 @@ mod tests { } // ════════════════════════════════════════════ - // Yield ordering: tool results are persisted before StartSession return + // Yield ordering: tool results are persisted before the yield return // ════════════════════════════════════════════ #[tokio::test] async fn test_yield_persists_tool_results_before_returning() { use crate::persistence::MockPersister; - use crate::tool::start_session::StartSessionTool; + use crate::tool::save_plan::SavePlanTool; let persister = Arc::new(MockPersister::new()); - // Create a real git repo for start_session validation - let git_dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git").args(["init"]).current_dir(git_dir.path()).output().unwrap(); - let git_path = git_dir.path().to_string_lossy().to_string(); + let work_dir = tempfile::tempdir().unwrap(); - // LLM calls start_session - let args = format!(r#"{{"project_path":"{}","task_summary":"fix bug"}}"#, git_path); + // LLM calls save_plan, which yields PlanReady. + let args = r#"{"plan":"Goal: fix bug","filename":"plan.md"}"#; let mock = MockLlm::new(vec![ - Ok(tool_call_response("call_ss", "start_session", &args)), + Ok(tool_call_response("call_sp", "save_plan", args)), ]); let mut registry = ToolRegistry::new(); - registry.register(Arc::new(StartSessionTool)); + registry.register(Arc::new(SavePlanTool)); let (tx, rx) = mpsc::channel(256); let config = AgentConfig::new( @@ -2830,7 +2722,7 @@ mod tests { thinking: None, disable_cache_control: false, }, - git_dir.path().to_path_buf(), + work_dir.path().to_path_buf(), ); let mut agent = AgentLoop::with_provider( @@ -2841,17 +2733,17 @@ mod tests { tx, "test-yield".into(), ) - .with_persister(Arc::clone(&persister) as Arc, Some("thread-yield".into())); + .with_persister(Arc::clone(&persister) as Arc, "session-yield".into()); - let result = agent.run(ChatMessage::user("fix the bug")).await.unwrap(); + let result = agent.run(ChatMessage::user("plan the fix")).await.unwrap(); drop(rx); - // Should be StartSession + // Should be PlanReady match &result { - AgentResult::StartSession { task_summary, .. } => { - assert!(task_summary.contains("fix bug"), "Got: {task_summary}"); + AgentResult::PlanReady { plan, .. } => { + assert!(plan.contains("fix bug"), "Got: {plan}"); } - other => panic!("Expected StartSession, got {:?}", other), + other => panic!("Expected PlanReady, got {:?}", other), } // Let persistence drain @@ -2867,7 +2759,7 @@ mod tests { // The tool result should be persisted (this was the original bug — it was skipped before) let has_tool_result = persisted.iter().any(|(_, m)| m.message_type == crate::persistence::MessageType::ToolResult); - assert!(has_tool_result, "Tool result for start_session should be persisted"); + assert!(has_tool_result, "Tool result for the yielding tool should be persisted"); } // ════════════════════════════════════════════ diff --git a/crates/agent/src/agent/mod.rs b/crates/agent/src/agent/mod.rs index d0f60085..936dfc9d 100644 --- a/crates/agent/src/agent/mod.rs +++ b/crates/agent/src/agent/mod.rs @@ -36,7 +36,7 @@ pub fn spawn_agent( mut config: AgentConfig, user_message: ChatMessage, persister: Option>, - thread_id: Option, + persist_session_id: Option, approval_handler: Option>, ) -> SpawnedAgent { let (event_tx, event_rx) = mpsc::channel(256); @@ -75,9 +75,10 @@ pub fn spawn_agent( let cancel_token = cancel_token.clone(); let session_id = session_id.clone(); tokio::spawn(async move { + let persist_id = persist_session_id.unwrap_or_else(|| session_id.clone()); let mut agent_loop = AgentLoop::new(config, registry, cancel_token, event_tx, session_id); if let Some(p) = persister { - agent_loop = agent_loop.with_persister(p, thread_id); + agent_loop = agent_loop.with_persister(p, persist_id); } if let Some(h) = approval_handler { agent_loop = agent_loop.with_approval_handler(h); diff --git a/crates/agent/src/persistence.rs b/crates/agent/src/persistence.rs index b2a730d8..a3410ac1 100644 --- a/crates/agent/src/persistence.rs +++ b/crates/agent/src/persistence.rs @@ -73,8 +73,6 @@ pub struct AgentMessage { pub message_type: MessageType, /// Who originated this message. pub sender: Sender, - /// Whether to also post this to the main DM channel. - pub also_send_to_channel: bool, /// Which agent loop iteration (turn) produced this message. /// Set by the agent loop from its iteration counter. pub turn_count: Option, @@ -98,34 +96,23 @@ pub enum PersistError { /// Trait for persisting agent messages to external storage. #[async_trait] pub trait MessagePersister: Send + Sync { - /// Persist a single message, optionally associated with a thread. + /// Persist a single message associated with a session. async fn persist_message( &self, message: &AgentMessage, - thread_id: Option<&str>, + session_id: &str, ) -> Result; - /// Load all messages for a coding session (thread). - async fn load_session_context( + /// Load all messages for a session. + async fn load_context( &self, - thread_id: &str, + session_id: &str, ) -> Result, PersistError>; - - /// Load ask-mode context (messages not associated with any thread). - async fn load_ask_context(&self) -> Result, PersistError>; -} - -/// Builds a child persister that stamps `parent_thread_id` on every insert. -/// Implemented by the Tauri layer (SQLite-backed) so `spawn_subagent` stays -/// Tauri-agnostic. A child AgentLoop receives the `Arc` -/// returned from `for_subagent` and writes to it as normal. -pub trait PersisterFactory: Send + Sync { - fn for_subagent(&self, parent_thread_id: &str) -> Arc; } /// In-memory mock persister for testing. pub struct MockPersister { - messages: Arc, AgentMessage)>>>, + messages: Arc>>, } impl MockPersister { @@ -135,8 +122,8 @@ impl MockPersister { } } - /// Access all stored (thread_id, message) pairs for test assertions. - pub fn messages(&self) -> Vec<(Option, AgentMessage)> { + /// Access all stored (session_id, message) pairs for test assertions. + pub fn messages(&self) -> Vec<(String, AgentMessage)> { self.messages.lock().unwrap().clone() } } @@ -152,38 +139,26 @@ impl MessagePersister for MockPersister { async fn persist_message( &self, message: &AgentMessage, - thread_id: Option<&str>, + session_id: &str, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); self.messages .lock() .unwrap() - .push((thread_id.map(String::from), message.clone())); + .push((session_id.to_string(), message.clone())); Ok(PersistResult { id }) } - async fn load_session_context( + async fn load_context( &self, - thread_id: &str, + session_id: &str, ) -> Result, PersistError> { let msgs = self .messages .lock() .unwrap() .iter() - .filter(|(tid, _)| tid.as_deref() == Some(thread_id)) - .map(|(_, msg)| msg.clone()) - .collect(); - Ok(msgs) - } - - async fn load_ask_context(&self) -> Result, PersistError> { - let msgs = self - .messages - .lock() - .unwrap() - .iter() - .filter(|(tid, _)| tid.is_none()) + .filter(|(sid, _)| sid == session_id) .map(|(_, msg)| msg.clone()) .collect(); Ok(msgs) @@ -200,23 +175,19 @@ impl MessagePersister for NoopPersister { async fn persist_message( &self, _message: &AgentMessage, - _thread_id: Option<&str>, + _session_id: &str, ) -> Result { Ok(PersistResult { id: String::new(), }) } - async fn load_session_context( + async fn load_context( &self, - _thread_id: &str, + _session_id: &str, ) -> Result, PersistError> { Ok(Vec::new()) } - - async fn load_ask_context(&self) -> Result, PersistError> { - Ok(Vec::new()) - } } #[cfg(test)] @@ -232,7 +203,6 @@ mod tests { role, message_type: msg_type, sender: Sender::HumanUser, - also_send_to_channel: false, turn_count: None, } } @@ -242,12 +212,12 @@ mod tests { let persister = MockPersister::new(); let msg = make_message("hello", MessageRole::User, MessageType::Text); - let result = persister.persist_message(&msg, None).await.unwrap(); + let result = persister.persist_message(&msg, "session-1").await.unwrap(); assert!(!result.id.is_empty()); - let ask_msgs = persister.load_ask_context().await.unwrap(); - assert_eq!(ask_msgs.len(), 1); - assert_eq!(ask_msgs[0].content, "hello"); + let msgs = persister.load_context("session-1").await.unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].content, "hello"); } #[tokio::test] @@ -258,39 +228,39 @@ mod tests { let msg_b = make_message("thread-b msg", MessageRole::User, MessageType::Text); persister - .persist_message(&msg_a, Some("thread-a")) + .persist_message(&msg_a, "thread-a") .await .unwrap(); persister - .persist_message(&msg_b, Some("thread-b")) + .persist_message(&msg_b, "thread-b") .await .unwrap(); - let a_msgs = persister.load_session_context("thread-a").await.unwrap(); + let a_msgs = persister.load_context("thread-a").await.unwrap(); assert_eq!(a_msgs.len(), 1); assert_eq!(a_msgs[0].content, "thread-a msg"); - let b_msgs = persister.load_session_context("thread-b").await.unwrap(); + let b_msgs = persister.load_context("thread-b").await.unwrap(); assert_eq!(b_msgs.len(), 1); assert_eq!(b_msgs[0].content, "thread-b msg"); } #[tokio::test] - async fn test_ask_context_excludes_thread_messages() { + async fn test_sessions_isolated_by_id() { let persister = MockPersister::new(); - let ask_msg = make_message("ask msg", MessageRole::User, MessageType::Text); - let thread_msg = make_message("thread msg", MessageRole::User, MessageType::Text); + let msg_one = make_message("session-1 msg", MessageRole::User, MessageType::Text); + let msg_two = make_message("session-2 msg", MessageRole::User, MessageType::Text); - persister.persist_message(&ask_msg, None).await.unwrap(); + persister.persist_message(&msg_one, "session-1").await.unwrap(); persister - .persist_message(&thread_msg, Some("thread-1")) + .persist_message(&msg_two, "session-2") .await .unwrap(); - let ask_msgs = persister.load_ask_context().await.unwrap(); - assert_eq!(ask_msgs.len(), 1); - assert_eq!(ask_msgs[0].content, "ask msg"); + let msgs = persister.load_context("session-1").await.unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].content, "session-1 msg"); } #[tokio::test] @@ -301,15 +271,15 @@ mod tests { let compact_msg = make_message("summary", MessageRole::System, MessageType::Compaction); persister - .persist_message(&text_msg, Some("thread-1")) + .persist_message(&text_msg, "thread-1") .await .unwrap(); persister - .persist_message(&compact_msg, Some("thread-1")) + .persist_message(&compact_msg, "thread-1") .await .unwrap(); - let msgs = persister.load_session_context("thread-1").await.unwrap(); + let msgs = persister.load_context("thread-1").await.unwrap(); assert_eq!(msgs.len(), 2); let compaction_msgs: Vec<_> = msgs diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index d95624a0..e98f2303 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -86,7 +86,7 @@ impl SessionManager { None, initial_context, None, - Some(String::new()), // persist with thread_id="" so load_ask_messages finds them + None, // ask mode persists under its own session_id (no override) initial_token_count, approval_handler, None, // no turn offset for ask mode @@ -98,7 +98,7 @@ impl SessionManager { /// Start a coding session. Returns a receiver for events. /// `initial_context` carries the sliding window of ask-mode messages (with their original roles). - /// `persist_thread_id` overrides the persistence key (defaults to session_id if None). + /// `persist_session_id` overrides the persistence key (defaults to session_id if None). /// `is_resume`: true when resuming an existing thread (don't re-persist context), /// false when creating a new thread from ask mode (persist as completion_summary). /// `persister_override`: see `start_ask_session`. @@ -111,7 +111,7 @@ impl SessionManager { worktree_path: Option, branch: Option, initial_context: Option>, - persist_thread_id: Option, + persist_session_id: Option, initial_token_count: Option, approval_handler: Option>, turn_offset: Option, @@ -127,7 +127,7 @@ impl SessionManager { branch, initial_context, None, - persist_thread_id, + persist_session_id, initial_token_count, approval_handler, turn_offset, @@ -148,7 +148,7 @@ impl SessionManager { branch: Option, initial_context: Option>, provider: Option>, - persist_thread_id: Option, + persist_session_id: Option, initial_token_count: Option, approval_handler: Option>, turn_offset: Option, @@ -173,7 +173,7 @@ impl SessionManager { registry.register_spawn_subagent(sub_reg, inherit); } let persister = persister_override.unwrap_or_else(|| Arc::clone(&self.persister)); - let thread_id = Some(persist_thread_id.unwrap_or_else(|| session_id.clone())); + let persist_session_id = persist_session_id.unwrap_or_else(|| session_id.clone()); let client: Box = match provider { Some(p) => p, @@ -192,7 +192,7 @@ impl SessionManager { event_tx, sid.clone(), ); - agent_loop = agent_loop.with_persister(persister, thread_id); + agent_loop = agent_loop.with_persister(persister, persist_session_id); if let Some(h) = approval_handler { agent_loop = agent_loop.with_approval_handler(h); } @@ -341,7 +341,7 @@ impl SessionManager { message: ChatMessage, initial_context: Option>, provider: Box, - persist_thread_id: Option, + persist_session_id: Option, ) -> Result, AgentError> { self.start_session_inner( session_id, @@ -352,7 +352,7 @@ impl SessionManager { None, initial_context, Some(provider), - persist_thread_id, + persist_session_id, None, None, None, // no turn offset for test coding sessions @@ -640,13 +640,13 @@ mod tests { } #[tokio::test] - async fn test_persist_thread_id_routes_correctly() { + async fn test_persist_session_id_routes_correctly() { let persister = Arc::new(MockPersister::new()); let manager = SessionManager::new(Arc::clone(&persister) as Arc); let mock = MockLlm::new(vec![Ok(text_response("Done!"))]); - // Start a coding session with a custom persist_thread_id + // Start a coding session with a custom persist_session_id let mut rx = manager .start_coding_session_with_provider( "session-uuid-123".into(), @@ -654,7 +654,7 @@ mod tests { ChatMessage::user("Fix the bug"), None, Box::new(mock), - Some("real-thread-id".into()), // This should be the persistence key + Some("real-session-id".into()), // This should be the persistence key ) .await .unwrap(); @@ -665,33 +665,33 @@ mod tests { // Give persistence worker time to finish tokio::time::sleep(std::time::Duration::from_millis(50)).await; - // Messages should be stored under "real-thread-id", not "session-uuid-123" + // Messages should be stored under "real-session-id", not "session-uuid-123" let msgs = persister.messages(); - let thread_ids: Vec> = msgs.iter().map(|(tid, _)| tid.as_deref()).collect(); + let session_ids: Vec<&str> = msgs.iter().map(|(sid, _)| sid.as_str()).collect(); assert!( - !thread_ids.is_empty(), + !session_ids.is_empty(), "Should have persisted messages" ); assert!( - thread_ids.iter().all(|tid| *tid == Some("real-thread-id")), - "All messages should be stored under 'real-thread-id', got: {:?}", - thread_ids + session_ids.iter().all(|sid| *sid == "real-session-id"), + "All messages should be stored under 'real-session-id', got: {:?}", + session_ids ); assert!( - !thread_ids.iter().any(|tid| *tid == Some("session-uuid-123")), + !session_ids.iter().any(|sid| *sid == "session-uuid-123"), "No messages should be stored under the session UUID" ); } #[tokio::test] - async fn test_persist_thread_id_defaults_to_session_id() { + async fn test_persist_session_id_defaults_to_session_id() { let persister = Arc::new(MockPersister::new()); let manager = SessionManager::new(Arc::clone(&persister) as Arc); let mock = MockLlm::new(vec![Ok(text_response("Done!"))]); - // Start without persist_thread_id (None) — should use session_id + // Start without persist_session_id (None) — should use session_id let mut rx = manager .start_coding_session_with_provider( "my-session".into(), @@ -699,7 +699,7 @@ mod tests { ChatMessage::user("Hello"), None, Box::new(mock), - None, // No custom persist_thread_id + None, // No custom persist_session_id ) .await .unwrap(); @@ -708,12 +708,12 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(50)).await; let msgs = persister.messages(); - let thread_ids: Vec> = msgs.iter().map(|(tid, _)| tid.as_deref()).collect(); + let session_ids: Vec<&str> = msgs.iter().map(|(sid, _)| sid.as_str()).collect(); assert!( - thread_ids.iter().all(|tid| *tid == Some("my-session")), + session_ids.iter().all(|sid| *sid == "my-session"), "Messages should default to session_id for persistence, got: {:?}", - thread_ids + session_ids ); } } diff --git a/crates/agent/src/subagents/tool.rs b/crates/agent/src/subagents/tool.rs index a2534c60..36d4e778 100644 --- a/crates/agent/src/subagents/tool.rs +++ b/crates/agent/src/subagents/tool.rs @@ -15,7 +15,7 @@ use crate::context_engine::ContextEngineApi; use crate::error::{AgentError, ToolError}; use crate::llm::types::{CacheControl, ChatMessage}; use crate::llm::{LlmClient, LlmClientConfig, LlmProvider}; -use crate::persistence::PersisterFactory; +use crate::persistence::MessagePersister; use crate::tool::{Tool, ToolContext, ToolMode, ToolRegistry, ToolResult}; use crate::types::{AgentEvent, AgentResult}; @@ -39,9 +39,9 @@ pub struct SubagentInheritance { pub max_iterations: u32, pub context_engine: Option>, pub context_engine_repo_path: Option, - pub persister_factory: Option>, + pub persister: Option>, pub approval_handler_factory: Option>, - pub parent_thread_id: Option, + pub parent_session_id: Option, pub write_lock_registry: Arc, } @@ -169,14 +169,12 @@ impl Tool for SpawnSubagentTool { let definition = definition.clone(); let child_session_id = Uuid::new_v4().to_string(); - let child_thread_id = format!("{}-sub-{}", ctx.session_id, &child_session_id[..8]); log::info!( - "[subagents::spawn] resolved definition: name={} model_override={:?} allowed_tools={:?} child_session={} child_thread={}", + "[subagents::spawn] resolved definition: name={} model_override={:?} allowed_tools={:?} child_session={}", definition.name, definition.model, definition.allowed_tools, child_session_id, - child_thread_id, ); // Build child's Coding-mode tool registry, then apply `allowed-tools` filter. @@ -332,17 +330,16 @@ impl Tool for SpawnSubagentTool { let child_cancel = ctx.cancel_token.child_token(); - let child_persister = self.inherit.persister_factory.as_ref().map(|f| { - let parent_tid = self.inherit.parent_thread_id.as_deref().unwrap_or(""); + let child_persister = self.inherit.persister.as_ref().map(|p| { log::info!( - "[subagents::spawn] attaching child persister parent_thread_id={:?} child_thread_id={}", - parent_tid, child_thread_id, + "[subagents::spawn] attaching inherited persister: child_session={} parent_session={:?}", + child_session_id, self.inherit.parent_session_id, ); - f.for_subagent(parent_tid) + Arc::clone(p) }); - if self.inherit.persister_factory.is_none() { + if self.inherit.persister.is_none() { log::warn!( - "[subagents::spawn] no persister_factory inherited — child messages will NOT be persisted" + "[subagents::spawn] no persister inherited — child messages will NOT be persisted" ); } @@ -368,7 +365,10 @@ impl Tool for SpawnSubagentTool { child_session_id.clone(), ); if let Some(p) = child_persister { - child_loop = child_loop.with_persister(p, Some(child_thread_id)); + child_loop = child_loop.with_persister(p, child_session_id.clone()); + if let Some(parent) = self.inherit.parent_session_id.clone() { + child_loop = child_loop.with_parent_session_id(parent); + } } if let Some(h) = child_approval { child_loop = child_loop.with_approval_handler(h); diff --git a/crates/agent/src/tool/ask_user.rs b/crates/agent/src/tool/ask_user.rs index a6f6d579..7db94602 100644 --- a/crates/agent/src/tool/ask_user.rs +++ b/crates/agent/src/tool/ask_user.rs @@ -6,7 +6,7 @@ use super::{Tool, ToolContext, ToolResult}; /// Tool that lets the agent ask the user a clarifying question. /// -/// Uses the yield pattern (same as start_session): sets `yield_data` on the result, +/// Uses the yield pattern (same as save_plan): sets `yield_data` on the result, /// which causes the agent loop to yield `AgentResult::AskUser`. The caller collects /// the user's answer and re-invokes `run()` with the answer as a new user message. pub struct AskUserTool; diff --git a/crates/agent/src/tool/mod.rs b/crates/agent/src/tool/mod.rs index ebb1786e..b916a52e 100644 --- a/crates/agent/src/tool/mod.rs +++ b/crates/agent/src/tool/mod.rs @@ -7,7 +7,6 @@ pub mod glob; pub mod grep; pub mod git_tool; pub mod pr_tool; -pub mod start_session; pub mod ask_user; pub mod todo_write; pub mod apply_patch; @@ -39,7 +38,7 @@ pub struct ToolResult { pub output: String, pub is_error: bool, /// Optional yield data — when set, the agent loop will yield control - /// (e.g., start_session sets this to signal a coding session should begin). + /// (e.g., save_plan / ask_user set this to signal a yield to the host). pub yield_data: Option, /// Files modified by this tool execution (for write/edit/apply_patch tools). pub modified_files: Vec, @@ -102,11 +101,11 @@ pub trait Tool: Send + Sync { /// Mode that determines which tools are available. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum ToolMode { - /// Ask mode: read-only tools + start_session + ask_user. + /// Ask mode: read-only tools + ask_user. Ask, /// Coding mode: full tool suite for making changes. Coding, - /// Plan mode: read-only tools + ask_user + start_session + todo_write. + /// Plan mode: read-only tools + ask_user + save_plan + edit_plan. Plan, } @@ -148,7 +147,6 @@ impl ToolRegistry { registry.register(Arc::new(read::ReadTool)); registry.register(Arc::new(glob::GlobTool)); registry.register(Arc::new(grep::GrepTool)); - registry.register(Arc::new(start_session::StartSessionTool)); registry.register(Arc::new(ask_user::AskUserTool)); } ToolMode::Coding => { @@ -210,12 +208,12 @@ impl ToolRegistry { return; } log::info!( - "[subagents] registering spawn_subagent: {} subagent(s) available, names={:?}, persister_factory={}, approval_factory={}, parent_thread_id={:?}", + "[subagents] registering spawn_subagent: {} subagent(s) available, names={:?}, persister={}, approval_factory={}, parent_session_id={:?}", registry.len(), registry.names(), - inherit.persister_factory.is_some(), + inherit.persister.is_some(), inherit.approval_handler_factory.is_some(), - inherit.parent_thread_id, + inherit.parent_session_id, ); self.register(Arc::new(SpawnSubagentTool::new(registry, inherit))); } @@ -340,7 +338,7 @@ mod tests { .map(|d| d.function.name.clone()) .collect(); names.sort(); - assert_eq!(names, vec!["ask_user", "glob", "grep", "read", "start_session"]); + assert_eq!(names, vec!["ask_user", "glob", "grep", "read"]); } #[test] @@ -367,12 +365,6 @@ mod tests { assert_eq!(names, vec!["ask_user", "edit_plan", "glob", "grep", "read", "save_plan"]); } - #[test] - fn test_start_session_not_in_coding() { - let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); - assert!(reg.get("start_session").is_none()); - } - #[test] fn test_ask_user_not_in_coding() { let reg = ToolRegistry::for_mode(ToolMode::Coding, None, None); @@ -429,7 +421,7 @@ mod tests { // Original ask tools still present assert!(reg.get("read").is_some()); assert!(reg.get("grep").is_some()); - assert!(reg.get("start_session").is_some()); + assert!(reg.get("ask_user").is_some()); } #[test] diff --git a/crates/agent/src/tool/start_session.rs b/crates/agent/src/tool/start_session.rs deleted file mode 100644 index e2ad7e2a..00000000 --- a/crates/agent/src/tool/start_session.rs +++ /dev/null @@ -1,215 +0,0 @@ -use async_trait::async_trait; -use serde_json::{json, Value}; - -use crate::error::ToolError; -use super::{Tool, ToolContext, ToolResult}; - -/// Tool that initiates a coding session from ask mode. -/// In ask mode, this signals the agent loop to yield with `AgentResult::StartSession`. -pub struct StartSessionTool; - -#[async_trait] -impl Tool for StartSessionTool { - fn name(&self) -> &str { - "start_session" - } - - fn description(&self) -> &str { - "Start a coding session to make changes to the codebase. Use this when you need to write, edit, or execute code. \ - This creates a new git worktree and branch for isolated work." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["project_path", "task_summary"], - "properties": { - "project_path": { - "type": "string", - "description": "Absolute path to the project root directory" - }, - "branch": { - "type": "string", - "description": "Optional branch name. If not provided, one will be generated from the task summary." - }, - "task_summary": { - "type": "string", - "description": "Brief description of what this coding session will accomplish" - } - } - }) - } - - async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result { - let project_path = args - .get("project_path") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let branch = args - .get("branch") - .and_then(|v| v.as_str()) - .map(String::from); - let task_summary = args - .get("task_summary") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - - // Validate project_path exists - let path = std::path::Path::new(&project_path); - if project_path.is_empty() || !path.exists() { - return Ok(ToolResult::error(format!( - "Cannot start coding session: project path '{}' does not exist. \ - Ask the user to select a project folder first.", - project_path - ))); - } - - // Validate it's a git repository (required for worktree creation) - let git_dir = path.join(".git"); - if !git_dir.exists() { - return Ok(ToolResult::error(format!( - "Cannot start coding session: '{}' is not a git repository. \ - Tell the user to please select a project folder with git initialized using the folder picker.", - project_path - ))); - } - - let yield_data = json!({ - "yield_type": "start_session", - "project_path": project_path, - "task_summary": task_summary, - "branch": branch, - }); - - Ok(ToolResult { - output: format!("Starting coding session: {task_summary}"), - is_error: false, - yield_data: Some(yield_data), - modified_files: Vec::new(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tool::ToolContext; - - fn make_git_repo() -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - dir - } - - #[tokio::test] - async fn test_start_session_basic() { - let dir = make_git_repo(); - let tool = StartSessionTool; - let args = json!({ - "project_path": dir.path().to_string_lossy(), - "task_summary": "Fix the login bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - assert!(!result.is_error); - assert!(result.output.contains("Fix the login bug")); - assert!(result.yield_data.is_some()); - - let data = result.yield_data.unwrap(); - assert_eq!(data["project_path"], dir.path().to_string_lossy().as_ref()); - assert_eq!(data["task_summary"], "Fix the login bug"); - assert!(data.get("branch").is_none() || data["branch"].is_null()); - } - - #[tokio::test] - async fn test_start_session_with_branch() { - let dir = make_git_repo(); - let tool = StartSessionTool; - let args = json!({ - "project_path": dir.path().to_string_lossy(), - "branch": "fix/login-bug", - "task_summary": "Fix the login bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - let data = result.yield_data.unwrap(); - assert_eq!(data["branch"], "fix/login-bug"); - } - - #[tokio::test] - async fn test_start_session_empty_path() { - let tool = StartSessionTool; - let args = json!({ - "project_path": "", - "task_summary": "Fix a bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - assert!(result.is_error); - assert!(result.output.contains("does not exist")); - assert!(result.yield_data.is_none()); - } - - #[tokio::test] - async fn test_start_session_nonexistent_path() { - let tool = StartSessionTool; - let args = json!({ - "project_path": "/this/path/does/not/exist/at/all", - "task_summary": "Fix a bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - assert!(result.is_error); - assert!(result.output.contains("does not exist")); - assert!(result.yield_data.is_none()); - } - - #[tokio::test] - async fn test_start_session_not_a_git_repo() { - let dir = tempfile::tempdir().unwrap(); - let tool = StartSessionTool; - let args = json!({ - "project_path": dir.path().to_string_lossy(), - "task_summary": "Fix a bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - assert!(result.is_error); - assert!(result.output.contains("not a git repository")); - assert!(result.yield_data.is_none()); - } - - #[tokio::test] - async fn test_start_session_valid_git_repo() { - let dir = tempfile::tempdir().unwrap(); - // Initialize a git repo - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - - let tool = StartSessionTool; - let args = json!({ - "project_path": dir.path().to_string_lossy(), - "task_summary": "Fix the login bug" - }); - let ctx = ToolContext::test_context(std::path::Path::new("/tmp")); - let result = tool.execute(args, &ctx).await.unwrap(); - - assert!(!result.is_error); - assert!(result.yield_data.is_some()); - assert!(result.output.contains("Fix the login bug")); - } -} diff --git a/crates/agent/src/types.rs b/crates/agent/src/types.rs index fece62e5..6e0b8bc9 100644 --- a/crates/agent/src/types.rs +++ b/crates/agent/src/types.rs @@ -62,13 +62,6 @@ pub enum AgentEvent { turn_count: u32, modified_files: Vec, }, - /// The ask-mode agent wants to start a coding session. - SessionStart { - session_id: String, - project_path: String, - branch: Option, - task_summary: String, - }, /// The agent is asking the user a clarifying question (yield). UserQuestionAsked { session_id: String, @@ -130,12 +123,6 @@ pub struct TodoItem { pub enum AgentResult { /// Agent completed normally with a final summary. Done { summary: String }, - /// Agent wants to start a coding session (yield from ask mode). - StartSession { - project_path: String, - branch: Option, - task_summary: String, - }, /// Agent is asking the user a clarifying question (yield from ask/plan mode). AskUser { question: String, diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 8139b591..9c0db5c4 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -165,10 +165,6 @@ async fn run_agent(config: AgentConfig, message: &str) -> TestRunResult { eprintln!("Agent completed: {summary}"); summary } - Ok(AgentResult::StartSession { task_summary, .. }) => { - eprintln!("Agent wants to start session: {task_summary}"); - task_summary - } Ok(AgentResult::AskUser { question, .. }) => { eprintln!("Agent asks: {question}"); question @@ -421,65 +417,6 @@ async fn test_agent_uses_git_tool() { // ── Phase 3 Tests ── -/// Phase 3 — Ask mode agent calls start_session when asked to make changes. -#[tokio::test] -#[ignore = "requires OPENAI_API_KEY"] -async fn test_ask_mode_starts_coding_session() { - let _ = env_logger::try_init(); - let tmp = tempdir().unwrap(); - std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap(); - - // Initialize a git repo so start_session validation passes - std::process::Command::new("git") - .args(["init"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - - let mut config = make_config(&api_key(), tmp.path().to_path_buf()); - config.system_prompt = Some(sys( - "You are a coding assistant in ask mode. You have read-only tools (read, glob, grep) \ - and a start_session tool. When the user asks you to modify code, you MUST call start_session \ - with the project_path set to the working directory and a task_summary. \ - Do NOT try to use write or edit tools — they are not available. \ - Call start_session immediately without explanation.", - )); - - let r = run_agent_with_mode( - config, - &format!( - "Please fix the main function in main.rs to print 'hello'. \ - The project path is '{}'.", - tmp.path().display() - ), - ToolMode::Ask, - ) - .await; - - // The agent should yield a StartSession result - match &r.agent_result { - AgentResult::StartSession { - project_path, - task_summary, - .. - } => { - eprintln!("StartSession: project_path={project_path}, task_summary={task_summary}"); - assert!(!project_path.is_empty(), "project_path should not be empty"); - assert!(!task_summary.is_empty(), "task_summary should not be empty"); - } - other => { - panic!("Expected StartSession, got {:?}", other); - } - } - - // Verify start_session was called - let names = r.tool_names(); - assert!( - names.contains(&"start_session"), - "Expected start_session tool, got: {names:?}" - ); -} - /// Phase 3 — Ask mode cannot use destructive tools (write, edit, bash, git). #[tokio::test] #[ignore = "requires OPENAI_API_KEY"] @@ -490,7 +427,7 @@ async fn test_ask_mode_tool_isolation() { let mut config = make_config(&api_key(), tmp.path().to_path_buf()); config.system_prompt = Some(sys( - "You are a coding assistant in ask mode. You only have read, glob, grep, and start_session tools. \ + "You are a coding assistant in ask mode. You only have read, glob, grep, and ask_user tools. \ Read the file test.txt and tell the user its content. Do NOT try to modify it.", )); @@ -501,11 +438,11 @@ async fn test_ask_mode_tool_isolation() { ) .await; - // Verify only read-only tools were used (ask mode has: read, glob, grep, start_session, ask_user) + // Verify only read-only tools were used (ask mode has: read, glob, grep, ask_user) let names = r.tool_names(); for name in &names { assert!( - ["read", "glob", "grep", "start_session", "ask_user"].contains(name), + ["read", "glob", "grep", "ask_user"].contains(name), "Ask mode used unexpected tool: {name}" ); } @@ -575,7 +512,7 @@ async fn test_coding_mode_with_persistence() { tokio::spawn(async move { let mut agent_loop = AgentLoop::new(config, registry, cancel_token, event_tx, session_id); - agent_loop = agent_loop.with_persister(persister_clone, Some("test-thread".into())); + agent_loop = agent_loop.with_persister(persister_clone, "test-thread".into()); agent_loop.run(agent::llm::types::ChatMessage::user("Read hello.txt and tell me its content.")).await }) }; @@ -618,12 +555,12 @@ async fn test_coding_mode_with_persistence() { ); eprintln!("Persisted {} messages", messages.len()); - // All should have thread_id = "test-thread" - for (tid, _msg) in &messages { + // All should have session_id = "test-thread" + for (sid, _msg) in &messages { assert_eq!( - tid.as_deref(), - Some("test-thread"), - "Expected thread_id 'test-thread'" + sid.as_str(), + "test-thread", + "Expected session_id 'test-thread'" ); } } @@ -667,7 +604,7 @@ async fn test_compaction_triggers_with_small_context() { tokio::spawn(async move { let mut agent_loop = AgentLoop::new(config, registry, cancel_token, event_tx, session_id); - agent_loop = agent_loop.with_persister(persister_clone, Some("compact-thread".into())); + agent_loop = agent_loop.with_persister(persister_clone, "compact-thread".into()); agent_loop .run(agent::llm::types::ChatMessage::user("Read all .txt files one by one (file0.txt through file4.txt) and summarize each one.")) .await @@ -810,7 +747,7 @@ async fn test_coding_mode_todo_write() { assert!(r.has_done_event()); } -/// Plan mode: tool isolation — only read-only tools + ask_user + start_session + todo_write. +/// Plan mode: tool isolation — only read-only tools + ask_user + save_plan + edit_plan. #[tokio::test] #[ignore = "requires OPENAI_API_KEY"] async fn test_plan_mode_tool_isolation() { @@ -834,7 +771,7 @@ async fn test_plan_mode_tool_isolation() { // Verify only plan-mode tools were used let names = r.tool_names(); - let plan_tools = ["read", "glob", "grep", "ask_user", "start_session", "todo_write"]; + let plan_tools = ["read", "glob", "grep", "ask_user", "save_plan", "edit_plan"]; for name in &names { assert!( plan_tools.contains(name), From eeea4d52725a655de9cdf4496632b4e8b348211f Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Tue, 2 Jun 2026 11:23:46 +0530 Subject: [PATCH 06/26] Replace worktrees with in-place editing + file-snapshot checkpoints Agent now edits the project in place instead of a per-session git worktree. Undo is provided by an app-managed file-snapshot layer that backs up a file's prior contents before write/edit/apply_patch mutate it, keyed by (session, turn) and stored outside the project. - git-ops: replace git-ref checkpoint.rs with file-snapshot module (backup_file/restore_to/diff_turn/list/delete_from); delete worktree.rs - agent: thread checkpoint_dir/turn through ToolContext + AgentConfig; capture pre-edit backups in write/edit/apply_patch - remove worktree machinery; repurpose codebase_search overlay to always reconcile against the live working copy - rewrite prompts for the in-place model --- crates/agent/src/agent/ask_prompt.txt | 2 +- crates/agent/src/agent/coding_prompt.txt | 16 +- crates/agent/src/agent/config.rs | 10 +- crates/agent/src/agent/loop_.rs | 10 +- crates/agent/src/agent/mod.rs | 6 +- crates/agent/src/agent/plan_prompt.txt | 2 +- crates/agent/src/agent/worktree.rs | 492 ------------ crates/agent/src/context_engine.rs | 2 +- crates/agent/src/session.rs | 11 +- crates/agent/src/skills/tool.rs | 8 + crates/agent/src/subagents/tool.rs | 7 +- crates/agent/src/subagents/write_lock.rs | 2 +- crates/agent/src/tool/apply_patch.rs | 4 + crates/agent/src/tool/bash.rs | 2 + crates/agent/src/tool/codebase_graph.rs | 2 +- crates/agent/src/tool/codebase_search.rs | 34 +- crates/agent/src/tool/edit.rs | 3 + crates/agent/src/tool/mod.rs | 23 + crates/agent/src/tool/write.rs | 46 ++ crates/agent/src/util.rs | 1 - crates/agent/tests/integration.rs | 1 + crates/git-ops/Cargo.toml | 2 +- crates/git-ops/src/checkpoint.rs | 974 +++++++++++++---------- crates/git-ops/src/error.rs | 9 + crates/git-ops/src/lib.rs | 5 +- crates/git-ops/src/worktree.rs | 208 ----- 26 files changed, 732 insertions(+), 1150 deletions(-) delete mode 100644 crates/agent/src/agent/worktree.rs delete mode 100644 crates/git-ops/src/worktree.rs diff --git a/crates/agent/src/agent/ask_prompt.txt b/crates/agent/src/agent/ask_prompt.txt index 646319cc..4d61d5f5 100644 --- a/crates/agent/src/agent/ask_prompt.txt +++ b/crates/agent/src/agent/ask_prompt.txt @@ -21,7 +21,7 @@ Four specialists are always available via `spawn_subagent`. Prefer spawning one - `code-architect` — "design/plan" this feature - `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) -Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). +Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per working dir). ## User subagent mentions If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. diff --git a/crates/agent/src/agent/coding_prompt.txt b/crates/agent/src/agent/coding_prompt.txt index cb4f5655..68e8719d 100644 --- a/crates/agent/src/agent/coding_prompt.txt +++ b/crates/agent/src/agent/coding_prompt.txt @@ -1,13 +1,11 @@ -You are a coding agent working in an isolated git worktree. You have full access to read, write, edit, and execute code. Your changes are isolated from the user's working copy until they choose to merge. +You are a coding agent working directly in the user's project. You have full access to read, write, edit, and execute code. Your changes apply in place to the user's working copy on their current branch. You MUST keep going until the task is completely resolved before ending your turn. Do not stop after a partial implementation — complete the full task including testing. -CRITICAL — Worktree isolation: -- You are running inside an isolated git worktree at `{{working_dir}}`. The main project lives at a separate path (the plan or task instructions may reference it). -- Your worktree was branched from a specific commit, so it may NOT contain newer files, untracked files, or uncommitted changes from the main project. -- READS from outside your worktree are ALLOWED. If the plan references an absolute path in the main project, or you need to gather context from a file that doesn't exist in your worktree yet, you may read it directly from the main project path. -- WRITES, EDITS, and bash commands MUST happen INSIDE `{{working_dir}}`. Writing outside the worktree triggers a security prompt and breaks isolation. -- When you need to modify or create a file based on something you read from the main project: do the read against the absolute main-project path, then write the new/modified version inside your worktree at the corresponding logical location. Do NOT echo the absolute main-project path into your write/edit calls. +Editing in place: +- You edit the user's project directly at `{{working_dir}}`. Writes, edits, and bash commands apply to their real working copy — there is no isolated copy to merge later. +- Each turn is checkpointed automatically: before a `write`/`edit`/`apply_patch` changes a file, its prior contents are backed up, so the user can undo a turn. (Changes made by `bash`/shell are NOT checkpointed and cannot be auto-undone — be deliberate with destructive shell commands.) +- Keep writes, edits, and bash commands inside `{{working_dir}}`. Writing outside the project triggers a security prompt. # Available Tools @@ -32,7 +30,7 @@ Four specialists are always available via `spawn_subagent`. Prefer spawning one - `code-architect` — "design/plan" this feature - `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) -Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). +Spawn code-explorer, code-reviewer, and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per working dir). ## User subagent mentions If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. @@ -95,7 +93,7 @@ Call multiple tools in a single response when they are independent. Never call d 3. **Implement** — Make small, testable changes. Follow existing code conventions (formatting, naming, structure, patterns). 4. **Test** — Run tests with `bash` after making changes. Fix what you break. If tests exist, run them. If you add new functionality, add tests for it. 5. **Verify** — Use `git diff` to review your changes. Make sure nothing unintended was modified. -6. **Commit** — Do NOT commit unless the user explicitly asks you to. Your changes are already tracked in the worktree. +6. **Commit** — Do NOT commit unless the user explicitly asks you to. Your edits are already saved to the working copy; when the user does ask, commit to the current branch. # Code Quality diff --git a/crates/agent/src/agent/config.rs b/crates/agent/src/agent/config.rs index a5370fb5..7384c67f 100644 --- a/crates/agent/src/agent/config.rs +++ b/crates/agent/src/agent/config.rs @@ -33,8 +33,8 @@ pub struct AgentConfig { pub compaction_llm: Option, /// Optional context engine. When set, codebase_search and codebase_graph are registered. pub context_engine: Option>, - /// Canonical repo path for context engine (main checkout, not worktree). - /// Required when context_engine is Some. + /// Canonical repo path the context-engine index was built from. + /// Required when context_engine is Some; defaults to working_dir if unset. pub context_engine_repo_path: Option, /// Optional skill registry. When set, the skills list is injected into the /// system prompt and the `skill` tool is registered in all modes. @@ -47,6 +47,11 @@ pub struct AgentConfig { /// factory, write-lock registry, etc.). Required at parent-turn /// registration time when `subagents` is Some. pub subagent_inheritance: Option>, + /// App-managed directory for file-snapshot checkpoints, stored OUTSIDE the + /// project. When set, file-mutating tools back up a file's prior contents + /// before editing it (keyed by `(session_id, turn)`), enabling per-turn undo. + /// `None` disables checkpoint capture (bench/tests). + pub checkpoint_dir: Option, } impl AgentConfig { @@ -65,6 +70,7 @@ impl AgentConfig { skills: None, subagents: None, subagent_inheritance: None, + checkpoint_dir: None, } } diff --git a/crates/agent/src/agent/loop_.rs b/crates/agent/src/agent/loop_.rs index 529082fe..5fca5a3a 100644 --- a/crates/agent/src/agent/loop_.rs +++ b/crates/agent/src/agent/loop_.rs @@ -459,6 +459,8 @@ impl AgentLoop { let tool_name = tool_call.function.name.clone(); let arguments_json = tool_call.function.arguments.clone(); let approval_ref = self.approval_handler.clone(); + let checkpoint_dir = self.config.checkpoint_dir.clone(); + let checkpoint_turn = iteration + self.iteration_offset; join_set.spawn(async move { let result = execute_tool_call_impl( @@ -471,6 +473,8 @@ impl AgentLoop { &event_tx, &session_id, approval_ref.as_deref(), + checkpoint_dir, + checkpoint_turn, ) .await; (idx, tool_call_id, result) @@ -1009,6 +1013,8 @@ async fn execute_tool_call_impl( event_tx: &mpsc::Sender, session_id: &str, approval_handler: Option<&dyn ApprovalHandler>, + checkpoint_dir: Option, + checkpoint_turn: u32, ) -> ToolResult { // Parse arguments first — if this fails, emit a basic ToolStart before the error ToolEnd let args: serde_json::Value = match serde_json::from_str(arguments_json) { @@ -1176,6 +1182,8 @@ async fn execute_tool_call_impl( event_tx: event_tx.clone(), session_id: session_id.to_string(), tool_call_id: tool_call_id.to_string(), + checkpoint_dir, + checkpoint_turn, }; let mut result = match tool.execute(args, &ctx).await { @@ -1230,7 +1238,7 @@ async fn execute_tool_call_impl( /// If the path is outside working_dir, show it as-is. fn shorten_path(path: &str, working_dir: &std::path::Path) -> String { let wd = working_dir.to_string_lossy(); - // Strip worktree prefix (e.g., /project/.agent-worktrees/session-id/foo.rs → foo.rs) + // Strip the project dir prefix (e.g., /Users/me/project/src/foo.rs → src/foo.rs) if let Some(rel) = path.strip_prefix(wd.as_ref()) { let rel = rel.strip_prefix('/').unwrap_or(rel); if rel.is_empty() { ".".to_string() } else { rel.to_string() } diff --git a/crates/agent/src/agent/mod.rs b/crates/agent/src/agent/mod.rs index 936dfc9d..71dffb85 100644 --- a/crates/agent/src/agent/mod.rs +++ b/crates/agent/src/agent/mod.rs @@ -2,7 +2,6 @@ pub mod config; pub mod loop_; pub mod prompt; pub mod compaction; -pub mod worktree; pub mod model_profile; use std::sync::Arc; @@ -45,10 +44,7 @@ pub fn spawn_agent( let context_engine_arg = config.context_engine.as_ref().map(|engine| { let repo_path = config.context_engine_repo_path.clone().unwrap_or_else(|| { - log::warn!( - "context_engine_repo_path not set; falling back to working_dir. \ - Worktree overlay will be disabled." - ); + log::warn!("context_engine_repo_path not set; falling back to working_dir."); config.working_dir.clone() }); (engine.clone(), repo_path) diff --git a/crates/agent/src/agent/plan_prompt.txt b/crates/agent/src/agent/plan_prompt.txt index 2c21d227..1cbd2131 100644 --- a/crates/agent/src/agent/plan_prompt.txt +++ b/crates/agent/src/agent/plan_prompt.txt @@ -23,7 +23,7 @@ Four specialists are always available via `spawn_subagent`. Prefer spawning one - `code-architect` — "design/plan" this feature (produces a build blueprint — useful for complex subsections of a plan) - `code-simplifier` — refinement pass on recent edits; ASK THE USER FIRST via `ask_user` (users often don't want an unsolicited simplify pass) -Spawn code-explorer and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per worktree). +Spawn code-explorer and code-architect on your own judgement when appropriate. The child sees only your `prompt` (no parent history) and returns a final summary. Multiple `spawn_subagent` calls in one turn run in parallel (write-capable subagents serialize per working dir). ## User subagent mentions If the user's message contains `@` (e.g. `@code-reviewer`) and `` matches an entry in "Default Subagents" above or "Available Subagents" below, treat it as a strong directive to route that work through `spawn_subagent` with `name: ""`. Derive the child's `prompt` from the surrounding request. Do not echo the `@` token back to the user — just dispatch the subagent. diff --git a/crates/agent/src/agent/worktree.rs b/crates/agent/src/agent/worktree.rs deleted file mode 100644 index d9f00736..00000000 --- a/crates/agent/src/agent/worktree.rs +++ /dev/null @@ -1,492 +0,0 @@ -use std::path::{Path, PathBuf}; - -/// Maximum number of agent-managed worktrees per project. -/// When this limit is reached, the oldest worktree is force-removed before creating a new one. -pub const MAX_AGENT_WORKTREES: usize = 10; - -/// Information about a created worktree. -#[derive(Debug, Clone)] -pub struct WorktreeInfo { - pub worktree_path: PathBuf, - pub branch: String, - pub session_id: String, - pub project_path: PathBuf, -} - -/// State of an existing worktree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WorktreeState { - Clean, - Dirty, - Missing, -} - -#[derive(Debug, thiserror::Error)] -pub enum WorktreeError { - #[error("Git error: {0}")] - Git(#[from] git_ops::GitOpsError), - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Worktree error: {0}")] - Other(String), -} - -/// Generate a branch name from a task summary: `agent/{slug}-{uuid8}`. -/// Slug is capped at 20 chars (down from 30) so the full branch name stays -/// under ~35 chars — `agent/` prefix + 20-char slug + `-` + 8-char uuid = 35. -/// The uuid suffix stays for collision avoidance when two tasks slugify the -/// same prefix (e.g. two "add a small comment" runs). -pub fn generate_branch_name(task_summary: &str) -> String { - let slug = slugify(task_summary, 20); - let uuid_short = &uuid::Uuid::new_v4().to_string()[..8]; - format!("agent/{slug}-{uuid_short}") -} - -/// Compute the worktree path for a session: `{project}/.agent-worktrees/{session_id}/`. -pub fn worktree_path(project_path: &Path, session_id: &str) -> PathBuf { - project_path.join(".agent-worktrees").join(session_id) -} - -/// Create a new worktree with its own branch. -/// `base_branch` is the user-selected branch to create the new branch FROM. -/// `branch_name_hint` overrides `task_summary` for branch naming when provided. -pub async fn create_worktree( - project_path: &Path, - session_id: &str, - base_branch: Option<&str>, - task_summary: &str, - branch_name_hint: Option<&str>, -) -> Result { - let branch_name = generate_branch_name(branch_name_hint.unwrap_or(task_summary)); - - let wt_path = worktree_path(project_path, session_id); - - // Prune stale git worktree registrations before counting - prune_stale_worktrees(project_path).await.ok(); - - // Enforce worktree limit: evict oldest agent worktree if at capacity - enforce_worktree_limit(project_path).await?; - - // Create parent directory if needed - if let Some(parent) = wt_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Add agent directories to git's info/exclude (invisible to user, not tracked). - // .agent-worktrees/ in the main repo so VS Code doesn't show thousands of untracked files. - crate::util::ensure_git_exclude(project_path, ".agent-worktrees/").await; - - // Create worktree with new branch FROM the user's selected base branch - git_ops::worktree_add(project_path, &wt_path, &branch_name, true, base_branch).await?; - - // .agent/ in the main repo's info/exclude (git only honors info/exclude from - // the shared gitdir, not per-worktree private gitdirs). Content inside worktrees - // is already covered by the .agent-worktrees/ exclusion above. - crate::util::ensure_git_exclude(project_path, ".agent/").await; - - Ok(WorktreeInfo { - worktree_path: wt_path, - branch: branch_name, - session_id: session_id.to_string(), - project_path: project_path.to_path_buf(), - }) -} - -/// Delete a worktree. -pub async fn delete_worktree(info: &WorktreeInfo) -> Result<(), WorktreeError> { - git_ops::worktree_remove(&info.project_path, &info.worktree_path).await?; - Ok(()) -} - -/// Check the state of a worktree for a given session. -pub async fn check_worktree_state( - project_path: &Path, - session_id: &str, -) -> Result { - let wt_path = worktree_path(project_path, session_id); - - if !wt_path.exists() { - return Ok(WorktreeState::Missing); - } - - // Check for uncommitted changes via git status - let status = git_ops::core::status(&wt_path).await?; - if status.staged.is_empty() - && status.unstaged.is_empty() - && status.untracked.is_empty() - { - Ok(WorktreeState::Clean) - } else { - Ok(WorktreeState::Dirty) - } -} - -/// Prune stale worktree entries from git. -pub async fn prune_stale_worktrees(project_path: &Path) -> Result<(), WorktreeError> { - git_ops::worktree_prune(project_path).await?; - Ok(()) -} - -/// Enforce the worktree limit by evicting the oldest agent worktree(s) if at capacity. -async fn enforce_worktree_limit(project_path: &Path) -> Result<(), WorktreeError> { - let agent_wts = list_agent_worktrees(project_path).await?; - - if agent_wts.len() < MAX_AGENT_WORKTREES { - return Ok(()); - } - - // Sort by creation time (oldest first) — already sorted by list_agent_worktrees - let to_evict = agent_wts.len() - MAX_AGENT_WORKTREES + 1; // +1 to make room for the new one - for wt in agent_wts.iter().take(to_evict) { - log::info!( - "[worktree] Evicting oldest agent worktree to stay within limit ({}): {}", - MAX_AGENT_WORKTREES, - wt.worktree_path.display() - ); - if let Err(e) = git_ops::worktree_remove(project_path, &wt.worktree_path).await { - log::warn!("[worktree] Failed to remove worktree {}: {e}", wt.worktree_path.display()); - // Try filesystem removal as fallback - let _ = tokio::fs::remove_dir_all(&wt.worktree_path).await; - } - } - - // Prune again after removal to clean up git metadata - prune_stale_worktrees(project_path).await.ok(); - Ok(()) -} - -/// An existing agent-managed worktree found on disk. -#[derive(Debug)] -struct AgentWorktree { - worktree_path: PathBuf, - created: std::time::SystemTime, -} - -/// List agent-managed worktrees under `.agent-worktrees/`, sorted oldest-first. -async fn list_agent_worktrees(project_path: &Path) -> Result, WorktreeError> { - let agent_wt_root = project_path.join(".agent-worktrees"); - - if !agent_wt_root.exists() { - return Ok(Vec::new()); - } - - // Get git-registered worktrees to cross-reference - let git_worktrees = git_ops::worktree_list(project_path).await.unwrap_or_default(); - let git_paths: std::collections::HashSet = git_worktrees.iter() - .map(|e| e.path.clone()) - .collect(); - - let mut agent_wts = Vec::new(); - let mut entries = tokio::fs::read_dir(&agent_wt_root).await?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - // Only count worktrees that git knows about (or that exist on disk) - let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); - let is_git_registered = git_paths.contains(&canonical) || git_paths.contains(&path); - - // Include if git-registered or if the directory simply exists (could be orphaned) - if is_git_registered || path.exists() { - let created = entry.metadata().await - .and_then(|m| m.created().or_else(|_| m.modified())) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - - agent_wts.push(AgentWorktree { - worktree_path: canonical, - created, - }); - } - } - - // Sort oldest first - agent_wts.sort_by_key(|w| w.created); - Ok(agent_wts) -} - -/// Convert text to a URL-safe slug, limited to `max_len` characters. -fn slugify(text: &str, max_len: usize) -> String { - let slug: String = text - .to_lowercase() - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c - } else { - '-' - } - }) - .collect(); - - // Collapse multiple hyphens - let mut result = String::new(); - let mut last_was_hyphen = false; - for c in slug.chars() { - if c == '-' { - if !last_was_hyphen && !result.is_empty() { - result.push(c); - last_was_hyphen = true; - } - } else { - result.push(c); - last_was_hyphen = false; - } - } - - // Trim trailing hyphens - let result = result.trim_end_matches('-').to_string(); - - // Truncate to max_len - if result.len() > max_len { - result[..max_len].trim_end_matches('-').to_string() - } else { - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - use tokio::process::Command; - - async fn init_repo(dir: &Path) { - Command::new("git") - .args(["init"]) - .current_dir(dir) - .output() - .await - .unwrap(); - Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(dir) - .output() - .await - .unwrap(); - Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(dir) - .output() - .await - .unwrap(); - std::fs::write(dir.join("README.md"), "# Test").unwrap(); - Command::new("git") - .args(["add", "-A"]) - .current_dir(dir) - .output() - .await - .unwrap(); - Command::new("git") - .args(["commit", "-m", "initial"]) - .current_dir(dir) - .output() - .await - .unwrap(); - } - - #[test] - fn test_generate_branch_name_format() { - let name = generate_branch_name("Fix the login bug"); - assert!(name.starts_with("agent/fix-the-login-bug-"), "Got: {name}"); - // UUID part should be 8 chars - let parts: Vec<&str> = name.rsplitn(2, '-').collect(); - assert_eq!(parts[0].len(), 8); - } - - #[test] - fn test_slugify_special_chars() { - assert_eq!(slugify("Hello, World! 123", 50), "hello-world-123"); - } - - #[test] - fn test_slugify_length_limit() { - let result = slugify("this is a very long task summary that exceeds the limit", 20); - assert!(result.len() <= 20, "Got len {}: {result}", result.len()); - assert!(!result.ends_with('-')); - } - - #[test] - fn test_worktree_path_format() { - let path = worktree_path(Path::new("/home/user/project"), "abc123def456xyz"); - assert_eq!( - path, - PathBuf::from("/home/user/project/.agent-worktrees/abc123def456xyz") - ); - } - - #[tokio::test] - async fn test_create_and_delete_worktree() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - let info = create_worktree(dir.path(), "test-session-id", None, "Fix login bug", None) - .await - .unwrap(); - - assert!(info.worktree_path.exists(), "Worktree should exist"); - assert!(info.branch.starts_with("agent/")); - - // Verify it's a valid git worktree - let status = Command::new("git") - .args(["status"]) - .current_dir(&info.worktree_path) - .output() - .await - .unwrap(); - assert!(status.status.success()); - - // Delete - delete_worktree(&info).await.unwrap(); - assert!(!info.worktree_path.exists(), "Worktree should be gone"); - } - - #[tokio::test] - async fn test_check_worktree_state_clean() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - let _info = create_worktree(dir.path(), "clean-session", None, "Clean task", None) - .await - .unwrap(); - - let state = check_worktree_state(dir.path(), "clean-session").await.unwrap(); - assert_eq!(state, WorktreeState::Clean); - } - - #[tokio::test] - async fn test_check_worktree_state_dirty() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - let info = create_worktree(dir.path(), "dirty-session", None, "Dirty task", None) - .await - .unwrap(); - - // Create an uncommitted file in the worktree - std::fs::write(info.worktree_path.join("new_file.txt"), "dirty").unwrap(); - - let state = check_worktree_state(dir.path(), "dirty-session").await.unwrap(); - assert_eq!(state, WorktreeState::Dirty); - } - - #[tokio::test] - async fn test_check_worktree_state_missing() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - let state = check_worktree_state(dir.path(), "nonexistent-session").await.unwrap(); - assert_eq!(state, WorktreeState::Missing); - } - - #[tokio::test] - async fn test_prune_stale_worktrees() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - // Should not error even with no stale worktrees - prune_stale_worktrees(dir.path()).await.unwrap(); - } - - #[tokio::test] - async fn test_check_worktree_state_returns_error_on_git_failure() { - // Use a separate temp dir (outside any git repo) so git status actually fails - let isolated = tempdir().unwrap(); - let fake_project = isolated.path().join("project"); - std::fs::create_dir_all(&fake_project).unwrap(); - - // Create the worktree path so it exists, but it's not a git repo at all - let fake_wt = fake_project.join(".agent-worktrees").join("broken-session"); - std::fs::create_dir_all(&fake_wt).unwrap(); - std::fs::write(fake_wt.join("not-a-repo.txt"), "hello").unwrap(); - - let result = check_worktree_state(&fake_project, "broken-session").await; - // Should return an error (not Missing), because the path exists but git status fails - assert!(result.is_err(), "Expected error for non-git directory, got: {:?}", result); - } - - #[tokio::test] - async fn test_evicts_oldest_when_limit_reached() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - // Create MAX_AGENT_WORKTREES worktrees - let mut infos = Vec::new(); - for i in 0..MAX_AGENT_WORKTREES { - let info = create_worktree(dir.path(), &format!("evict-{i}"), None, &format!("task {i}"), None) - .await - .unwrap(); - infos.push(info); - // Small delay so filesystem timestamps differ - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - - // All should exist - for info in &infos { - assert!(info.worktree_path.exists(), "Worktree {} should exist", info.session_id); - } - - // Create one more — should evict the oldest (evict-0) - let new_info = create_worktree(dir.path(), "evict-new", None, "new task", None) - .await - .unwrap(); - - assert!(new_info.worktree_path.exists(), "New worktree should exist"); - - // The oldest worktree should have been removed - // (canonicalize might differ, so check the session dir name) - let agent_wt_root = dir.path().join(".agent-worktrees"); - assert!(!agent_wt_root.join("evict-0").exists(), "Oldest worktree (evict-0) should have been evicted"); - - // Total count should be at most MAX_AGENT_WORKTREES - let remaining = list_agent_worktrees(dir.path()).await.unwrap(); - assert!( - remaining.len() <= MAX_AGENT_WORKTREES, - "Expected at most {} worktrees, found {}", - MAX_AGENT_WORKTREES, - remaining.len() - ); - } - - #[tokio::test] - async fn test_no_eviction_when_below_limit() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - // Create 2 worktrees (well below limit) - let info1 = create_worktree(dir.path(), "below-1", None, "task 1", None) - .await - .unwrap(); - let info2 = create_worktree(dir.path(), "below-2", None, "task 2", None) - .await - .unwrap(); - let info3 = create_worktree(dir.path(), "below-3", None, "task 3", None) - .await - .unwrap(); - - // All 3 should still exist - assert!(info1.worktree_path.exists()); - assert!(info2.worktree_path.exists()); - assert!(info3.worktree_path.exists()); - } - - #[tokio::test] - async fn test_stale_worktree_pruned_before_limit_check() { - let dir = tempdir().unwrap(); - init_repo(dir.path()).await; - - // Create a worktree, then manually delete its directory - let info = create_worktree(dir.path(), "stale-session", None, "stale task", None) - .await - .unwrap(); - std::fs::remove_dir_all(&info.worktree_path).unwrap(); - - // Creating another should succeed (stale entry gets pruned, doesn't count toward limit) - let info2 = create_worktree(dir.path(), "fresh-session", None, "fresh task", None) - .await - .unwrap(); - assert!(info2.worktree_path.exists()); - } -} diff --git a/crates/agent/src/context_engine.rs b/crates/agent/src/context_engine.rs index 7c1425b7..e6dc8ba0 100644 --- a/crates/agent/src/context_engine.rs +++ b/crates/agent/src/context_engine.rs @@ -58,7 +58,7 @@ pub struct ContextEngineConfig { pub workspace_id: u64, /// Machine ID (UUID from SQLite) pub machine_id: String, - /// Canonical repo path (main checkout, NOT worktree) + /// Canonical repo path the index was built from (the user's project checkout) pub repo_path: String, /// Auth token (X-Auth-Token header). Empty = no auth. pub auth_token: String, diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index e98f2303..5c2cac83 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -108,7 +108,6 @@ impl SessionManager { session_id: SessionId, config: AgentConfig, message: ChatMessage, - worktree_path: Option, branch: Option, initial_context: Option>, persist_session_id: Option, @@ -118,12 +117,15 @@ impl SessionManager { is_resume: bool, persister_override: Option>, ) -> Result, AgentError> { + // The agent edits the project in place, so the session's project path IS + // its working directory (no separate worktree). + let project_path = Some(config.working_dir.clone()); self.start_session_inner( session_id, config, message, ToolMode::Coding, - worktree_path, + project_path, branch, initial_context, None, @@ -160,10 +162,7 @@ impl SessionManager { let cancel_token = CancellationToken::new(); let context_engine_arg = config.context_engine.as_ref().map(|engine| { let repo_path = config.context_engine_repo_path.clone().unwrap_or_else(|| { - log::warn!( - "context_engine_repo_path not set; falling back to working_dir. \ - Worktree overlay will be disabled." - ); + log::warn!("context_engine_repo_path not set; falling back to working_dir."); config.working_dir.clone() }); (engine.clone(), repo_path) diff --git a/crates/agent/src/skills/tool.rs b/crates/agent/src/skills/tool.rs index a3bdb39e..6f7fe6df 100644 --- a/crates/agent/src/skills/tool.rs +++ b/crates/agent/src/skills/tool.rs @@ -130,6 +130,8 @@ mod tests { event_tx: tx, session_id: "sess".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, }; let result = tool .execute(json!({"name": "nope"}), &ctx) @@ -150,6 +152,8 @@ mod tests { event_tx: tx, session_id: "sess".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, }; let result = tool .execute(json!({"name": "hello"}), &ctx) @@ -189,6 +193,8 @@ mod tests { event_tx: tx, session_id: "sess".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, }; let result = tool .execute(json!({"name": "meta"}), &ctx) @@ -211,6 +217,8 @@ mod tests { event_tx: tx, session_id: "sess".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, }; let err = tool.execute(json!({}), &ctx).await.unwrap_err(); assert!(err.0.contains("Missing")); diff --git a/crates/agent/src/subagents/tool.rs b/crates/agent/src/subagents/tool.rs index 36d4e778..85b31612 100644 --- a/crates/agent/src/subagents/tool.rs +++ b/crates/agent/src/subagents/tool.rs @@ -43,6 +43,8 @@ pub struct SubagentInheritance { pub approval_handler_factory: Option>, pub parent_session_id: Option, pub write_lock_registry: Arc, + /// Inherited file-snapshot checkpoint dir so child edits are captured too. + pub checkpoint_dir: Option, } pub struct SpawnSubagentTool { @@ -52,7 +54,7 @@ pub struct SpawnSubagentTool { } /// Tools that make a subagent "write-capable" and thus force it to -/// serialize on the per-worktree mutex. Mirrors Coding-mode's mutating set +/// serialize on the per-working-dir mutex. Mirrors Coding-mode's mutating set /// from `tool::mod::ToolRegistry::for_mode`. const WRITE_CAPABLE_TOOLS: &[&str] = &[ "write", @@ -81,7 +83,7 @@ fn build_description(registry: &SubagentRegistry) -> String { summary as this tool's result. Child shares the parent's working \ directory. Multiple calls in one assistant turn run concurrently \ unless the subagent is write-capable (write-capable children serialize \ - per worktree).\n\nAvailable subagents:\n", + per working dir).\n\nAvailable subagents:\n", ); for (name, description) in registry.list_for_prompt() { s.push_str(&format!("- {name}: {description}\n")); @@ -242,6 +244,7 @@ impl Tool for SpawnSubagentTool { skills: None, subagents: None, subagent_inheritance: None, // depth-1 enforced + checkpoint_dir: self.inherit.checkpoint_dir.clone(), }; // Child event channel + drain. Per DEC-1 (in docs/subagent-bugs-and-fixes.md), diff --git a/crates/agent/src/subagents/write_lock.rs b/crates/agent/src/subagents/write_lock.rs index 29a27b9a..0fcd3a85 100644 --- a/crates/agent/src/subagents/write_lock.rs +++ b/crates/agent/src/subagents/write_lock.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; /// Per-working_dir mutex registry for write-capable subagents. Two -/// concurrent write-capable children against the same worktree serialize; +/// concurrent write-capable children against the same working dir serialize; /// read-only children bypass the registry entirely. /// /// The outer Mutex is a cheap `std::sync::Mutex` because it only guards diff --git a/crates/agent/src/tool/apply_patch.rs b/crates/agent/src/tool/apply_patch.rs index ddbac6e5..0327961f 100644 --- a/crates/agent/src/tool/apply_patch.rs +++ b/crates/agent/src/tool/apply_patch.rs @@ -106,6 +106,10 @@ impl Tool for ApplyPatchTool { // ── Phase 2: Apply all validated changes to disk ── for (path, name, _op, action) in &staged { + // Back up each file's prior state before mutating it (per-turn undo). + // backup_file records the did-not-exist sentinel for created files. + ctx.checkpoint(path).await; + match action { Staged::Delete => { if path.exists() { diff --git a/crates/agent/src/tool/bash.rs b/crates/agent/src/tool/bash.rs index afdfc4c9..45fd8884 100644 --- a/crates/agent/src/tool/bash.rs +++ b/crates/agent/src/tool/bash.rs @@ -229,6 +229,8 @@ mod tests { event_tx: tx, session_id: "test".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, }; let tool = BashTool; diff --git a/crates/agent/src/tool/codebase_graph.rs b/crates/agent/src/tool/codebase_graph.rs index fc742e2f..a70efb79 100644 --- a/crates/agent/src/tool/codebase_graph.rs +++ b/crates/agent/src/tool/codebase_graph.rs @@ -73,7 +73,7 @@ impl Tool for CodebaseGraphTool { )); } - let _ = ctx; // ToolContext not needed for graph queries (no worktree overlay) + let _ = ctx; // ToolContext not needed for graph queries (no working-copy overlay) log::info!( "[codebase_graph] query={:?}, function_name={:?}, file_path={:?}, query_type={:?}", diff --git a/crates/agent/src/tool/codebase_search.rs b/crates/agent/src/tool/codebase_search.rs index cc260ac1..902cd27c 100644 --- a/crates/agent/src/tool/codebase_search.rs +++ b/crates/agent/src/tool/codebase_search.rs @@ -12,7 +12,8 @@ use super::{Tool, ToolContext, ToolResult}; pub struct CodebaseSearchTool { context_engine: Arc, - /// Canonical repo path (main checkout) — used for overlay detection. + /// Canonical repo path the context-engine index was built from (for logging / + /// index identity). The agent edits this same dir in place. repo_path: PathBuf, } @@ -116,15 +117,16 @@ impl Tool for CodebaseSearchTool { let mut results = response.results; - // Apply worktree overlay when running in a worktree (working_dir != repo_path) - if ctx.working_dir != self.repo_path { - log::info!("[codebase_search] Worktree detected (working_dir={:?}, repo_path={:?}), applying overlay", - ctx.working_dir, self.repo_path); - if let Err(e) = apply_worktree_overlay(&mut results, &ctx.working_dir).await { - log::warn!("[codebase_search] Worktree overlay failed, skipping: {e}"); - } - } else { - log::info!("[codebase_search] No worktree (working_dir == repo_path), skipping overlay"); + // Reconcile results against the user's live working copy. The context-engine + // index is built from the canonical repo (`self.repo_path`) and lags the + // agent's in-place edits, so we always overlay `git diff HEAD` from the + // working dir to drop deleted files and flag stale (modified) ones. + log::info!( + "[codebase_search] applying local overlay (working_dir={:?}, index_repo={:?})", + ctx.working_dir, self.repo_path + ); + if let Err(e) = apply_local_overlay(&mut results, &ctx.working_dir).await { + log::warn!("[codebase_search] local overlay failed, skipping: {e}"); } let mut output = format!("Found {} results for \"{}\":\n\n", results.len(), query); @@ -144,18 +146,18 @@ impl Tool for CodebaseSearchTool { } } -/// Overlay search results with worktree state. +/// Overlay search results with the live working-copy state. /// Modified files: annotated (chunk content may be stale; no line-range data to re-extract). /// Deleted files: drop from results. /// New files: NOT added (not in index to match against). -async fn apply_worktree_overlay( +async fn apply_local_overlay( results: &mut Vec, - worktree_path: &Path, + working_dir: &Path, ) -> Result<(), ToolError> { // 1. Get all modified + added files let mut cmd = Command::new("git"); cmd.args(["diff", "--name-only", "HEAD"]) - .current_dir(worktree_path); + .current_dir(working_dir); git_ops::no_window::no_window_tokio(&mut cmd); let output = cmd .output() @@ -175,7 +177,7 @@ async fn apply_worktree_overlay( // 2. Get deleted files specifically let mut cmd = Command::new("git"); cmd.args(["diff", "--name-only", "--diff-filter=D", "HEAD"]) - .current_dir(worktree_path); + .current_dir(working_dir); git_ops::no_window::no_window_tokio(&mut cmd); let del_output = cmd .output() @@ -212,7 +214,7 @@ async fn apply_worktree_overlay( log::info!("[codebase_search] overlay: annotating stale file {}", result.file_path); annotated += 1; result.content = format!( - "/* NOTE: This file has local modifications in the worktree. \ + "/* NOTE: This file has local modifications in the working copy. \ Content below is from the index and may be stale. \ Use the read tool to see current content. */\n{}", result.content diff --git a/crates/agent/src/tool/edit.rs b/crates/agent/src/tool/edit.rs index cf69d170..7dd8eea6 100644 --- a/crates/agent/src/tool/edit.rs +++ b/crates/agent/src/tool/edit.rs @@ -84,6 +84,9 @@ impl Tool for EditTool { match replace(&raw_content, old_string, new_string, replace_all) { Ok(new_content) => { + // Back up the file's prior contents before editing (per-turn undo). + ctx.checkpoint(&path).await; + // Preserve original line ending style tokio::fs::write(&path, &new_content) .await diff --git a/crates/agent/src/tool/mod.rs b/crates/agent/src/tool/mod.rs index b916a52e..0c77f226 100644 --- a/crates/agent/src/tool/mod.rs +++ b/crates/agent/src/tool/mod.rs @@ -73,6 +73,27 @@ pub struct ToolContext { pub event_tx: mpsc::Sender, pub session_id: String, pub tool_call_id: String, + /// App-managed directory for file-snapshot checkpoints (outside the project). + /// `None` disables capture (bench/tests). File-mutating tools call + /// `git_ops::backup_file` here before mutating, keyed to `(session_id, checkpoint_turn)`. + pub checkpoint_dir: Option, + /// Current turn number, used as the checkpoint key alongside `session_id`. + pub checkpoint_turn: u32, +} + +impl ToolContext { + /// Best-effort: back up `path`'s prior contents BEFORE a mutating tool edits it, + /// so the turn can be undone. No-op when checkpointing is disabled. Failures are + /// logged, not propagated — a missing backup must never block the edit itself. + pub(crate) async fn checkpoint(&self, path: &std::path::Path) { + if let Some(dir) = &self.checkpoint_dir { + if let Err(e) = + git_ops::backup_file(dir, &self.session_id, self.checkpoint_turn, path).await + { + log::warn!("checkpoint backup_file failed for {}: {e}", path.display()); + } + } + } } #[cfg(test)] @@ -85,6 +106,8 @@ impl ToolContext { event_tx: tx, session_id: "test".into(), tool_call_id: "tc_1".into(), + checkpoint_dir: None, + checkpoint_turn: 0, } } } diff --git a/crates/agent/src/tool/write.rs b/crates/agent/src/tool/write.rs index bbb59efd..8d12a1a4 100644 --- a/crates/agent/src/tool/write.rs +++ b/crates/agent/src/tool/write.rs @@ -58,6 +58,9 @@ impl Tool for WriteTool { .map_err(|e| ToolError(format!("Failed to create parent directories: {e}")))?; } + // Back up the file's prior contents before overwriting (per-turn undo). + ctx.checkpoint(&path).await; + let byte_count = content.len(); tokio::fs::write(&path, content) .await @@ -71,6 +74,49 @@ impl Tool for WriteTool { mod tests { use super::*; use tempfile::tempdir; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + /// End-to-end: a `write` then `edit` through the real tool path with a checkpoint + /// dir set must back up prior contents so `restore_to` reverts the agent's edits. + #[tokio::test] + async fn test_write_edit_then_restore_roundtrip() { + let proj = tempdir().unwrap(); + let ckpt = tempdir().unwrap(); + let file = proj.path().join("src/lib.rs"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "original\n").unwrap(); + + let (tx, _rx) = mpsc::channel(32); + let ctx = ToolContext { + working_dir: proj.path().to_path_buf(), + cancel_token: CancellationToken::new(), + event_tx: tx, + session_id: "sess".into(), + tool_call_id: "tc_1".into(), + checkpoint_dir: Some(ckpt.path().to_path_buf()), + checkpoint_turn: 1, + }; + + // write overwrites the file (backs up "original\n") ... + WriteTool + .execute(json!({ "filePath": "src/lib.rs", "content": "rewritten\n" }), &ctx) + .await + .unwrap(); + // ... and edit mutates it again within the same turn (no-op backup; first wins). + crate::tool::edit::EditTool + .execute( + json!({ "filePath": "src/lib.rs", "oldString": "rewritten", "newString": "edited" }), + &ctx, + ) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited\n"); + + // Restore to before turn 1 -> back to the turn's starting contents. + git_ops::restore_to(ckpt.path(), "sess", 0).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "original\n"); + } #[tokio::test] async fn test_write_new_file() { diff --git a/crates/agent/src/util.rs b/crates/agent/src/util.rs index 6067186e..43c14d28 100644 --- a/crates/agent/src/util.rs +++ b/crates/agent/src/util.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; /// Ensure the `.agent/` directory exists and is excluded from git via `info/exclude`. /// Called by any tool that writes to `.agent/` (save_plan, todo_write, truncate_and_persist). -/// Works in both the main repo and worktrees. pub(crate) async fn ensure_agent_dir(working_dir: &Path) -> PathBuf { let agent_dir = working_dir.join(".agent"); let _ = tokio::fs::create_dir_all(&agent_dir).await; diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 9c0db5c4..2e3348da 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -136,6 +136,7 @@ fn make_config(api_key: &str, working_dir: PathBuf) -> AgentConfig { skills: None, subagents: None, subagent_inheritance: None, + checkpoint_dir: None, } } diff --git a/crates/git-ops/Cargo.toml b/crates/git-ops/Cargo.toml index 81edbdfe..be59e8fb 100644 --- a/crates/git-ops/Cargo.toml +++ b/crates/git-ops/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -tokio = { version = "1", features = ["process"] } +tokio = { version = "1", features = ["process", "fs", "io-util"] } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/git-ops/src/checkpoint.rs b/crates/git-ops/src/checkpoint.rs index e0c61dde..502adeb9 100644 --- a/crates/git-ops/src/checkpoint.rs +++ b/crates/git-ops/src/checkpoint.rs @@ -1,494 +1,668 @@ -use std::path::Path; +//! App-managed file-snapshot checkpoint layer. +//! +//! Replaces the old git-ref checkpoint system. The coding agent edits the user's +//! project directory IN PLACE; before a file-mutating tool (write/edit/apply_patch) +//! touches a file, it calls [`backup_file`] to stash the file's prior contents into +//! an out-of-tree checkpoint directory keyed by `(session, turn)`. [`restore_to`] +//! reverse-applies those backups to undo a range of turns. +//! +//! Shell/bash-driven changes are intentionally NOT captured — only the tools that +//! call `backup_file` are covered (same limitation as Claude Code / Cursor). + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; use crate::error::GitOpsError; -use crate::exec::{run_git, run_git_raw}; use crate::types::DiffOutput; -/// Info about a captured checkpoint. -#[derive(Debug, Clone)] -pub struct CheckpointInfo { - pub ref_name: String, - pub commit_sha: String, - pub turn_count: u32, - pub created_at: String, // ISO 8601 from git creatordate +/// One backed-up file within a turn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupEntry { + /// Absolute path of the file in the user's project. + pub path: String, + /// `false` => the agent created this file (no prior state); restore deletes it. + pub existed_before: bool, + /// Blob filename under `blobs/` holding the prior bytes. `None` iff `!existed_before`. + pub blob: Option, } -/// Generate the git ref path for a checkpoint. -/// Format: `refs/agent/checkpoints/{thread_id}/turn-{turn}` -pub fn checkpoint_ref(thread_id: &str, turn: u32) -> String { - format!("refs/agent/checkpoints/{}/turn-{}", thread_id, turn) +/// Per-turn manifest, persisted as `manifest.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnManifest { + pub version: u32, + pub session_id: String, + pub turn: u32, + pub entries: Vec, } -/// Check if a checkpoint ref exists in the repository. -pub async fn has_checkpoint( - repo_path: &Path, - ref_name: &str, -) -> Result { - let output = run_git_raw(repo_path, &["rev-parse", "--verify", ref_name]).await?; - Ok(output.exit_code == 0) +/// Summary of one available turn (replacement for the old `CheckpointInfo`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnInfo { + pub turn: u32, + pub file_count: usize, + /// Absolute paths touched in this turn (from the manifest). + pub paths: Vec, } -/// Capture the current worktree state as a hidden git ref. -/// -/// Performs: -/// 1. git add -A (stage everything including untracked) -/// 2. git write-tree -> tree SHA -/// 3. git rev-parse HEAD -> parent SHA -/// 4. git commit-tree {tree} -p {parent} -m "checkpoint: {ref_name}" -> commit SHA -/// 5. git update-ref {ref_name} {commit_sha} -/// -/// Returns the commit SHA of the checkpoint. -pub async fn capture_checkpoint( - repo_path: &Path, - ref_name: &str, -) -> Result { - // 1. Stage everything (including untracked files) - run_git(repo_path, &["add", "-A"]).await?; - - // 2. Write the current index as a tree object - let tree_output = run_git(repo_path, &["write-tree"]).await?; - let tree_sha = tree_output.stdout.trim().to_string(); - - // 3. Get the current HEAD as parent - let head_output = run_git(repo_path, &["rev-parse", "HEAD"]).await?; - let parent_sha = head_output.stdout.trim().to_string(); - - // 4. Create a commit object (dangling — not on any branch) - let msg = format!("checkpoint: {}", ref_name); - let commit_output = run_git( - repo_path, - &["commit-tree", &tree_sha, "-p", &parent_sha, "-m", &msg], - ) - .await?; - let commit_sha = commit_output.stdout.trim().to_string(); - - // 5. Point the ref at the new commit - run_git(repo_path, &["update-ref", ref_name, &commit_sha]).await?; - - // NOTE: We intentionally do NOT run `git reset HEAD` here. - // The staged state from `git add -A` is harmless — the agent's tools (write/edit/bash) - // work on the filesystem directly, not via git staging. And final diffs use - // checkpoint-based refs, not `git diff` (unstaged). - // Running `git reset HEAD` would unstage new files, which breaks the LLM's - // `git add` + `git commit` workflow (the checkpoint fires between tool calls - // and would unstage files the LLM just staged). - - Ok(commit_sha) +const MANIFEST_VERSION: u32 = 1; + +// --------------------------------------------------------------------------- +// Path / layout helpers +// --------------------------------------------------------------------------- + +/// Encode a session id into a single filesystem-safe directory segment. +/// Chars outside `[A-Za-z0-9._-]` are percent-encoded byte-wise (reversible, +/// collision-free). The raw id is also stored inside each manifest. +fn sanitize_session_id(session_id: &str) -> String { + let mut out = String::with_capacity(session_id.len()); + for &b in session_id.as_bytes() { + let safe = b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'); + if safe { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out } -/// Compute the diff between two checkpoint refs. -/// Returns a DiffOutput with raw unified diff text + stats. -pub async fn diff_checkpoints( - repo_path: &Path, - from_ref: &str, - to_ref: &str, -) -> Result { - let range = format!("{}..{}", from_ref, to_ref); - crate::core::diff(repo_path, None, false, Some(&range)).await +fn session_root(checkpoint_dir: &Path, session_id: &str) -> PathBuf { + checkpoint_dir.join(sanitize_session_id(session_id)) } -/// Restore the worktree to the state of a checkpoint. -/// -/// Steps: -/// 1. Capture a pre-restore snapshot at {ref_name}-pre-restore -/// 2. git checkout {ref_name} -- . (restore files without moving branch) -/// -/// The pre-restore snapshot ensures recovery is always possible. -pub async fn restore_checkpoint( - repo_path: &Path, - ref_name: &str, -) -> Result<(), GitOpsError> { - // 1. Safety: capture the current state before restoring - let pre_restore_ref = format!("{}-pre-restore", ref_name); - capture_checkpoint(repo_path, &pre_restore_ref).await?; - - // 2. Restore working tree to match the checkpoint exactly. - // read-tree sets the index to the checkpoint's tree. - // checkout-index writes all index entries to the working tree. - // clean -fd removes working tree files no longer in the index. - // This handles modifications, deletions, AND additions correctly - // (unlike `git checkout -- .` which leaves added files behind). - run_git(repo_path, &["read-tree", ref_name]).await?; - run_git(repo_path, &["checkout-index", "-f", "-a"]).await?; - run_git(repo_path, &["clean", "-fd"]).await?; +fn turn_dir(session_root: &Path, turn: u32) -> PathBuf { + session_root.join(format!("turn-{turn:04}")) +} + +/// Parse the turn number out of a `turn-NNNN` directory name. +fn parse_turn_from_dirname(name: &str) -> Option { + name.strip_prefix("turn-")?.parse().ok() +} + +async fn read_manifest(turn_dir: &Path) -> Result, GitOpsError> { + let manifest_path = turn_dir.join("manifest.json"); + match tokio::fs::read(&manifest_path).await { + Ok(bytes) => { + let manifest = serde_json::from_slice(&bytes).map_err(|e| GitOpsError::CorruptManifest { + path: manifest_path.to_string_lossy().to_string(), + reason: e.to_string(), + })?; + Ok(Some(manifest)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(GitOpsError::Io(e)), + } +} +/// Write the manifest atomically (`manifest.json.tmp` + rename). +async fn write_manifest_atomic(turn_dir: &Path, manifest: &TurnManifest) -> Result<(), GitOpsError> { + tokio::fs::create_dir_all(turn_dir).await?; + let json = serde_json::to_vec_pretty(manifest)?; + let tmp = turn_dir.join("manifest.json.tmp"); + let final_path = turn_dir.join("manifest.json"); + tokio::fs::write(&tmp, &json).await?; + tokio::fs::rename(&tmp, &final_path).await?; Ok(()) } -/// Extract the turn number from a checkpoint ref name. -/// Expected format: `refs/agent/checkpoints/{thread_id}/turn-{N}` -/// Also handles pre-restore refs like `turn-2-pre-restore`. -fn parse_turn_from_ref(ref_name: &str) -> Option { - let last_segment = ref_name.rsplit('/').next()?; - let turn_part = last_segment.strip_prefix("turn-")?; - // Handle "turn-5" or "turn-5-pre-restore" - let num_str = turn_part.split('-').next()?; - num_str.parse().ok() +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Back up a file's prior state BEFORE a mutating tool writes to it. +/// +/// - The FIRST backup of a given path within a turn wins (idempotent per path/turn): +/// this guarantees [`restore_to`] reverts to the turn's *starting* state even if a +/// file is edited multiple times in one turn. +/// - If the file does not currently exist, records `existed_before = false` (no blob) +/// so restore knows to delete the agent-created file. +/// - `file_path` must be absolute. +pub async fn backup_file( + checkpoint_dir: &Path, + session_id: &str, + turn: u32, + file_path: &Path, +) -> Result<(), GitOpsError> { + if !file_path.is_absolute() { + return Err(GitOpsError::NonAbsolutePath( + file_path.to_string_lossy().to_string(), + )); + } + + let root = session_root(checkpoint_dir, session_id); + let tdir = turn_dir(&root, turn); + let path_key = file_path.to_string_lossy().to_string(); + + // Load (or start) this turn's manifest. + let mut manifest = read_manifest(&tdir).await?.unwrap_or(TurnManifest { + version: MANIFEST_VERSION, + session_id: session_id.to_string(), + turn, + entries: Vec::new(), + }); + + // First backup of this path in this turn wins. + if manifest.entries.iter().any(|e| e.path == path_key) { + return Ok(()); + } + + // Capture prior state. + let existed_before = match tokio::fs::metadata(file_path).await { + Ok(m) => m.is_file(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(GitOpsError::Io(e)), + }; + + let blob = if existed_before { + let bytes = tokio::fs::read(file_path).await?; + let blobs_dir = tdir.join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await?; + let blob_name = format!("{:04}.blob", manifest.entries.len()); + tokio::fs::write(blobs_dir.join(&blob_name), &bytes).await?; + Some(blob_name) + } else { + None + }; + + manifest.entries.push(BackupEntry { + path: path_key, + existed_before, + blob, + }); + write_manifest_atomic(&tdir, &manifest).await?; + Ok(()) } -/// Delete all checkpoint refs for a thread from a given turn onward. -/// Used after restore to clean up stale future checkpoints. -/// Also deletes associated pre-restore snapshots (e.g. `turn-2-pre-restore` -/// is treated as belonging to turn 2 and deleted when `from_turn <= 2`). -/// Returns the number of refs deleted. -pub async fn delete_checkpoint_refs( - repo_path: &Path, - thread_id: &str, - from_turn: u32, -) -> Result { - let prefix = format!("refs/agent/checkpoints/{}/", thread_id); - let output = run_git(repo_path, &["for-each-ref", "--format=%(refname)", &prefix]).await?; +/// Restore project state to the end of `target_turn` by reverse-applying every turn +/// strictly greater than `target_turn` (newest-first). The edits captured *in* +/// `target_turn` are kept. Returns the number of files rewritten/created/deleted. +/// +/// This is a pure inverse — it does NOT prune the undone turns. Callers that want to +/// discard them should follow with [`delete_from`]`(.., target_turn + 1)`. +pub async fn restore_to( + checkpoint_dir: &Path, + session_id: &str, + target_turn: u32, +) -> Result { + let root = session_root(checkpoint_dir, session_id); + if tokio::fs::metadata(&root).await.is_err() { + return Ok(0); + } - let mut deleted = 0u32; - for line in output.stdout.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if let Some(turn) = parse_turn_from_ref(line) { - if turn >= from_turn { - run_git(repo_path, &["update-ref", "-d", line]).await?; - deleted += 1; + // Collect turns to undo: turn > target_turn, newest-first. + let mut turns = list_turn_numbers(&root).await?; + turns.retain(|&t| t > target_turn); + turns.sort_unstable_by(|a, b| b.cmp(a)); // descending + + let mut changed = 0usize; + for t in turns { + let tdir = turn_dir(&root, t); + let manifest = match read_manifest(&tdir).await? { + Some(m) => m, + None => continue, + }; + for entry in &manifest.entries { + let path = PathBuf::from(&entry.path); + if !entry.existed_before { + // Agent created this file in turn t -> remove it. + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(GitOpsError::Io(e)), + } + changed += 1; + } else { + // File existed before turn t (edited or deleted) -> rewrite prior bytes. + let blob_name = entry.blob.as_ref().ok_or_else(|| GitOpsError::CorruptManifest { + path: tdir.join("manifest.json").to_string_lossy().to_string(), + reason: format!("entry {} has existed_before=true but no blob", entry.path), + })?; + let bytes = tokio::fs::read(tdir.join("blobs").join(blob_name)).await?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&path, &bytes).await?; + changed += 1; } } } - Ok(deleted) + Ok(changed) } -/// List all checkpoints for a thread, ordered by turn count. -/// Excludes pre-restore snapshots from the listing. -pub async fn list_checkpoints( - repo_path: &Path, - thread_id: &str, -) -> Result, GitOpsError> { - let prefix = format!("refs/agent/checkpoints/{}/", thread_id); - let output = run_git( - repo_path, - &[ - "for-each-ref", - "--format=%(refname) %(objectname:short) %(creatordate:iso-strict)", - "--sort=refname", - &prefix, - ], - ) - .await?; - - let mut checkpoints = Vec::new(); - for line in output.stdout.lines() { - let line = line.trim(); - if line.is_empty() { +/// Human-readable diff (prior vs current on-disk) for a single turn. +/// Generated in-process — there is no git repo to diff against. +pub async fn diff_turn( + checkpoint_dir: &Path, + session_id: &str, + turn: u32, +) -> Result { + let root = session_root(checkpoint_dir, session_id); + let tdir = turn_dir(&root, turn); + let manifest = read_manifest(&tdir) + .await? + .ok_or_else(|| GitOpsError::CheckpointNotFound { + session_id: session_id.to_string(), + turn, + })?; + + let mut diff = String::new(); + let mut files_changed = 0u32; + let mut insertions = 0u32; + let mut deletions = 0u32; + + for entry in &manifest.entries { + // Prior bytes: blob contents, or empty for agent-created files. + let prior: Vec = match &entry.blob { + Some(name) => tokio::fs::read(tdir.join("blobs").join(name)).await?, + None => Vec::new(), + }; + // Current bytes: on-disk contents, or empty if the file is now absent. + let current: Vec = match tokio::fs::read(&entry.path).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(GitOpsError::Io(e)), + }; + + if prior == current { continue; } + files_changed += 1; + unified_file_diff(&entry.path, &prior, ¤t, &mut diff, &mut insertions, &mut deletions); + } - // Skip pre-restore snapshots - if line.contains("-pre-restore") { - continue; - } + let stat = format!("{files_changed} files changed, {insertions} insertions(+), {deletions} deletions(-)"); + Ok(DiffOutput { + diff, + files_changed, + insertions, + deletions, + stat, + }) +} - let parts: Vec<&str> = line.splitn(3, ' ').collect(); - if parts.len() < 3 { - continue; // Malformed line, skip - } +/// List available turns for a session, ascending by turn number. +pub async fn list(checkpoint_dir: &Path, session_id: &str) -> Result, GitOpsError> { + let root = session_root(checkpoint_dir, session_id); + if tokio::fs::metadata(&root).await.is_err() { + return Ok(Vec::new()); + } - let ref_name = parts[0].to_string(); - let commit_sha = parts[1].to_string(); - let created_at = parts[2].to_string(); - let turn_count = parse_turn_from_ref(&ref_name).unwrap_or(0); + let mut turns = list_turn_numbers(&root).await?; + turns.sort_unstable(); + + let mut out = Vec::with_capacity(turns.len()); + for t in turns { + if let Some(manifest) = read_manifest(&turn_dir(&root, t)).await? { + out.push(TurnInfo { + turn: t, + file_count: manifest.entries.len(), + paths: manifest.entries.iter().map(|e| e.path.clone()).collect(), + }); + } + } + Ok(out) +} - checkpoints.push(CheckpointInfo { - ref_name, - commit_sha, - turn_count, - created_at, - }); +/// Delete all turn directories with `turn >= from_turn`. Returns the count removed. +pub async fn delete_from( + checkpoint_dir: &Path, + session_id: &str, + from_turn: u32, +) -> Result { + let root = session_root(checkpoint_dir, session_id); + if tokio::fs::metadata(&root).await.is_err() { + return Ok(0); } - // Sort numerically by turn count (--sort=refname is lexicographic) - checkpoints.sort_by_key(|c| c.turn_count); + let turns = list_turn_numbers(&root).await?; + let mut deleted = 0u32; + for t in turns { + if t >= from_turn { + tokio::fs::remove_dir_all(turn_dir(&root, t)).await?; + deleted += 1; + } + } + Ok(deleted) +} - Ok(checkpoints) +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Enumerate the turn numbers present under a session root. +async fn list_turn_numbers(root: &Path) -> Result, GitOpsError> { + let mut turns = Vec::new(); + let mut rd = tokio::fs::read_dir(root).await?; + while let Some(entry) = rd.next_entry().await? { + if let Some(name) = entry.file_name().to_str() { + if let Some(t) = parse_turn_from_dirname(name) { + turns.push(t); + } + } + } + Ok(turns) } -/// Delete ALL checkpoint refs for a thread. -/// Called when a coding session is completed and worktree is removed. -pub async fn delete_all_checkpoints( - repo_path: &Path, - thread_id: &str, -) -> Result { - delete_checkpoint_refs(repo_path, thread_id, 0).await +/// Append a per-file unified-style diff to `out`, trimming common prefix/suffix lines. +fn unified_file_diff( + path: &str, + prior: &[u8], + current: &[u8], + out: &mut String, + insertions: &mut u32, + deletions: &mut u32, +) { + match (std::str::from_utf8(prior), std::str::from_utf8(current)) { + (Ok(p), Ok(c)) => { + let p_lines: Vec<&str> = p.lines().collect(); + let c_lines: Vec<&str> = c.lines().collect(); + + // Common prefix. + let mut pre = 0; + while pre < p_lines.len() && pre < c_lines.len() && p_lines[pre] == c_lines[pre] { + pre += 1; + } + // Common suffix (not overlapping the prefix). + let mut suf = 0; + while suf < p_lines.len() - pre + && suf < c_lines.len() - pre + && p_lines[p_lines.len() - 1 - suf] == c_lines[c_lines.len() - 1 - suf] + { + suf += 1; + } + + out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); + for line in &p_lines[pre..p_lines.len() - suf] { + out.push('-'); + out.push_str(line); + out.push('\n'); + *deletions += 1; + } + for line in &c_lines[pre..c_lines.len() - suf] { + out.push('+'); + out.push_str(line); + out.push('\n'); + *insertions += 1; + } + } + _ => { + out.push_str(&format!("Binary files a/{path} and b/{path} differ\n")); + *insertions += 1; + } + } } #[cfg(test)] mod tests { use super::*; - use crate::test_util::init_repo_with_commit; - use std::fs; + use std::path::PathBuf; use tempfile::tempdir; - #[test] - fn test_checkpoint_ref_format() { - assert_eq!( - checkpoint_ref("thread-abc", 0), - "refs/agent/checkpoints/thread-abc/turn-0" - ); - assert_eq!( - checkpoint_ref("thread-abc", 5), - "refs/agent/checkpoints/thread-abc/turn-5" - ); + /// Create (project dir, checkpoint dir) temp pair. Returned handles must be kept + /// alive for the duration of the test. + fn dirs() -> (tempfile::TempDir, tempfile::TempDir) { + (tempdir().unwrap(), tempdir().unwrap()) } - #[tokio::test] - async fn test_capture_checkpoint_creates_ref() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let ref_name = checkpoint_ref("t1", 0); - let sha = capture_checkpoint(dir.path(), &ref_name).await.unwrap(); + fn write(path: &PathBuf, content: &str) { + if let Some(p) = path.parent() { + std::fs::create_dir_all(p).unwrap(); + } + std::fs::write(path, content).unwrap(); + } - // SHA should be a 40-char hex string - assert_eq!(sha.len(), 40); - assert!(sha.chars().all(|c| c.is_ascii_hexdigit())); + fn read(path: &PathBuf) -> String { + std::fs::read_to_string(path).unwrap() + } - // The ref should exist - assert!(has_checkpoint(dir.path(), &ref_name).await.unwrap()); + #[test] + fn test_sanitize_session_id_roundtrip_and_no_collision() { + assert_eq!(sanitize_session_id("plain-id_1.2"), "plain-id_1.2"); + // '/' and ':' get encoded to a single segment with no path separators. + let s = sanitize_session_id("a/b:c"); + assert!(!s.contains('/')); + assert!(!s.contains(':')); + // Distinct ids never collide. + assert_ne!(sanitize_session_id("a/b"), sanitize_session_id("a-b")); } #[tokio::test] - async fn test_capture_checkpoint_captures_uncommitted_changes() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; + async fn test_backup_edit_then_restore_roundtrip() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("src/main.rs"); + write(&f, "v1"); - // State 1: modify the file - fs::write(dir.path().join("README.md"), "# State One").unwrap(); - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "v2"); - // State 2: modify the file again (don't commit) - fs::write(dir.path().join("README.md"), "# State Two").unwrap(); - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); - - // Diff between the two checkpoints should show the change - let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); - assert!(d.diff.contains("State Two")); - assert!(d.diff.contains("State One")); + let changed = restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert_eq!(changed, 1); + assert_eq!(read(&f), "v1"); } #[tokio::test] - async fn test_has_checkpoint_false_for_missing() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let result = has_checkpoint(dir.path(), "refs/agent/checkpoints/nonexistent/turn-0") - .await - .unwrap(); - assert!(!result); + async fn test_backup_created_file_restore_deletes_it() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("new.rs"); + // File does not exist yet — backup records did-not-exist. + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "created by agent"); + assert!(f.exists()); + + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert!(!f.exists(), "agent-created file should be removed on restore"); } #[tokio::test] - async fn test_diff_checkpoints_shows_changes() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); - - // Write a new file between checkpoints - fs::write(dir.path().join("new.txt"), "new file content").unwrap(); - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); - - let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); - assert!(d.diff.contains("new.txt")); - assert!(d.insertions > 0); + async fn test_backup_deleted_file_restore_recreates_it() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("orig.rs"); + write(&f, "orig"); + + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + std::fs::remove_file(&f).unwrap(); + assert!(!f.exists()); + + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert!(f.exists()); + assert_eq!(read(&f), "orig"); } #[tokio::test] - async fn test_diff_checkpoints_no_changes() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); - - // No edits between captures - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); - - let d = diff_checkpoints(dir.path(), &ref0, &ref1).await.unwrap(); - assert!(d.diff.is_empty()); - assert_eq!(d.insertions, 0); - assert_eq!(d.deletions, 0); + async fn test_double_edit_same_turn_reverts_to_turn_start() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "A"); + + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "B"); + // Second backup in the same turn must be a no-op (first wins). + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "C"); + + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert_eq!(read(&f), "A"); } #[tokio::test] - async fn test_restore_checkpoint() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - // Capture state with "hello" - fs::write(dir.path().join("data.txt"), "hello").unwrap(); - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); - - // Overwrite to "world" and capture - fs::write(dir.path().join("data.txt"), "world").unwrap(); - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); - - // Verify current state - assert_eq!( - fs::read_to_string(dir.path().join("data.txt")).unwrap(), - "world" - ); - - // Restore to turn-0 - restore_checkpoint(dir.path(), &ref0).await.unwrap(); - - // File should be back to "hello" - assert_eq!( - fs::read_to_string(dir.path().join("data.txt")).unwrap(), - "hello" - ); + async fn test_first_backup_wins_one_entry_and_turn_start_blob() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "start"); + + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "mid"); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + + let turns = list(ckpt.path(), "s1").await.unwrap(); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].file_count, 1); + + // The single blob holds the turn-start content. + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert_eq!(read(&f), "start"); } #[tokio::test] - async fn test_restore_creates_pre_restore_snapshot() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); + async fn test_bash_change_not_covered() { + let (proj, ckpt) = dirs(); + let tracked = proj.path().join("tracked.txt"); + let untracked = proj.path().join("untracked.txt"); + write(&tracked, "t0"); + backup_file(ckpt.path(), "s1", 1, &tracked).await.unwrap(); + write(&tracked, "t1"); + + // A change made WITHOUT going through backup_file (simulating bash). + write(&untracked, "bash-made"); + + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + // tracked reverts; the bash-made file is untouched. + assert_eq!(read(&tracked), "t0"); + assert_eq!(read(&untracked), "bash-made"); + } - fs::write(dir.path().join("file.txt"), "some content").unwrap(); - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); + #[tokio::test] + async fn test_restore_across_multiple_turns() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "A"); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "B"); + backup_file(ckpt.path(), "s1", 2, &f).await.unwrap(); + write(&f, "C"); + backup_file(ckpt.path(), "s1", 3, &f).await.unwrap(); + write(&f, "D"); + + // Restoring to turn 2 undoes only turn 3 -> back to "C". + restore_to(ckpt.path(), "s1", 2).await.unwrap(); + assert_eq!(read(&f), "C"); + } - // Restore to turn-0 - restore_checkpoint(dir.path(), &ref0).await.unwrap(); + #[tokio::test] + async fn test_restore_to_target_turn_keeps_its_own_edits() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "A"); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "B"); // change made during turn 1 + + // restore_to(1) keeps turn 1's edit (only undoes turn > 1). + let changed = restore_to(ckpt.path(), "s1", 1).await.unwrap(); + assert_eq!(changed, 0); + assert_eq!(read(&f), "B"); + } - // Pre-restore snapshot should exist - let pre_restore_ref = format!("{}-pre-restore", ref0); - assert!(has_checkpoint(dir.path(), &pre_restore_ref).await.unwrap()); + #[tokio::test] + async fn test_restore_created_then_edited_across_turns() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + // Created in turn 1. + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "v1"); + // Edited in turn 2. + backup_file(ckpt.path(), "s1", 2, &f).await.unwrap(); + write(&f, "v2"); + + // Restoring to turn 0: newest-first => turn 2 rewrites "v1", then turn 1 deletes. + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert!(!f.exists(), "file created then edited should end up absent"); } #[tokio::test] - async fn test_delete_checkpoint_refs_from_turn() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let thread_id = "t1"; - // Create 4 checkpoints, modifying a file between each - for turn in 0..4u32 { - fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); - let r = checkpoint_ref(thread_id, turn); - capture_checkpoint(dir.path(), &r).await.unwrap(); + async fn test_list_returns_turns_ascending() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "x"); + for t in [0u32, 1, 2] { + backup_file(ckpt.path(), "s1", t, &f).await.unwrap(); + write(&f, &format!("x{t}")); } - - // Delete from turn 2 onward - let deleted = delete_checkpoint_refs(dir.path(), thread_id, 2).await.unwrap(); - assert_eq!(deleted, 2); - - // turn-0 and turn-1 should still exist - assert!(has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 0)).await.unwrap()); - assert!(has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 1)).await.unwrap()); - - // turn-2 and turn-3 should be gone - assert!(!has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 2)).await.unwrap()); - assert!(!has_checkpoint(dir.path(), &checkpoint_ref(thread_id, 3)).await.unwrap()); + let turns = list(ckpt.path(), "s1").await.unwrap(); + let nums: Vec = turns.iter().map(|t| t.turn).collect(); + assert_eq!(nums, vec![0, 1, 2]); + assert!(turns.iter().all(|t| t.file_count == 1)); + assert_eq!(turns[0].paths, vec![f.to_string_lossy().to_string()]); } #[tokio::test] - async fn test_list_checkpoints_ordered() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let thread_id = "t1"; - for turn in 0..3u32 { - fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); - let r = checkpoint_ref(thread_id, turn); - capture_checkpoint(dir.path(), &r).await.unwrap(); + async fn test_delete_from_prunes_future_turns() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "x"); + for t in 0..4u32 { + backup_file(ckpt.path(), "s1", t, &f).await.unwrap(); + write(&f, &format!("x{t}")); } + let deleted = delete_from(ckpt.path(), "s1", 2).await.unwrap(); + assert_eq!(deleted, 2); + let nums: Vec = list(ckpt.path(), "s1").await.unwrap().iter().map(|t| t.turn).collect(); + assert_eq!(nums, vec![0, 1]); + } - let checkpoints = list_checkpoints(dir.path(), thread_id).await.unwrap(); - assert_eq!(checkpoints.len(), 3); - assert_eq!(checkpoints[0].turn_count, 0); - assert_eq!(checkpoints[1].turn_count, 1); - assert_eq!(checkpoints[2].turn_count, 2); - - // Each should have a non-empty SHA and ISO date - for cp in &checkpoints { - assert!(!cp.commit_sha.is_empty()); - assert!(!cp.created_at.is_empty()); - } + #[tokio::test] + async fn test_diff_turn_shows_prior_vs_current() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + write(&f, "old line\n"); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "new line\n"); + + let d = diff_turn(ckpt.path(), "s1", 1).await.unwrap(); + assert!(d.diff.contains("old line")); + assert!(d.diff.contains("new line")); + assert!(d.insertions > 0); + assert!(d.deletions > 0); + assert_eq!(d.files_changed, 1); } #[tokio::test] - async fn test_delete_all_checkpoints() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let thread_id = "t1"; - for turn in 0..2u32 { - fs::write(dir.path().join("counter.txt"), format!("turn-{}", turn)).unwrap(); - let r = checkpoint_ref(thread_id, turn); - capture_checkpoint(dir.path(), &r).await.unwrap(); - } + async fn test_diff_turn_created_file() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("a.txt"); + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + write(&f, "line1\nline2\n"); + + let d = diff_turn(ckpt.path(), "s1", 1).await.unwrap(); + assert!(d.diff.contains("line1")); + assert!(d.insertions >= 2); + assert_eq!(d.deletions, 0); + } - let deleted = delete_all_checkpoints(dir.path(), thread_id).await.unwrap(); - assert_eq!(deleted, 2); + #[tokio::test] + async fn test_diff_turn_unknown_turn_errors() { + let (_proj, ckpt) = dirs(); + let err = diff_turn(ckpt.path(), "s1", 5).await; + assert!(matches!(err, Err(GitOpsError::CheckpointNotFound { .. }))); + } - let remaining = list_checkpoints(dir.path(), thread_id).await.unwrap(); - assert!(remaining.is_empty()); + #[tokio::test] + async fn test_restore_nonexistent_session_is_noop() { + let (_proj, ckpt) = dirs(); + let changed = restore_to(ckpt.path(), "never", 0).await.unwrap(); + assert_eq!(changed, 0); + assert!(list(ckpt.path(), "never").await.unwrap().is_empty()); } #[tokio::test] - async fn test_restore_removes_files_added_after_checkpoint() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - // Capture turn-0 (only README.md exists) - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); - - // Create a new file after the checkpoint - fs::write(dir.path().join("added_later.txt"), "I was added later").unwrap(); - let ref1 = checkpoint_ref("t1", 1); - capture_checkpoint(dir.path(), &ref1).await.unwrap(); - assert!(dir.path().join("added_later.txt").exists()); - - // Restore to turn-0 — the added file should be REMOVED - restore_checkpoint(dir.path(), &ref0).await.unwrap(); - assert!( - !dir.path().join("added_later.txt").exists(), - "File added after checkpoint should be removed on restore" - ); - // Original file should still be there - assert!(dir.path().join("README.md").exists()); + async fn test_binary_bytes_roundtrip() { + let (proj, ckpt) = dirs(); + let f = proj.path().join("blob.bin"); + let original: Vec = vec![0u8, 159, 146, 150, 255, 1, 2, 3]; + std::fs::write(&f, &original).unwrap(); + + backup_file(ckpt.path(), "s1", 1, &f).await.unwrap(); + std::fs::write(&f, [9u8, 9, 9]).unwrap(); + + restore_to(ckpt.path(), "s1", 0).await.unwrap(); + assert_eq!(std::fs::read(&f).unwrap(), original); } #[tokio::test] - async fn test_capture_checkpoint_includes_untracked_files() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - // Create an untracked file (NOT committed, NOT staged) - fs::write(dir.path().join("untracked.txt"), "i am untracked").unwrap(); - - let ref0 = checkpoint_ref("t1", 0); - capture_checkpoint(dir.path(), &ref0).await.unwrap(); - - // Delete the untracked file - fs::remove_file(dir.path().join("untracked.txt")).unwrap(); - assert!(!dir.path().join("untracked.txt").exists()); - - // Restore to turn-0 should bring it back - restore_checkpoint(dir.path(), &ref0).await.unwrap(); - assert!(dir.path().join("untracked.txt").exists()); - assert_eq!( - fs::read_to_string(dir.path().join("untracked.txt")).unwrap(), - "i am untracked" - ); + async fn test_backup_rejects_relative_path() { + let (_proj, ckpt) = dirs(); + let err = backup_file(ckpt.path(), "s1", 1, Path::new("relative/path.txt")).await; + assert!(matches!(err, Err(GitOpsError::NonAbsolutePath(_)))); } } diff --git a/crates/git-ops/src/error.rs b/crates/git-ops/src/error.rs index 73a1d2ee..f2eb7da0 100644 --- a/crates/git-ops/src/error.rs +++ b/crates/git-ops/src/error.rs @@ -22,6 +22,15 @@ pub enum GitOpsError { #[error("invalid remote URL: {0}")] InvalidRemoteUrl(String), + #[error("checkpoint path must be absolute: {0}")] + NonAbsolutePath(String), + + #[error("checkpoint not found: session {session_id} turn {turn}")] + CheckpointNotFound { session_id: String, turn: u32 }, + + #[error("corrupt checkpoint manifest at {path}: {reason}")] + CorruptManifest { path: String, reason: String }, + #[error("IO error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/git-ops/src/lib.rs b/crates/git-ops/src/lib.rs index 7b5e8b4f..e8cce2f5 100644 --- a/crates/git-ops/src/lib.rs +++ b/crates/git-ops/src/lib.rs @@ -2,7 +2,6 @@ pub mod error; pub mod types; pub mod exec; pub mod core; -pub mod worktree; pub mod checkpoint; pub mod pr; pub mod ide; @@ -11,7 +10,9 @@ pub mod no_window; pub use error::GitOpsError; pub use types::*; pub use core::*; -pub use worktree::{worktree_add, worktree_remove, worktree_prune, worktree_list, WorktreeEntry}; +pub use checkpoint::{ + backup_file, delete_from, diff_turn, list, restore_to, BackupEntry, TurnInfo, TurnManifest, +}; #[cfg(test)] pub(crate) mod test_util; diff --git a/crates/git-ops/src/worktree.rs b/crates/git-ops/src/worktree.rs deleted file mode 100644 index 1986a5cf..00000000 --- a/crates/git-ops/src/worktree.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::path::Path; - -use crate::error::GitOpsError; -use crate::exec::run_git; - -/// Add a git worktree. -pub async fn worktree_add( - repo_path: &Path, - worktree_path: &Path, - branch: &str, - create_branch: bool, - start_point: Option<&str>, -) -> Result<(), GitOpsError> { - let worktree_str = worktree_path.display().to_string(); - let mut args = vec!["worktree", "add"]; - if create_branch { - args.extend_from_slice(&["-b", branch, &worktree_str]); - } else { - args.extend_from_slice(&[&worktree_str, branch]); - } - // Start point: create branch FROM this ref (e.g., "main", "develop") - if let Some(sp) = start_point { - args.push(sp); - } - run_git(repo_path, &args).await?; - Ok(()) -} - -/// Remove a git worktree. -pub async fn worktree_remove(repo_path: &Path, worktree_path: &Path) -> Result<(), GitOpsError> { - let worktree_str = worktree_path.display().to_string(); - run_git(repo_path, &["worktree", "remove", &worktree_str, "--force"]).await?; - Ok(()) -} - -/// A parsed entry from `git worktree list --porcelain`. -#[derive(Debug, Clone)] -pub struct WorktreeEntry { - pub path: std::path::PathBuf, - pub branch: Option, - pub bare: bool, -} - -/// List all git worktrees by parsing `git worktree list --porcelain`. -pub async fn worktree_list(repo_path: &Path) -> Result, GitOpsError> { - let output = run_git(repo_path, &["worktree", "list", "--porcelain"]).await?; - Ok(parse_worktree_porcelain(&output.stdout)) -} - -/// Parse porcelain output from `git worktree list --porcelain`. -fn parse_worktree_porcelain(output: &str) -> Vec { - let mut entries = Vec::new(); - let mut path: Option = None; - let mut branch: Option = None; - let mut bare = false; - - for line in output.lines() { - if let Some(p) = line.strip_prefix("worktree ") { - // Flush previous entry - if let Some(prev_path) = path.take() { - entries.push(WorktreeEntry { path: prev_path, branch: branch.take(), bare }); - bare = false; - } - path = Some(std::path::PathBuf::from(p)); - } else if let Some(b) = line.strip_prefix("branch refs/heads/") { - branch = Some(b.to_string()); - } else if line == "bare" { - bare = true; - } - // Skip HEAD, detached, prunable lines — we don't need them - } - // Flush last entry - if let Some(prev_path) = path { - entries.push(WorktreeEntry { path: prev_path, branch: branch.take(), bare }); - } - entries -} - -/// Prune stale worktree entries. -pub async fn worktree_prune(repo_path: &Path) -> Result<(), GitOpsError> { - run_git(repo_path, &["worktree", "prune"]).await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - use tokio::process::Command; - use crate::test_util::init_repo_with_commit; - - #[tokio::test] - async fn test_worktree_add_new_branch() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let wt_path = dir.path().join("worktree-feature"); - worktree_add(dir.path(), &wt_path, "feature-wt", true, None) - .await - .unwrap(); - - // Worktree dir should exist - assert!(wt_path.exists()); - // Should have a README.md from the parent - assert!(wt_path.join("README.md").exists()); - } - - #[tokio::test] - async fn test_worktree_add_existing_branch() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - // Create a branch first - Command::new("git") - .args(["branch", "existing-branch"]) - .current_dir(dir.path()) - .output() - .await - .unwrap(); - - let wt_path = dir.path().join("worktree-existing"); - worktree_add(dir.path(), &wt_path, "existing-branch", false, None) - .await - .unwrap(); - - assert!(wt_path.exists()); - } - - #[tokio::test] - async fn test_worktree_remove() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let wt_path = dir.path().join("worktree-remove"); - worktree_add(dir.path(), &wt_path, "remove-branch", true, None) - .await - .unwrap(); - assert!(wt_path.exists()); - - worktree_remove(dir.path(), &wt_path).await.unwrap(); - assert!(!wt_path.exists()); - } - - #[test] - fn test_parse_worktree_porcelain() { - let output = "\ -worktree /Users/me/project -HEAD abc123 -branch refs/heads/main - -worktree /Users/me/project/.agent-worktrees/session-1 -HEAD def456 -branch refs/heads/agent/fix-bug-abc12345 - -worktree /Users/me/project/.agent-worktrees/session-2 -HEAD 789012 -detached - -"; - let entries = parse_worktree_porcelain(output); - assert_eq!(entries.len(), 3); - - assert_eq!(entries[0].path, std::path::PathBuf::from("/Users/me/project")); - assert_eq!(entries[0].branch.as_deref(), Some("main")); - assert!(!entries[0].bare); - - assert_eq!(entries[1].branch.as_deref(), Some("agent/fix-bug-abc12345")); - - // Detached worktree has no branch - assert!(entries[2].branch.is_none()); - } - - #[tokio::test] - async fn test_worktree_list() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let wt_path = dir.path().join("wt-list-test"); - worktree_add(dir.path(), &wt_path, "list-branch", true, None) - .await - .unwrap(); - - let entries = worktree_list(dir.path()).await.unwrap(); - // At least 2: the main repo + the worktree we created - assert!(entries.len() >= 2); - // macOS: /tmp is a symlink to /private/tmp, git canonicalizes paths - let wt_canon = wt_path.canonicalize().unwrap(); - assert!(entries.iter().any(|e| e.path == wt_canon)); - } - - #[tokio::test] - async fn test_worktree_prune_after_manual_delete() { - let dir = tempdir().unwrap(); - init_repo_with_commit(dir.path()).await; - - let wt_path = dir.path().join("worktree-prune"); - worktree_add(dir.path(), &wt_path, "prune-branch", true, None) - .await - .unwrap(); - - // Manually delete the worktree directory - fs::remove_dir_all(&wt_path).unwrap(); - - // Prune should succeed (cleans up stale entries) - worktree_prune(dir.path()).await.unwrap(); - } -} From ecdb647d3438b574c04d22cec3d16a3cbb8bb683 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Tue, 2 Jun 2026 12:25:21 +0530 Subject: [PATCH 07/26] Add desktop app backend: trimmed Tauri shell + SQLite-only agent bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/desktop/src-tauri ported from chat-desktop and rewired to crates/agent: greenfield session-keyed SQLite (v1), snapshot-checkpoint bridge, no chat/mqtt/ worktrees. cargo check clean, 18 bridge tests green. Frontend (apps/desktop/src) not included yet — adapted in the next change. --- .gitignore | 3 + Cargo.lock | 5965 ++++++++++++++--- Cargo.toml | 2 +- apps/desktop/src-tauri/Cargo.toml | 49 + apps/desktop/src-tauri/Entitlements.plist | 14 + apps/desktop/src-tauri/Info.plist | 8 + apps/desktop/src-tauri/build.rs | 28 + .../src-tauri/capabilities/default.json | 14 + apps/desktop/src-tauri/icons/128x128.png | Bin 0 -> 4139 bytes apps/desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 8860 bytes apps/desktop/src-tauri/icons/32x32.png | Bin 0 -> 868 bytes apps/desktop/src-tauri/icons/64x64.png | Bin 0 -> 1898 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 3437 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 4646 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 4900 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 9934 bytes .../src-tauri/icons/Square30x30Logo.png | Bin 0 -> 824 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 11094 bytes .../src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1272 bytes .../src-tauri/icons/Square71x71Logo.png | Bin 0 -> 2171 bytes .../src-tauri/icons/Square89x89Logo.png | Bin 0 -> 2849 bytes apps/desktop/src-tauri/icons/StoreLogo.png | Bin 0 -> 1479 bytes apps/desktop/src-tauri/icons/group.svg | 8 + apps/desktop/src-tauri/icons/icon.icns | Bin 0 -> 110800 bytes apps/desktop/src-tauri/icons/icon.ico | Bin 0 -> 15229 bytes apps/desktop/src-tauri/icons/icon.png | Bin 0 -> 20711 bytes apps/desktop/src-tauri/icons/new_chat.svg | 15 + apps/desktop/src-tauri/icons/superagilogo.svg | 1 + .../src-tauri/src/agent_bridge/commands.rs | 1287 ++++ apps/desktop/src-tauri/src/agent_bridge/db.rs | 928 +++ .../src-tauri/src/agent_bridge/events.rs | 554 ++ .../desktop/src-tauri/src/agent_bridge/mod.rs | 7 + .../src-tauri/src/agent_bridge/permissions.rs | 350 + .../src-tauri/src/agent_bridge/skills.rs | 221 + .../src-tauri/src/agent_bridge/subagents.rs | 144 + .../src-tauri/src/agent_bridge/traits.rs | 107 + apps/desktop/src-tauri/src/lib.rs | 537 ++ apps/desktop/src-tauri/src/main.rs | 6 + apps/desktop/src-tauri/tauri.conf.json | 57 + 39 files changed, 9428 insertions(+), 877 deletions(-) create mode 100644 apps/desktop/src-tauri/Cargo.toml create mode 100644 apps/desktop/src-tauri/Entitlements.plist create mode 100644 apps/desktop/src-tauri/Info.plist create mode 100644 apps/desktop/src-tauri/build.rs create mode 100644 apps/desktop/src-tauri/capabilities/default.json create mode 100644 apps/desktop/src-tauri/icons/128x128.png create mode 100644 apps/desktop/src-tauri/icons/128x128@2x.png create mode 100644 apps/desktop/src-tauri/icons/32x32.png create mode 100644 apps/desktop/src-tauri/icons/64x64.png create mode 100644 apps/desktop/src-tauri/icons/Square107x107Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square142x142Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square150x150Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square284x284Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square30x30Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square310x310Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square44x44Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square71x71Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square89x89Logo.png create mode 100644 apps/desktop/src-tauri/icons/StoreLogo.png create mode 100644 apps/desktop/src-tauri/icons/group.svg create mode 100644 apps/desktop/src-tauri/icons/icon.icns create mode 100644 apps/desktop/src-tauri/icons/icon.ico create mode 100644 apps/desktop/src-tauri/icons/icon.png create mode 100644 apps/desktop/src-tauri/icons/new_chat.svg create mode 100644 apps/desktop/src-tauri/icons/superagilogo.svg create mode 100644 apps/desktop/src-tauri/src/agent_bridge/commands.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/db.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/events.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/mod.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/permissions.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/skills.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/subagents.rs create mode 100644 apps/desktop/src-tauri/src/agent_bridge/traits.rs create mode 100644 apps/desktop/src-tauri/src/lib.rs create mode 100644 apps/desktop/src-tauri/src/main.rs create mode 100644 apps/desktop/src-tauri/tauri.conf.json diff --git a/.gitignore b/.gitignore index 1875c4e1..d14c4977 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ Thumbs.db # Env .env .envrc + +# Tauri generated +apps/*/src-tauri/gen/ diff --git a/Cargo.lock b/Cargo.lock index 014a4c1b..53be55aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "agent" version = "0.1.0" @@ -18,18 +24,30 @@ dependencies = [ "log", "percent-encoding", "regex", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_yml", "strsim", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "uuid", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -39,6 +57,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -105,1417 +138,4538 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "async-trait" -version = "0.1.89" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ - "proc-macro2", - "quote", - "syn", + "serde", + "serde_json", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "autocfg" -version = "1.5.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "base64" -version = "0.22.1" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "bitflags" -version = "2.11.1" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "bstr" -version = "1.12.1" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "memchr", - "serde", + "event-listener", + "event-listener-strategy", + "pin-project-lite", ] [[package]] -name = "bumpalo" -version = "3.20.3" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] [[package]] -name = "bytes" -version = "1.11.1" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "cc" -version = "1.2.63" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "find-msvc-tools", - "shlex", + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "chrono" -version = "0.4.44" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "core-foundation" -version = "0.9.4" +name = "atk" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" dependencies = [ - "core-foundation-sys", + "atk-sys", + "glib", "libc", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "atk-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" dependencies = [ - "core-foundation-sys", + "glib-sys", + "gobject-sys", "libc", + "system-deps", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "bit-vec", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "crossbeam-utils", + "serde_core", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] -name = "displaydoc" -version = "0.2.6" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "proc-macro2", - "quote", - "syn", + "objc2", ] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "cfg-if", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "env_filter" -version = "1.0.1" +name = "brotli" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ - "log", - "regex", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "env_logger" -version = "0.11.10" +name = "brotli-decompressor" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] [[package]] -name = "errno" -version = "0.3.14" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "libc", - "windows-sys 0.61.2", + "memchr", + "serde", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "bytemuck" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] -name = "fnv" -version = "1.0.7" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "foldhash" -version = "0.1.5" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] -name = "foreign-types" -version = "0.3.2" +name = "cairo-rs" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "foreign-types-shared", + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "cairo-sys-rs" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] [[package]] -name = "form_urlencoded" +name = "camino" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "percent-encoding", + "serde_core", ] [[package]] -name = "futures" -version = "0.3.32" +name = "cargo-platform" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "serde", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "cargo_metadata" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ - "futures-core", - "futures-sink", + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "cargo_toml" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] [[package]] -name = "futures-executor" -version = "0.3.32" +name = "cc" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "find-msvc-tools", + "shlex", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "cesu8" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] -name = "futures-macro" -version = "0.3.32" +name = "cfb" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "byteorder", + "fnv", + "uuid", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "futures-util" -version = "0.3.32" +name = "cfg_aliases" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", "memchr", - "pin-project-lite", - "slab", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "cfg-if", - "libc", - "wasi", + "crossbeam-utils", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "time", + "version_check", ] [[package]] -name = "git-ops" -version = "0.1.0" +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "log", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror", - "tokio", + "core-foundation-sys", + "libc", ] [[package]] -name = "globset" -version = "0.4.18" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", + "core-foundation-sys", + "libc", ] [[package]] -name = "h2" -version = "0.4.14" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "foldhash", + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] [[package]] -name = "heck" -version = "0.5.0" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] [[package]] -name = "http" -version = "1.4.1" +name = "crossbeam-channel" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "bytes", - "itoa", + "crossbeam-utils", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "bytes", - "http", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "crossbeam-utils", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "hyper" -version = "1.10.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", "itoa", - "pin-project-lite", + "phf", "smallvec", - "tokio", - "want", ] [[package]] -name = "hyper-rustls" -version = "0.27.9" +name = "cssparser-macros" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", + "quote", + "syn 2.0.117", ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "ctor" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "ctor-proc-macro", + "dtor", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "ctor-proc-macro" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", + "darling_core", + "darling_macro", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "cc", + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "dbus" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "deadpool" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] [[package]] -name = "icu_properties" -version = "2.2.0" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "derive_more-impl", ] [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] [[package]] -name = "icu_provider" -version = "2.2.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "block-buffer", + "crypto-common", ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "dirs" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] [[package]] -name = "idna" -version = "1.1.0" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "dirs-sys 0.5.0", ] [[package]] -name = "idna_adapter" -version = "1.2.2" +name = "dirs-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ - "icu_normalizer", - "icu_properties", + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] -name = "ignore" -version = "0.4.25" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] -name = "include_dir" -version = "0.7.4" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "include_dir_macros", + "bitflags 2.11.1", + "block2", + "libc", + "objc2", ] [[package]] -name = "include_dir_macros" -version = "0.7.4" +name = "displaydoc" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", + "syn 2.0.117", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "dlopen2" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "dlopen2_derive", + "libc", + "once_cell", + "winapi", ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "dlopen2_derive" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] [[package]] -name = "itoa" -version = "1.0.18" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "jiff" -version = "0.2.28" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", + "serde", ] [[package]] -name = "jiff-static" -version = "0.2.28" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ - "proc-macro2", - "quote", - "syn", + "dtoa", ] [[package]] -name = "js-sys" -version = "0.3.99" +name = "dtor" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "dtor-proc-macro", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "dtor-proc-macro" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" [[package]] -name = "libc" -version = "0.2.186" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "libyml" -version = "0.0.5" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ - "anyhow", - "version_check", + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg 0.55.0", ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "embed_plist" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] -name = "litemap" -version = "0.8.2" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "scopeguard", + "enumflags2_derive", + "serde", ] [[package]] -name = "log" -version = "0.4.30" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "memchr" -version = "2.8.1" +name = "env_filter" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] [[package]] -name = "mime" -version = "0.3.17" +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "git-ops" +version = "0.1.0" +dependencies = [ + "log", + "reqwest 0.12.28", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "notify-rust" +version = "4.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serde", + "serde_derive", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] -name = "mio" -version = "1.2.1" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "serde_core", + "serde_derive", ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "serde-untagged" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "erased-serde", + "serde", + "serde_core", + "typeid", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "autocfg", + "serde_derive", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "serde_derive_internals" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "openssl" -version = "0.10.80" +name = "serde_json" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] [[package]] -name = "openssl-sys" -version = "0.9.116" +name = "serde_spanned" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "serde_core", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "lock_api", - "parking_lot_core", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "serde_with" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "serde_with_macros" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "serde_yml" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "serial2" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "serialize-to-javascript" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] [[package]] -name = "portable-atomic-util" -version = "0.2.7" +name = "serialize-to-javascript-impl" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ - "portable-atomic", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "servo_arc" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "zerovec", + "stable_deref_trait", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "proc-macro2", - "syn", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "shared_child" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" dependencies = [ - "unicode-ident", + "libc", + "sigchld", + "windows-sys 0.60.2", ] [[package]] -name = "quote" -version = "1.0.45" +name = "shared_library" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" dependencies = [ - "proc-macro2", + "lazy_static", + "libc", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "shell-words" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "shlex" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" dependencies = [ - "bitflags", + "libc", + "os_pipe", + "signal-hook", ] [[package]] -name = "regex" -version = "1.12.3" +name = "signal-hook" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "libc", + "signal-hook-registry", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "errno", + "libc", ] [[package]] -name = "regex-syntax" -version = "0.8.10" +name = "simd-adler32" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] -name = "reqwest" -version = "0.12.28" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", "web-sys", + "windows-sys 0.61.2", ] [[package]] -name = "ring" -version = "0.17.14" +name = "soup3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", + "futures-channel", + "gio", + "glib", "libc", - "untrusted", - "windows-sys 0.52.0", + "soup3-sys", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "soup3-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" dependencies = [ - "bitflags", - "errno", + "gio-sys", + "glib-sys", + "gobject-sys", "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "system-deps", ] [[package]] -name = "rustls" -version = "0.23.40" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", ] [[package]] -name = "rustls-pki-types" -version = "1.14.1" +name = "string_cache_codegen" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "zeroize", + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", ] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supercoder-agent-desktop" +version = "0.1.0" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "agent", + "async-trait", + "base64 0.22.1", + "chrono", + "dirs 5.0.1", + "dotenvy", + "env_logger", + "git-ops", + "log", + "parking_lot", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-pty", + "tauri-plugin-shell", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "url", + "uuid", + "wiremock", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "swift-rs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ - "winapi-util", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "schannel" -version = "0.1.29" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "windows-sys 0.61.2", + "futures-core", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "security-framework" -version = "3.7.0" +name = "system-configuration" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", ] [[package]] -name = "security-framework-sys" -version = "2.17.0" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" +name = "system-deps" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "serde_core", - "serde_derive", + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "tao" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "serde_derive", + "bitflags 2.11.1", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "tao-macros" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "serde_json" -version = "1.0.150" +name = "target-lexicon" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "tauri" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "tauri-build" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ - "indexmap", - "itoa", - "libyml", - "memchr", - "ryu", + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", "serde", - "version_check", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "tauri-codegen" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "tauri-macros" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ - "errno", - "libc", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", ] [[package]] -name = "slab" -version = "0.4.12" +name = "tauri-plugin" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "tauri-plugin-dialog" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] [[package]] -name = "socket2" -version = "0.6.4" +name = "tauri-plugin-fs" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ - "libc", - "windows-sys 0.61.2", + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "tauri-plugin-notification" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "tauri-plugin-pty" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "5da8233d3c7b3581ff7da274d32b9145396da9d68bd16cedbbcb9a6908438be6" +dependencies = [ + "portable-pty", + "serde", + "serde_json", + "tauri", + "tauri-plugin", +] [[package]] -name = "subtle" -version = "2.6.1" +name = "tauri-plugin-shell" +version = "2.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] [[package]] -name = "syn" -version = "2.0.117" +name = "tauri-runtime" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", ] [[package]] -name = "sync_wrapper" -version = "1.0.2" +name = "tauri-runtime-wry" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ - "futures-core", + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "tauri-utils" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", "proc-macro2", "quote", - "syn", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", ] [[package]] -name = "system-configuration" -version = "0.7.0" +name = "tauri-winres" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", ] [[package]] -name = "system-configuration-sys" -version = "0.6.0" +name = "tauri-winrt-notification" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ - "core-foundation-sys", - "libc", + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", ] [[package]] @@ -1531,13 +4685,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1548,7 +4732,38 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -1561,6 +4776,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1586,7 +4816,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1622,6 +4852,126 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -1643,7 +4993,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -1656,41 +5006,145 @@ dependencies = [ ] [[package]] -name = "tower-layer" -version = "0.3.3" +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] [[package]] -name = "tower-service" -version = "0.3.3" +name = "unic-char-range" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" [[package]] -name = "tracing" -version = "0.1.44" +name = "unic-common" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" dependencies = [ - "pin-project-lite", - "tracing-core", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "unic-ucd-version" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" dependencies = [ - "once_cell", + "unic-common", ] [[package]] -name = "try-lock" -version = "0.2.5" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -1698,6 +5152,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1720,8 +5180,27 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1742,6 +5221,7 @@ checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1751,12 +5231,38 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1842,7 +5348,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1872,7 +5378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -1890,15 +5396,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -1912,6 +5431,114 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1921,6 +5548,62 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1929,9 +5612,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -1940,90 +5634,237 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link 0.2.1", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows-targets" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] [[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-targets" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] -name = "windows-result" -version = "0.4.1" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows-link", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "windows-strings" -version = "0.5.1" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-threading" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-targets", + "windows-link 0.1.3", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "windows-version" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows_aarch64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" @@ -2031,48 +5872,234 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2095,7 +6122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -2106,10 +6133,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap", + "heck 0.5.0", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2125,7 +6152,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2137,8 +6164,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2157,7 +6184,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2173,6 +6200,71 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2192,10 +6284,91 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2213,7 +6386,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2253,7 +6426,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2261,3 +6434,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index e7ddb0c5..5f1d6286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/agent", "crates/git-ops"] +members = ["crates/agent", "crates/git-ops", "apps/desktop/src-tauri"] [profile.dev] debug = 0 diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000..a181bc37 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "supercoder-agent-desktop" +version = "0.1.0" +description = "SuperCoder" +authors = [] +edition = "2021" +rust-version = "1.70" + +[lib] +name = "supercoder_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.0", features = [] } +dotenvy = "0.15" + +[dependencies] +tauri = { version = "2.10", features = ["macos-private-api", "devtools"] } +tauri-plugin-shell = "2.0" +tauri-plugin-pty = "0.2" +tauri-plugin-notification = "2" +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } +base64 = "0.22" +tokio = { version = "1", features = ["fs", "rt", "time", "macros", "test-util"] } +uuid = { version = "1", features = ["v4"] } +rusqlite = { version = "0.31", features = ["bundled"] } +parking_lot = "0.12" +log = "0.4" +dirs = "5" +url = "2" +env_logger = "0.11" +git-ops = { path = "../../../crates/git-ops" } +agent = { path = "../../../crates/agent" } +dotenvy = "0.15" +thiserror = "2" +chrono = "0.4" +async-trait = "0.1" +tokio-util = "0.7" +tempfile = "3" + +[dev-dependencies] +wiremock = "0.6" + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/apps/desktop/src-tauri/Entitlements.plist b/apps/desktop/src-tauri/Entitlements.plist new file mode 100644 index 00000000..4ed79d96 --- /dev/null +++ b/apps/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist new file mode 100644 index 00000000..42b53b7f --- /dev/null +++ b/apps/desktop/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSAccessibilityUsageDescription + SuperCoder needs accessibility access to detect active editor and terminal windows. + + diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 00000000..f30f9af9 --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,28 @@ +fn main() { + // Load environment-specific .env file (shared with Vite frontend). + // APP_ENV is set by npm scripts or defaults to "development". + let mode = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); + let env_file = format!("../.env.{}", mode); + + // Load mode-specific file first, then base .env as fallback + let _ = dotenvy::from_filename(&env_file); + let _ = dotenvy::from_filename("../.env"); + + // Forward VITE_* config vars to the Rust compiler so they're + // available via env!() macro at compile time + let config_keys = ["VITE_APP_ENV", "LLM_BASE_URL"]; + + for key in &config_keys { + if let Ok(val) = std::env::var(key) { + println!("cargo:rustc-env={}={}", key, val); + } + } + + // Re-run build.rs if env files change + println!("cargo:rerun-if-changed=../.env"); + println!("cargo:rerun-if-changed=../.env.{}", mode); + println!("cargo:rerun-if-changed=../.env.local"); + println!("cargo:rerun-if-env-changed=APP_ENV"); + + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000..8a3f9b0f --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "identifier": "default", + "description": "Default capabilities for SuperCoder", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open", + "shell:allow-stdin-write", + "shell:allow-kill", + "pty:default", + "notification:default", + "dialog:default" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..e197936a45cc02344c11bd8375999c86ace93cdf GIT binary patch literal 4139 zcmcIo^-~l8(>@NwBLzgIpBV3MNl0~6(gh}6{DCT2$$Um;%{pBtclsmP6=)ihvV zBc>0Qj+dH4w>k%jdMF1?$?l5~+>hubaug+?(+3d$k6;|WcS!FKJ#n=0A~}YjSQpKh z()8Oqkz|bwhq=vJ&=)%Bxlc1x?2;8lgVLe|#fflt2@VVpPcY3(#_khpFhe<%$)qgEvE2`J_zNx)pv)Dbcz68C)A;U$qLR4M zo zHa*(c zy4uBN8>7daO3V38^w)!c`J|+8Q3+h17dQz_8gF>@)Ixl8ZW@Wh8Vso7QlqhNvtPRa z6*O;~++PkoHW0K!&`Ns#tPv#{`f55ReRkJ*UcC;t*Kd-}L!+v^N`OHT(hcmN=k&9H zT(e6qMg$riXX?y@uFtl(_;#u~f%i*C65mgaAC{Gs1?|=9qydH3-CZiz$}{ES|I=uwBtZp!^iL^v*3{aZ{7(dEfBWWFm*9`Sl- z?TA!-xXw90grB5z4QVqJg4@<6~|Ssu?PDy zVL4cBCvwdOyo^eRZPAEh9Yh`zj7?4kX1E^y>DAWN?TaM5`=dKN{H&AIbXk|EqMg1t zAUE;A)m>bM9yak_HWRcEWLp4-3}P1Ea4&Sr5Ii%+7LdD!$^>@9^ZqIWq)k(i=Rmh$ z+B;Bh#gLPpJO|j@$ImC&l$2Nq2nc-D536FPwuSx{VTTV}BADd^zHN@b%Xt4@a7m#9 zb_J{}a#$Y7-YC}IUbyt2v&Tw*|9m{4{NRAX`FPqzyxK0*Vq}JMOMaB++C9x+1L)`H zXN3$95_afHE_nRB+@jt_I5UWhI(a#BekyFGE9~=&6(uJ50P-jClsS!4NTUtr?7O`D z{6|(*9$r`T&+Nor&YRE{G{;V@iXy!5^1j^B%KpvM>4hP6PjTHxfY%DUR_2nWYF6mM zl)qD2tgE{-AFY}k2^b_j*ClhJ|LX7rTwh;{XUIW!X#)D&aJ0lRAsnOynvf{Lw{qSs*QwFc68vG#>-3+G}JlA zN|ad{rMy-A{5~gr*tMGJ;0LGQ&6o6g*zmLitoY=eluvQchU01dHCkIv6;Zn`1O@|L zzNh90cj;tt`zF-|#MI$i$zGivItf~TQGd4hPrN1AdN0XeM1~WHu0GImbrnn`^Jm^D zo<1}htd3WER6|V@tLQMjSIK^mKHh`+{vHv~YBJjLA0+_zF$mF?*`QLV@vM0G%OV%F}K&+j&NF)?bbG#02W zuSN}xEfN*RC>k)r@=a<#IjU2?;BWtC0uFV^Pn+=l%h)@>D>lIZ z@ogxBgX^4!tNSD6nexWw!Wa#ctOX(5onv?2^GuviKuok7~}BcT5%^^Y==xp!$&x&l)IMERRn@m% z*7XW1clL`O~a}_@iZ`W=P2SO)OI*XM~&zf>pvjT|$A+rI@@C8qHZAdNj;~S`uip%)Uw` zPP@bGpx+bGo8&a{V59i)CX&37Bl0ri90_Y;2xu5RS>-(|<2R?J}6G zYG!8kHf}Dy8-wvK5H&eP!miFaczQXw07XPaKSpu{+|A3r^nx7zM{+&Nv}L{2;$q}vDVk>L zf>^hw{byb)=DZ5LD1k;Q7M4`w3eDlC3Evt}M=bl3C-$fZ!cXCUs-lA)5d4f}RtPde z!0M)S?o20g@&9vka~nSG3O)1RIT~eL8TG%QU>I~-{AP!0GP|ZX>)^Sj-x%jzf)^8= zC>UHng?C!gDVWf83VRZ1fjQa8Bd^(y@T=@o+Ffm|;TM=0-F@}2VwLddbr;fmO9-fd zxnnu>LuW0dL}?xw*yvReyswg=Yu^Hl7l-NUl5S2E2`q*B`eJVe@FSwOP`syx)Zq5# z-&8bolpiZW+}U|_H^qSbRoH*RdILJK)OdSbOf@j=q;;w>9gZDHO4m%nM%lyDe_ za=iKamt!m#uW{KKjrnAIl*R!D>c%E~yG-Au)9;t_y1=arXB9H0E8qSt(ee2JpCEAV-w5_XvAI zkA$`U;_vvaT@6&~!l7Q$uXrN^+>ZzieE%HMH>{`s>`gDVvFd)#FT8wezYDUsw`LUl zBtkbKo}3;$iZ(2g%V7YzZ%r5SL;kdyH2K@DZ<5g7;UC2Z=i1wK033mkR}{C3m^oOz;(^bR-}3^CTR{99-tV&Y`Z=3n12C-YHNQd>BX(c`qqZ*k#M&1^s(;U zDXH|I7aq$I3eS=r3cbWG%FfXh&XKB~WAeodvSdqz$TL@g&w*+sZyjhr0yDs^@3Mlui`M>q$ef_C+q%{M$>& zz{*t}as+hG0!I~fl2n?qA<}sWtb&xJI`zkOBwNpdjcTJiSHHjy8)$ei-a}&@-t`jcMmom z0?mI0>RYq@P7{;q6^^>sIZi#BKLAm&CrD>}(0c@;S|2SilcliG8GP&-DpEgHfp<5X z4ZGdmj!thy!LE`9zChh$`b9mf<)HJJ(mrH|D^1Mw9Z!qboT0Gu@HrfPqVGI(d}Vw= zCDanC=NIY4pxprjYv*g9OR)s8UQ9IbS=Kl9O%_UetvAeNwQ=?<2OX+(!G3*lYcT2H zANFZ2Ciywr;Tai#UF3VSl`Z*V2{B`E??+lp?H$ttA)UuYladJ`lvArIPF(0|X!rzT zWqa(jU!(p87oMwwHVvxLu5Z+?eu^x#{h1oP)ERug`T1n-^4eGYa@Jt=)=X-+$sj&4 z=?U%}AjCTmj4-Cb{rml5^h06{$NovWe`E1tr7qx|BqoAQY$wasJs{}@by_}qv${s< zZCGeqnWXYS$6OIo8m!@o_FaTeNp{vfi0N@z#r`-r(#sRpX-y}(j}VT?7TurVd-s&m zMe4>*OLo`iwIRXBA_opOV4i83%Z79oD@|1twK27kz-0~DlZ7@P0r_Ir(62m%4v&oF zvi+-`y4%Wc*pP8E8i{Q!K@XnqHTIED#

xW5Uw`OoQP*3cl-6MUp_UoNJLFPJs``c^S{nV< zoFJ{vpVt6vKCMb9H`enfwK>DZFwe*Ci2Ub*9o&mEGhET65?g%zs^qoi!SM=Jc|B7L z@)PHrXJG0pW!vs(Cdv%b(mx?J`G?k5OD+724$NtFc-)c#caIBqRP}&`{A0Cb=zzKs z4_diq_Fn4XESeR(gKE1iWUcCPphg?`jr-Xc?6qj98)XmmjUT~oI&i1>Vf6x5D<2Ed zP@HYw``F%B6mr8KL>cxdc z1jsY4JSS1X78xm*^VvvlN4>F@ZaRG@R^I6Fr*n34&;q}6K=K-k#av#<99l%yaXLkNm=2~tBT0wPG4bT=Z+%z%{ANGUZ)!_Y%c zUcPUw`{%B8e}BKudH*_o;0RRYYUkL!@q_+=S zzbaP%fX`X?soIO+#lx>;VK2-Uhu{=@yaL5BI$wmO2A|RQeMqPyFCqWvq?Q{v7YM@! z1O`sGhOsa)y?Dv|f|xy_r08RSriTurnwy??ahWjk>&{qyo&u1OZhPY814S_SMldQn zrE^Dd=TO-GX^%R&yj1*-o+>#>B%L||PpmP3oRoy$@hO3WMWu9Sc*szq_2lzf{sCy_IG(RGrErD5!Z>d5PWZS;eO=$8=w~adQ_+guboISXr9CCW(wv zr3A!7#zD3s)HeD2N~B2{KYC);`;;vz!^6T-J#G%!!_WHF9h~9`Rh>o-k{9w|Az!!s z2B(1Gog3>;oOjNH=U$1ewL6Z7L5*(%a}S=4r)g12xkdKL^A%X~+XFQ&YcU&JFlBM$N6Gb+$$B_3zm_;8#`F(4)TF_wO; zdo4k`%rh=3wHsXd;EHi}=n7Q>=&@Af5|t85p@!YV7jDCv7*bx~KB`1VX!;FxYHS@G zf#;N=Ue{5YYZqArdk@y0%69}MdaRuDBk|t>ItOLwgtB)_Tz?3b)1$T{YFDr1E>Y(e?fjUKH?D z>|Jl;)133un&L|MNwtY&DU@l8`*+@Q{xuhP6V8R5T!pB$d0X{iRBp4b?tYvBG)hG! zO{8G*J;Zl+nn|w4_h<=Jad;RzH0{R^TRVw?I{=bMj=_prlk$Xyip0$MRW71}thA*I z3C2@ad^PtPK2e&a!|{+1A9UJr7UGjlPQtSdj0g+sk;_UnlMJUI^k5bttZ2Q;*E`B+ z>7q&F$UrRQfu7BTb0N;9TS;p~7I>tAHgY(e!B3n@1Q;2zret6Ec!ISIFkl`jIk9-H zsi|25R(>u8IX98tD@zm8C)RiO&buVqj2`7SF^lN_K6y;>_;m0LFUi^q!X;k9?G)7A z@q@=naF}_$wclTtEM0T+%!u%lS4@PIs$(8u60ao=#>VtJ5e?lh2NRedI1k;iYV*kX z?(z4*I-kSu@T9z;F9k$e2vkJCg%wRy|1xVH&oyDB?l(7VYRV$mk^0}&#Lu58@2OZv z&i7_65D)w|b*e2I3j~RMs86&+02!n$ug8Z}P6rsbrnR{4jEXn*@WC)=iOWIo zaFZ6-aR4Lh8d9hIvZBmI7hh~DATO`Mini>;0Se)rr`SQ2k-(qNqkb5$FT3Ff5iji( z__OJT{`JR=j%0|t?9W#J?(~0!4Z3V}=}jTznP&R)FprI@)qSDi42M1*om9a~0O4UR zWo2c%u-czIS#F1e=m?F~%-mL+s%mAjuudF9qi31|5xD#>fLt~D0Y1pXM({6Mu6q$z ztNlsANwCNp4*llPN0_4&+J?EA1h?70LRy=UivXCprh$nU3E~YY?~vQSmOK3~hQTkJ zwVVi+{ITI)**pffhu8($x1!a;qcTFZv$3E0gcaa+_-XqYp5bpV_=v0A-ogn*UO|S}rB39pf+t2rxt` zbr({CxgwM>q1QJjSBC`R8!S)z6;-lvhut(;{!(4Tu$v7c(@>MHo5RY8Nb+*$#n!Vw&dGbK;t$D~BAX-?Jv=0uDh%>QS|*ii`R6_! zXd{0-?8K!l*$SANGR1Kq|Z;R8te} zA4qSMA51ug>*eK+I)u*eO5*`3Vk0{s{8CBNcRW{p-e$mHYHI4s-KpYC@eisVvz<;% zkt581`q7C}T{_CxbJLs4CGV{+M8k}54||_*%W1#k{p|VGPj#ff!6!ERuLNr5SJf|? zHtNO?9$pk2yN;-M^-;6sLga_jPjD_cks%q-@mWb+Yu74Xf~%(b*$X6Byv zMTbS1f$c}tPcnl8FW{>Xc9$i&phfUF9=z5|phy1v4pR-?5tl4+S*tNxyyY$OFKGZ@)K_umL_;IRkASb#>NmdB1)PFz;+R5ii7QciFUvQ3#xx=*%IKgRM*g!E4k~l;oVB z*_I62j;{rKfBWdAj1R@+&|D$fmb_-7EMk1e)lcDH)s2mfdAuPPnTl7-{)-IFlR_U~ z#>_|GupwKrZ@Uniq}c1jlsK~5;tGdQ%9 zEaLKwT|R2n%lmH=bKJcQ)VS>?b~v;s_vbv}aWIN^okjorL%h6sM=^h?BO&r0VD4fS?b>WsgguskymW z`eYYu8CmqkrHEe}e9%0ZP&npbRj2bObYOY^_gY4@*@tdf;bPX5NY0~*9li%YUWM4$ zhaSqZ0lo4Yj9c)bKbj*y{RPz;Qn7_C>6>vERuJ>Rr&ofQwxPkJb01jSo68q}0*MF~$Xg^U?H7#L*VSDpN<0 zQnOknvyQ|U7NlSOXiTplIJ{2;k(pgfE*q#hL!$;Cy#2Ao47JTy2gq1c&vU+!UH-)Q zc%=)AzH&#?0or*Am--ERhbhL`cYMrB`KS+!!(QD?=Q1a zbum|WtHPS-#$;A5R-5unUNXwWz38b8bKHv^_`{8fnd4-r!!Q0XU2@BNTtzkhYrRp6 zVybaPOYQ?WrA!GczEZ8E-p`#D7Du+G59&j6~$? z2r_@_bIsxrJlF{-4@sFfY zJLlxg`s$&9pG!)G~_kojTk%@-M&YStK4SQGjC-)pB&zv zsIoS)B9kqvK`E3`q%rz6|i^_hBX&+>bfqpH3U6C@|T)2+a-DsrUn{20@IGO2ujNI6vb ziPzx;)~12&(eprwwhRrjtE%^G++{73Ul2h|*mP`*DiyD#1KrkOwP?-*r(UMHioCi| zDn;D=wLi-;h<`$B8`04kxX99G9>vT&lhhO`n3pvZb7S?pwxO=kiHS^B`(QPo+r`m} z%`n9+=JrIUgX%!m_dq!&k;8ubOJtha7^Qmw`MB`!Id0p4&ULXsckf!gfFMxMuZt`S zR1ToBC$<}JQ|YG?1w<`4TswI*`M*)qYghnslGqNZ{!x=T)w#;t=9v(;(L!cB$S+TA#@#Mc7{0>sEE8qOyI z$%Owt~F=T628ug$ho6EqtE{0ylc@DcF#CiTi7Pn9PaEyRc? zj%$q;)jd&-_V|n*xsA)J6*|`^Y4QS1QY_N;bu)3hvcYRnWak4VI%G0#1RWmoJmDYP zeQ4JM^3rnOPFE_I>;?A^&Gf09?u$YM2T8;@v-#z3(CJf$=HA`2(JKx5OOmM&aBT=4 z^lN-HGXkRR&TU_~InNmxWuWF_N9oQH#fINI;D3>xG=0lp~tw>CCEVHpdzf3x6Gp`ubX z68`+69prfs{>GWxnBWQV9u3!GH4qG~p&~agx2V-JW$vi0U5c$|T*|>qzj~l1sjkf;~4F=F&H=-h?r068dVCb{OtWhS`OyzZ!yiz zUMaS^;crKIaC$Ahe{Od2Z{R(5!^+1GoZ(r~xdQplBCk*M)RK6DITt>}GapJrEQ%gM zvY86S7+UTVn7)3E${MCz%k@%WPh%9D+#zdl?TUs&Eb&9r{qu~QR7LUo({gQ z=pdu*bnO4|p9?a=JV(xR(K79&##h3FOu;pHLx>uma5wozU{S&79Wn#M z8X@Za*0dkdwjhQ{-_#|e@Z=V;rhG)7;_5d|O}e|p*wsP>6R4e%WQ`n5srOZZ=V+#6 z;GuE?v>@zEH+(!h=cxxR9e3hu=kN~*XxxSgV9a&++T+g5qbN3ir*7X#?t9wCY;Cu9 zeI<69mfhtpvR)Fl$NNqO$@u012-KMyr z?~pyhcCZ3Gf^BbROEjr&c2w}9Htk-LrTbpCIPMeE`e6Ln6Dp; zoeL*Vv9hVNZf;?Gyjqh;o~(PthQ0?WrKy4psm6M)Q~*hV--u{KAPFhrWU<4yv&U8p(@LZ(j7C<_8|Lg9A{Ce9DZ&a zDP`ByvAXX@OI!?LjN(iGECqrpz7g)r9tYLG;k;Q&kq)d^UMDGdfF$g}nL8aj&P2LE z=Rt_%NH{dcX6v*)bOfiNAokKyr|FmRP6bn|N7#? zLBaBrd_w8URtW*OC1q>)0(U^2?{yWdB}zj-cInEv^0#?S+kLQAdcHz$9D>RMlIr7n z&mG*qli5?5<-d55FKuLS$e$NfpAbAH{RcVhaMvJ^Fjpz`Z8zMyxBC$;Hz;}f70iSqcQCGl;_f6nnW}&nee?5_w0%D$I6h>$nyJOPx74)x%#qU zcZm~8ro+y|_!HN*%hM(Xqo=36p*I!w65{4{AFo9nqkUSx<^F^KBaRrW7`$_Q;q&p_ z1I!}$Xydt+EE7{@YfQSn4d&(3ee~BD#oR}3PSj^rX0@4!5-+?myPU_=TuH~zxv_+g zODU5M{fg2sjGyG6+nK=ePa$LMJ^FB_=u^L`jm=C4TOmg&MqlJh zQE9_SKj-T-g|`Pj*bpD2$TS|WGJUEqE*Ye@1UK{^Ag4?qghVherP40Y;Y*r zNkZ1|gj#AO(>4=&qeuty_0JD_m(}AyF2RS=*&BT3kWA>4PM~p}`ID31VRt*wsc)V5 zuEzTiLZ;6}0(0+{jH+<>_`t=VN(&KnMqbIKC)yse*gj`Kc^>x#Hd`$vt=)(VT$yz+ zwt9EoR)w^!7dHG*)ju;22l3x1wIuWBp|4e=H>$HmY7&r79}H}3M^ZRNl78`#QjZ*I zzQt>|1)dl5GT~P{U+-I@KV}I)_uQ+_gn$qQN{@|LSUK$s#CiU5Q+}ow7^#LeJitxkK`1|)!jBJS{{zOw8u~fPThkeCk^pD`f5DK(4eoL%3l#GeuY zK$hZR(Ed`!1-X7R9gFbqciAkVXTO9IXgJ;2L*XQL^))TDHPvmukE0XaEsuW*f;t$J@;<~)QR&}PQ zH{#5$55Lq)W)jLm}k{%-U`6aQ&ySSJ^2Vsu-h#e?xl&lJ+o}7 z?sAP^HxAXD3iFp|P7*c+9+61mP8V%L83jRhhq56<4u1=~^~kU#E+1es%>C?7WTwf9 zE$)yg-euhRAg)Eg!%J2u(@y8VH7UQt@pX|A{1oM*PWSEcwFbH5y@0bVtX|LOYS`tp zeOplx>WL-g={}wfh+z5K6B;;s2YH9(lcrEb=+zM_;aE3Kh~(43_E06@#kCXj((J!m zVPGGlJd{zqE0Dh@Y?`xBw`zsritPPYX!3TyaqC`sl`U8{=kb-Y71Y=3y>~>9oFRhJ zRk2I_OO%?B>-JazR9*E6tYQ5z0l$Q~@F7O><>*A;FxA@Oj9(%bma-gqw ze_z7MNW+8z&Crd3@3ZS+@b(*}t`XXLh|Oi)7wy$!$#?JL0?I+UB$eoV82)rBM!JXN z#b@*w0Sh#Id9FEk>Y}ziWpVCaH3n?odkDK!;}&6hW>OD9&l&tEbzR;vvE$jwKB7)BCFpjVzJ;M6PvRI~fj`QSJ?bx^~$hW*(MOS+oM+H~Xyf^?h z%xR%dPznfLu zg?7a}HguI@=l(Kpf2*&58CmJYG%lzyY@`NgBt!O%c2INM_>e>Gv&<)0J5hO|TRm0s z2plg6c#Pe^umvw_OQBnmUQxCiJgfix^5}ETygi>j z5yRCRppp5W%I4Mh6zv@XO|}!N7VnJl&;^-wqt^sF`ZNNkUw0+<^F=iWZy0tin9*f| zWuvh-c=1u-3LKe(u+6iHiq$Z}8j~fVtF~(NMj@)ORrb`lYsLT2;vkxZCzCqs2r9UGqIc^$0qANPK5bBUi2grMW;zuB literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..42b17cc562b364419595f4749702998f9f150160 GIT binary patch literal 868 zcmV-q1DpJbP)g3SGF_w7Ii$I`2C@%r(M6aJWd`HpXlrXjQc{u%5?j7~0SGNgwO+4BcXv0|*Vi#RItsVj zjq>txd{|kb^Qz8Ua4P>If@@y9do#V0{%w?%m0@OP23D&Tfj|HQ0|PJ`jZ`yUCYb2s zn50gUB($`&U~q5{TCEmiV`EfoVPQdCbf)SC&Yisp-|8}0GSpWLPW~^!4?rG2&)`JS3y0rUnLs0aH^`=;-KBgL53KK&2qy|AdkI zW{CdJ(1sYaU$&z-zaC$N2qU@ip#DG^rYPyB$ua z6V=t#=;`S}dU`s#y1L-;c&PRdGY}0y+9wzcVt9BMnVFd|o6XeG216R1mzPzHpl=P< ziAHP&eN>8gMi2zr0pz#8zn^vl(IVxI4gQ@8<8=gSpWSxc9Vt2kZe4kXd(-W3dR~B2 zcEqr&Q2!Jl@ky2dNC5md0N((#`Bn5c=qNV;0000 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..9ff53f4f7ff44e1aff6d55fdcaf39c12cd9140fb GIT binary patch literal 1898 zcmV-w2bK7VP)f}mE4MQvlXR>21Rfse$bYg1c61Z`0fpHywErnCuB3^CE9A{at7 zl~_Uro1&Olv`rt9@=B~G)|=!!bBBK&29x^E!6}rp;P4$N-?ocO~Kb@n7Th;61l`VRH`nHpm^f*=(@e?T>L@3(;=HgegWC7kvTI(u&5~zfo6P)dfIjS%{*@ zF1A^X#^v_`lL6#7ZH_ig&XYw)N29Q?5dQxD@)(!MOV0a`or=iihhVofL#NW>X3ZHE zc|o9vcaGh^e?NBY*a3sVz~(0BX|2a)SHA$j(~~>y;o*U`YuDo9#fvy{u+T&*tdvBu1qGqG*kHe9)K1-o|bLO?)(3j)yxd9r705_Ez?%uitv-vvX ze;UU*AU-}Gd3kv<5bo~oSh;c~E?l^P81RC*%9=YE2>-`Rl{$$71ygFq`w+2o$JWt`lRu|O8KHxh#0z76C*zkh zU!ubF8xu+*>kNVm5ab+nnhhH^AS5J&t;-29BO?PFH*RE~@$pZs19&}od3mwdrluy= zlBmT+Mn=lDNgaURJ1@x7-eB;PYV~87s!Pyhz6Eo`HN5)DZ1{KvK(w~APO#LN3Y8QJ zy>1w)P317%KHo*1u6lq#aC38G5vEQoXNXX0w^SlJm3? zqtS>RfZ*U@wjMc8*4Wti>`9OoB&WA**@8K9=JWuEb3j#9 zRgVpK>wZZCIP3@t2*bt?3s@PbX$#WkwJIGeh244YqPF1*)RGvoi?$x~P;`j{h!7`C znBcOHii!$Gz&SZNXl`zPHXQIE3k?lrU4Tl!S%^?(5ED~tEG{nYv6Na8LFClDuuWJv zHyN$XccthHP)iv=N-*u%5>(zOlbv^VASft^5g$44?d|O{b6Hs#_U_$_{QUgB>jFHz zX;QAmiG??9+Jt4xma)=sw(|0F?AfzNCQfHUOo+>vf*UI<*1i75P1! zpFVv$6F@}5_lunqCr+^TS5#EgUDMJS=kn_l9Y8?P26x5A`1)* zWMYU(U`Yk)hm1hd@?SAjD`|Uc8-~04;AqKCeE)koI|GQKO)en<`rNs53**pZg_y*>5Vm-F;zeYk`-W4~oto zV$O4!%#BBofB(*%JJ`K@H_JM%1i3PGR)YO{9OkWwAA^aV4;(nauEmQNBQ`dck&&~# zHYN;$#Qei_!|_M;56C{9D4!J`l#HY(sf;MqsRcV9iywF7?)RIb&i4Zzw#=sy`FLaO zY*gPogPjEnVYRorw7W+}s}Jr6JkuK@{8&st@bfxFsZ-NSElfWOK|WJaS6_qlQ*m9+ zcechywO_h;**zBzMx3Q^H~ZVA$j^daJrp}nEP#%vf04M+`04M+`04M+`04M+`04M+` k04M+`04M+`01Phs58E$+4W>$X$N&HU07*qoM6N<$f=#%BcK`qY literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b234aa04c52e93a6eb3e01f8a3a5f7d31fa1b09a GIT binary patch literal 3437 zcmZ{nXE+-S_r}p!v1^2?+Oul3)RrPjI?NcQw1O6`J!&h75iu)i8=6v-iWQ0)DHXM1 z_c7a|=3_?97{y=zpWYAed#>x8b6vmdoKN@tzpvw2gD@lfou}JcEe`&#Ne2PiQBO3knFg!R?>2qf=c}|B?>k z{}WgHzobf4@h>?$YJZfMi0=qMvLgKZ zqn((;XZ@?$I2UzYhknllaEoAzptf_Q${3Zz&fDf?!K>y7$g$cH(LQ%B_e78S^Nz_D z?G#g~MafewW1^=3BRpBP!2+=^11;zmWZikULb16=hC+(WZC-Mh+?8v^*D{+!eZD!2 z0|S1!?{%ckXUSc;k|49q-$povdI~IC8h||!U7oqEibcyr)*GB0@~^l`Ast@Cy5XF5 z_)tp)?xjkbk8fO!yA(_m#adlu?Ye1EN|o#HIiEG`#zMkpKkwbS?Bg=A9>ODYd$&qO5mbP`!)C;0 zoDqjWY>qnmii?ZW>qYDh8-(9@AG7-tIkGgoYbV#+|GeET7OL26vrVootlU*uZiIG? zmOCaMAJOJevTp}FC3KkKPdVOgzZ=nz3Li8piuasu)`P?085j)m*;E)aUOU)ub+o*c zpA)K*@kv(;E`G?KbTiuaLN7ydH}yH{jrE_DQk232EX{V|^XCZK&CpOyPEq|7Nt3jP zy{}cbv{O^3tfvQS4k;!(cQTd?&^4a_$SgxYJ|4LF@S#|8a`OC<_jSizsm3nrbdJ|a zzaI~!7#J+nYZXy4k}dhy_~yL(v1()AjT3C2ui-{%E|_KUK2 zBZZ6YP|CO3^MM16**lfh8{QQ`l9>yOiz~v&4#P1UYWV(4VwGF>`TGq4j6E-}7&V2g z&j&LQ$YBNX@5M^GnE-%?i@mt|&`pUtpULFrsH2spF&1dN>6Ye}E=4p$zPQFPEq`$A zD;Us%s}|9>1&^cA6cPnjthO1Yo@npqDE=ZV%{IF-MGJj>=!b7hs<4L7(z|~C3-L=@ zfs0)kpZ(`_own2Pc(PRHXgqE|=-cVH|Y0)5G(H)>zYE+Wa~ zHHEgk&BokZ(T;S{bfIgH@=Ve%9cp3=?ZhpFyvw-2f4Cn+?P8--oi%*EGBKjXhQ4@6 zxH&ZjwU*ucsNz2h(F$XPuG>nEuZ~srkcc7c4h^N35zZ6&Y3QjGs@_dWu&k3|?2eqQ zTzFA-&wvig54I$1@c#0#*PIAA`aWSoL?>j) zwd}4(E%jbH_@thlSDExXuCqIeHu-FSsUV$OSQs4_#}E~;qtE!cnv>B$kZ1D}69*1O zTDyu;(gSMCg@+`)%xZa{qCc&}m6#sGj+RSVPkSL+wC<;1G$z?i$+N64=mYWdGy3{& z0D0oxVhszq8{qB0r{hEeyVq3YU3}XGRXAX=FDFslC;pvNUe2@l2q1?(RSHa-$FB^9u?VT2iNy59^tY7Nitp zN^C7>p3_;Fj@Nk$k5)cN$6zqPrdn-8alDj8dW~>W7s!qc<%9UtZoVkb;3iP{iVyU~ z7Gqvd0VIsK!*%JK#Y3-!i~b=oNb7omEd_V$_9eE>fXPn}YzUA7`HgoCLkPDMf=y}R zzbTjW9Kyf4T!eAOdN&`n^^_CB6$ww8(Yl?t;%L2oBZssdO{~f zOPlvHahZx(C!hW+o2EwL-=%AS>j}=_2xq#U!0^|-E+G?NM$|-E>&I=8hMApiCIwO= zJ#fhk{3{uwl1WR4a#guXaTc}Q*;)A7_gc0ymt%8Ye53npMufRp3U4_}N0ooE!h#J{ zMrG&yb2NNA$nk9MY59%F`G%_BK{gRs9MD{+?k@rByx0J~%B7{7z9h5fA_re>t^QOKG6JxE~c=!2L%-XT}JFH2y8}pkX6Dksv&sQHg!ATbC8NcDgJPGRd%2R z_LQdP>7_GNSi)g5GD|Rgxl(+P#U<8N$+$Kg+{rZ?=aaq3tF^MY4p5xDev1;sIPMfA zs1;C{!fvj&3yZ&7gsI5xjAsKW@SBgI*mpU5oLY#kooUtE`kvpUgice@d2Uszl|!-f zKN|uUR8f~zq(AA@P}3(Ybu1O7o1ioW!h4dM?>uO#{e`!rzs|qm*sxn++9S8oAmx{F zUDo?DC1z1KwDbO`=6nifDkVVf`p0~)@x01~jE`^Vqc6CbN(McMv=oPwMDF%J*AmnV z6%UAiqtRCOO!>{sAd)M@SYmn9^Ap9qRWo8wvGLi4(i2(rM|P$Wduy2)oR@U%A7`xW z6xs(XU;t*fZ)de%4Z9$Me*=zbjzii#EKR1RRZG;l+JpU_ZPs-Cf;7`Nq18vPqpFmm zi%cwTLh*RKladr+^V=1splci=uQJ?ZEjpTVQ<#fseR_f`bmk)8EE5p{yz7sE24_Nk z1GP@-aeAqq%TY5~&8VmzaT9QcsAG-3aO`*`4{#o{7FT>7`LLueaG;iulESWd<^&&t zymgW3fOQTfl*UW_whzfAJ~oqO zt}p*A)LzsX=F2eW?8D|~2f8BI1X|BNy)W0S2ng3On(fQ7uZ~E;EWSBZ zUr%oz#DcfB=ib|IUCvzAOq4b- z-i1dZFa!)SqTtZyY{<9=FWZCB1kqv(LKTxbejn%I6WWiYV;v$ExV zWbH55Mmo~Gz#gu|sIRX-HYi3Qfu_!NKwWAm!+;+?sZHIj_CQQ7$~LqO=o%o326!mb z68Y)05rDnxy?wdAW!1#5R31I41j@m&?n>L zVzaaz$7pY#1OUIzhSth ztTHc?Qje8G26!(=IgYn+Mjut*&7Nn*7FT|vuxxz=GE2l6QPEBYJMT2!NlS?zLatD+ zzM2>uZtxPveF){fxn~s^qpm#6XKz1oyQD8R%T58L?7pZ8@##~FXQJM0++g)!LY1Dj zhLwau9z9y2p2*j|e}fHV`$`oVkKS7Flenb0i6@Q?3x5GZU&2@+&FoGgx9Dw|w6|Fw zVq?{7kVEIJl$rm$c*F3@ok7go04mlU3GDu}`^)psj#&)5T!M{BJo;rW-hN{wKp-;a zGN*Xbu1MW51WX3iXYNB%s=pmpw@Z^-oz`_i!EVl9+S%C=b{iHo3iQ4iF(cPSs})a~ zxLktKHiI4SA|jTG3evllx}y$f9Ws_W$vIc2>zpTxyCSFu#tLiYiquVF!0jY|zNloA z7$@dc-Q{FlEW8}b8(irh!V(&0i#R=ItNCuP=U?NNR)554X=^K2Shvnx-c>^Wgb*f3 z#A-+G=mZ7HBZpEV#tWXk?1^UD7|Wx!K`k%8cpI#(tsQSpY3Brjx<-n}O?4p=2?a)z z4uzjHJ8k{JSC(_h3L=jmHLza#pByUunRz)=<3LyM_A4?=HOja$?G%vt1c<=^pkj8~ z3qi-aX|k`$JX#LPZg+&;DSVe8yZK4I%%IS!xZB(pU*z%&E5P1ZJP5>#{k2^w14|`Z_2qRgltTOIk)t6+2BIILJO)1=?*_);)p#gHv zpblo_@4;kO&y(d;7Sra)=TC1VOT=G%UHwqv>2O?LtQfaT75PI~_NOpykIa>khg7@dg`Yz^N zDypQo4>p%mahjxdm?e;u^E(rpV5Y^8TZM&?+u(mjP+*b}Z|!vdIci`|bU9Y3rmk*# zb$;l7bFnUKKlyxQIdBhZt^}lqh>Fs22A|L~3!A_5Xup=u9M!|`?u`Z0Xk7o2U|h3# zc^4{n<*~8Z4Yn61=lHCTcXu|`OF49S7oh=p70m?YCW8tzNH9+ijNptQ z7)(rG8)?a>>YD)GV85Ag+RLFnEpeb*(uaHeJmTV``}v2KsW1C5ww+-BOUntTx0x>b zq8*3xz}jEdft!2}`1zZMYGj_DkR*j>DFrNbMOfYz4!DoA$Vmutz^!L6@6L?{Aj9zB zqEA%Dy(Doxo=wc!lm6Mf_bQQiMzDNK{Zqnub<`Q#&6P zQR~4fePiRPXJjMKke-n=Y+XgaRp}H~r`zA4Z5&MuuDq_!J^ki0he9%coAvRYG~;v@ zV~oo}LbJnM<{P1ee^)NN0HZyn#P{)`jbD5cY~jBB{_`)2D!=vuUX!t}k53OqYhyeq3I!ePNWv*ev6`nAziGFD^@L?I7nz+l4oDfWQ)t zaHfsJQLGb6>ufOuMyke^ueh%y^tvcTC%5zqRqn(>p|HO*0Bl7Ze0A98p%pvS_RHeR z>wuu_cV!4~vuKWwin^w~<_rP^6f18=_M+{@yVVjiEa(kB%(NT>UFR-r!2AZTjO?@e z3Ja`N;<|TPUXz1*q)p6BSf%%qqguL3GeuL#`h&`M=jys^aEd<_WCeqqQ710JW%fwq z!#;gR(kU|eI;!E5&GExwxqUIBrKKeqBlr+=)YE;&9XEVaokk4+s9UiRCw?`G73gm+ z)tlQ2%Kxa;UQKmG@(e<^{+>%C9R~(~c>+TiOAvhT29`lI`i#%spA?tOEod6i% z8r&A*Uh+I$gP7*;l$O>~*l(R}RFQrDaBkOE>a6?{^u(1F&uGJLj)Co4e2<(rP?hWt zWT)V`4Cz1pBv(+c#)a4p1Y-E_%z4*4j*_@cm#-y7k}RG5bIs=F<|NfSg(2z9W895u zomhh4^YY3Ph_GTjC=>JhfQ zJZO{Z*75>)i{ZVI$>yP|!#l`W+ri$>>P^Y}YL zDZwwKs#;=V=;3yD7PpMX;m0B5cWamnj6z({-BX|7^z`S2hl05+#}?07Ugif{bPwHt zlra802D0lnHB$*fBxLw6Q|{oK6ZyeX(1rQN+1Bw!XCG<2F0m%3m~PYP<9C=pK9vQ+ zp$_4aJUn5fN)kO5^{z|Y{H2$gmcEr7)T0cQbR5XZq-gPW0 zDgvv)fu+`YE9pH2Q)L#f5ht4k85$}oDtVARZ13xn4Fg&VlM3tDu&^*tc?_uFnX$uE z?l;GYO1?$v@|Udm1_?_8!@Z@j_J$@c zk8fW4r1iM#3)XVY1C)75b21>i*0r`In0$9?5Xb`nD`tdueiZDxF&AHOwn>$C>uL!& zb{DWWl6lm+DNK$Vdy)(nq-{H2M$2FSuE^doV-pq<`p%yb)I}@>^heYIY~B57H^iB8 z$Z!T`3ru?BLp|+OZWQ&>zDzWID>-g82a}`m%E~KPTJJ$4wujFP4Z%9-P;1p~CFV$p z@vTzTZ=59u_2Y$6>bf}Pa56yMtK6Yfnb+fSzLtpS$hl=5P#uHS9NkjPPolnwK(RV* zf$(UwYYgsexchc5z1xQ?YMcWqwh$fAmXeabt_x6j%GDnX7lOmt99vmekLo1_&U1|7 znttB|N-hYEf9BPk_q=PCs{{(VyeNGCxaEmwf#8Ca#DY(kljldnB&)AErHoa@9cQ=v zh6gtZ>+>GjMrN>qAaH0s-IY)04=Xsr)PtdwqXD0)8Mw!6ZrODEo;lI*sSVLZba)|$ z+UsT`%M zw?6z0d@x31XvNA@R^unTu^cagvT6%T=D+^c;DI9={hsPj)?MS6UqH;u%S&2_Y!;gT zrht8>E;7LSRMZb{R6^NcSi*CkS_UJN$xTT6lPUNc#DKGXbBt#LrTA=5W%=zdSn^Gc z_%CqMVSjeMN$qG!Qdd{snciypN^cvc_j;qV zhTg^AcT#E&xxLvKFQD7k!Qoj8anlWTwc1PBG{}yeEja+6+na=y=B2T(E zFmX6+EyA(}&4=MVb(5bq-#gqapPhMgd0PyOyg0>g=^fzMT3!AN2By)fLQB{&BKJx246>!?m;9E&nrZxh- zjraJ=SE9np;FO42KHKI*;P<|uoTWOUZ$btVP(juRlyboy+4kHA9Q8_IBfQ4C7oQ64 zTo+Qc(|S<8X7~Ii@g8f6l8aR6cSmm>Z&xXC+DC*5L#|y|W33oXmUB$4+jzOWs1C7b zAxKRWv4~rQ(oa*1Oly&?xiQcmmFJwE*jaT7PP&8oYVBT4^grnmvNf%CdopaLV6ZaM z{};TH;GgP!Q=)1}C-v;!kDLtgtSZyfM4Od<>^G9wpoaybHps>pJ#B^Ax$hDDFSm0ab**+^DgdACWZQ~i2i6M4u8~P^!Zb)8q-Dv=todNG t@gMY{e9ZGdpvV7>`d>lHhVe9v$f|>~YN1Kp=^y_g(pJ|~D_40Q_MfVm+Xw&v literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..525dc67775664b15a7bf026b18cdf3b9e27e6b89 GIT binary patch literal 4900 zcmcJTg;Nw-8^-A_SGq%qB_yO9S-N3i>0G26iA7pKYE`7JEa0w4*U}vl64FbDl$3Ob z_;Kg{4d2X(dCt5u&&+$ydFPzpTLV3HauP-oEG#T?O$}9}hdJZlPDJo9%3=GiVPOIG zG*y*Ng6H=U#6Biq+PJ?5Bf0OaiEf?AQ0$+Gs1v`Hu%lhFBO~!~x(wfg+`bAFt5gM) zDdYJ@+0#%_z42o(eFQR9win1CM#ok4XaK&mq60?dFVA0xxXL09zNAq}D`L`TUQJxI z9JXxbz($%XUt`U7I)s=j$l(bPB=y==(+kihbvcMY{#6cD5VC*Ozl$;ds{dn1pnw=< z)Fo8(xayzImHSOyWRJ^<54!gRXbd#~iF@_ii2#;G@3=#z6zy4Ug87L(5B4kD9O{K3 zWk3pKFK2Q(jXWz1e~C~BWEf7Xl}H)_{$4_1(q5H8yS_}gKK`=Dih65{eXEbgN?QSF z+hAVqQ4Imdl!SOZBMW&&oOps>-D8TJ((aG#1y;2vGsX0t*!>(<0F)%O%&!&qoC_0w zb9y`~$W3ZA^1DXehMQv9oHcg08(?1k^oWw|MR(FCDr)Me31rw+)*scd^pv43N>{Hj zXJ{4VfJf1D+9-ti@12^eWMZ0dcCVr>TZGeB1n9=QW4;Uhkm^f7z65OedDxv+ZR%c3 zcMcgO)>5^Yrkyszsx4U60(i>8Z2M=Y>w~7YHwA6FTP)44tcQ@yE4G;E-z#r!PiciTzJ196p){5I)U&R5USRbWKD?cBYksDxlG#TDTV>OU#3JFEUMpYVv~) zpDpI+>0?zCa~L|OZUaRf?KZhU5qx)`Lw0?>fQAzq{ z$YW}{UxRo>WYgu0CILFwVP4bTjiH-#4X0-K`1mJV<(3UpjEsq{lV4Y7Y$JN->)gi+ zwCR>^7zIURz+Bm0{?hr;F)_z$NzXX>_=xkuZ}>vbwmGcd9y`!m!wZjqQn|TD#Q#Vf zMt8SoA` z;prSc=U^|ye(K^9>FD5abhB5vzS!h{^)rT&ijJy1 z=^Tp$DB%IH#bFm7#1Ig6WwX+gh=>UIIuLD2Nl7IrOsTJytHXcAD3BhlElZmRc-lbn zF6=m89hwsviFKVeGKx|80=qnz;LKBaZDHYpo;tUG>e`Ml3&taFYD{60)XFrQ>ttLP z3S8ZeZgg*QvQVDFI*ee)#*G_(lmWC(D)xxW(u&vik5S;v?+D?-?W$&yi+Po85IbfMYHB>Jxeu5^Rl5Kj$*1V+|7&}7@ zNmY6iAMe;5OFV(8^IDqcd!CBx&U_~Ja^xopuJA!~{F9`2hNUOit=NfFhYttm+j}Wb zNhHz&rSXMk_!S)ywA$d64k5|&LD$z+jf&y7e`;TYTml!gWNq$l4_fq7yJo8$9{~VJ z+^^v?G7j?1pTJIGF z0gGCq2m5J4y(aQfCc!Q!dzSEOfu{@RYo-RjQ(|*orE_r(gfFnHfB^^eN|Bh9?h><7 z2>iAqC6vM_-re)|@%yYS-u~~1x6ZZOA1fe@J{%Grv-y7!JnKf@oksZffkqFNq$3~* zr4FAq^Q6HpAb5cd!mV?i3sNtfCLvxb0_+N#Ey{7SI6wU}~8~0-0r$iI-U&UnoVrzfR3wN%z@1?c0X#JayfV ziH?>-Uzn&NKa4YZw?^@sRZQ_(i4$bpjMd8N>FNf`q*?Xq-(DWhtn)zY+wA*jO!g00 zVnx=cQQM{!OPK6IE0+AN()(=yh_cuvjrhzvvWD)$;ds8#sf?CAmyl46{RuClJx2-^ zD}XD!?`Or4>Gwg+UOL#X#DjkTmA&@5|2%(R%_sklgy*gyvs#FfFA+}lh8#2w-`CJ$}yssc>e zO_Gcfn&;Lu_7 zV_eQ}6W6!fm+=%iT+x1tiBrAbo-Em!w?aQ0J<@Q#rBDg|Apjrtn*?bnJyDnw!7UUD zKcp$j=~C9*Tw8OH)>{cXO$m4pSGXw}Vs!PSu5!mOOB@#LdVxF966BpD<*VYHx&k|t z&(<_G9kd*BOn;&I_)D7)GS z49%UFxmQv2LeGzcJfx*4Z&NGJwSY)ua8h4E$my>vxe#=i!D+r@!YYx8Cq%&C6T+W} zn}g|^+9P2u*{HPbu#sr}kt1#F)P*`zDGibV>N-5su%RnKA!`{%vTJ>UoCd?L}1XxZZti zB`mGiAGfdX`c+4up58v!k1!q&VX>Lm-Idjw5d0n*5B`HbgK3!J4k{#sOxm;|_ zGZ(H6a>f}>yCZEQET`p5Y7IJEW-N^7yM94enzfr@hhaY zZPk~WcGHx`;;UkfpJE>E0ECTb(GMQtyZxyonm2rllQ& zv}+*1Q@}9r1-`59R|dB>qH;Y`1haC13?uK%rmH$Gri+(L-<1S-Db>OY#&;A~E$|4jIYO@t9;eu-Dy(j4BGdS|zSx%SsEM1agx z=t6Q0YndTRZ9s+|A9auw?0AtoC}(oO@trrJJhWMbiG(eQ?OtS6HXDU(IVqMi4dI7Q zDx-cXe6PgL@Pe@_BChATigGY`gcQuYu$skUVwpIy03Gu{w84}TB(u#taC&>o!@w06 zo2J+`NDB&bdl|DU;C|>O@OiD2R)j^w@i#3)&!=QzY3k2lD;h@nHz|t4eIuwO`RKvz zRJnZzAGyn%9Ok($C6OZFnAuF|%?gye=D=@fJCmCS)Z*ImzPJzW?;;H+z|BC( zpGG2vd0uZ54*%+JY|fERQg|6af4bt+)6%C{|Fup$hdB>eWnyBu6>qG_nIt||KiLL!XoWu6J-;f61>=Z(= zEE%i@9iFB_-sn}wfY`&0Rn0O^JjFz@wL+c@f1o$uOm%K4Os{lYV>SuIM;$F+jj8|0%E5 zhoi`aCje?WI~8Ks|2s`z;qAr#f;Kq$(|P9uTN4=N2cfw zC3;Nbi7={oFO<__YdC>4gsH|opO!HCxPU-CRq_lVWEyXK_dY!uWZ5?moH+3)cM)KO zt>OFr5#!4TA87+gobpI^jNpnKnx zfuUQ2cHqx)8&XLfH@o}~8&60nfA&r%LuSNblXkHM`yT>zkI4fxOJ6eDl6s&MF>kWX zib7(X2U0s$wfbD){@`?7R#ag4=?}?*z^^~3dGvDwToGNi>_6o@le>WBSUckt*0Ky# zllw`@Ak%&?OrojF)bX4^2|~VIRQjyJ@{xdBWNAALpU3zj7#|3&HaV)0$d_5TGMM3S zmEFCEq*_zfrsvjw7}v=Ydzsqqss!ESr&z4xa9JvP+>L{TGJdwI(i0R$TCVPK9j?Xy zEzg|0-c>5F^0%?cz0;KZlmA;rm&}cUv&dvIumyi3Xk%tjdI3-SlO!(%D!2d{5D8pQ z4s#MBc$J&Hct-}~;d8QxvRlZk#U6U4-w^SQt^`w_uR!QITmarXSZXnx`J~NI2E_X1 zmFjBREm51?PT9~SPrpqeiU-CW?+*t;pWUD58B#D=wauLS%6i0aJ##(D=dlU|kNJY= zbfN%{mkCn7EhMTnsz5xrv!f_4JR?hYG$FN)$Z&qI(#&>O6)>*Jq|IyM@7D^4aXPL1 z{P}B$`V=-Q%~1o-7qLD`_Ze6l2?54+K^41i~u-15-O z=+>3=w+D-6i6B`X4*4x~MkzWYy4S9W#m2}lZQg1A`R^yNRlXlocEmjshhNvN=0z$u zOX$mFcY5}{s?4338RMR-zrVVuF!-zCA$3NOYBf7(;Cp0)@XjGTc)Ppr~C8?Rs>0XM8QXS^XAP*8EJ9lH*ek&{`-&+UM<+ysL(fW zsN-eCMO59EPSO!Q)YR(w=7AJ7iUAX9O4L{jF28C&<)g<&8i zBk}%_tzW($iImT zubtOF(y=5rMke#``T12JP810~2wk+3%pV;Ejm+Oy42cYj@c*BUmizyV``=|#bogI$ z`%jGjK3n3?f2+y?mpPulrrg_Ae#xdEb@<|5%oUpG(<5c0+*37_ex_I)JzU8f+a)Kw zS#_?gT)QwcYDN7naE1&@E8HWsEW8 z`2wTL3`Y12wk!XW?jkS%uC|P*6V5%KIqH-<=l^X%q<7I;~y0;TG_!$vVZ)HP>PN3iDIjDe+b6!^V&B~C@*LtV|ZK{@U z5>TatSWKxTWWNnP7aCDx^IvR%nKHX(y95$FM|!mo{)%7(+9%su2rm<-Rm z;!2KbhFR0`CglqS#rOT2(!>2@%ms_gu&d`2gdo1y=l8u0u=DJfD`)95C8_f3))>I3 z+k>Ir1?oK~IiI@t6xA*k)`1Rqi!PGc2>87H2B^)S%~wE54oEx?#D&vitzr)!2<_*W zaxE({bZlz&<8*lQtIZ!Ht0=zLGI9Ucz+*t6b9_BrJn?f`OegM3CKk@|91j6t_C9Pu zSTl|`FW+=XfgrJ{$1xa(Ui~v_Di<=()7BZL<@~`19tGRHkm&byPZ0oW^R0P+H(GyH zy;|y==SZkjC)u`iozjbTD)lJeV4jJ&g;C|ukTWJXEoT;t0PB$qZYi6b&@R6rk6avW z-6~sE2dFE!fjwBVsyGz#>CnGIfg$ofwl{&oJLe_hWCj%9yjbnz_r*y5)h|*BwemKb z+QNK|77d+}4qbX=r35u8?=5S?OL0-VjNEWk8cE`Na>`fmZ&z5^GB+7D5iWy&FOi>A zTHE$idZM6&)7^_5tLSUf6P&mrP>0fSQyN`T&=mzzaTt|SkF~i~UGwkAdY6WKJ`v%} z{&LbWJxEFOeQvDc*gN`Vmp@YL`xz4jjR($Q3yMImu^d@dRps9l95ySy&Q3E>xVKVf zW$SuqtNO)!E6j>A7eT6?z2FIWs!&WR#Nz0RHD(=9o|upzxse5p60=ot$a%BE-QWWf zgni@L3S-CI#gdv|DgYFY35+L_QM@opAl@D_#jsG)9fw)z!h5^=S0D5bEGj{pHnd$1 zrX{idq$hK8$)zs)7&u6p-^y#COpOE!MW$bpkTX zEvI-c_-eTyYdq>u!K6iCRG{Gx?3$b}zA9>bD7v3?>tB1eM%&+)AD*M?C7NVhtIn}A zWqN$#ZlvP=zA8Ca?4Yc{fq^LNvPQ|aheHO)S?k^PG=4N%$K%C?n{By#DNsFU&Jos- zY^9?r_lTaKq)lM1O3}GHVi;e!(|!?8)Y~S&VQf?PPrDIhGNLo?MpNy<*J4{XVo=(@!iD1SNhT@zs;)jUCy7_uEa=HwUw%bXfsz z-HWby-s$17eUpZo$bV6B$ZbmF#$)BV8euoRJznH3j6h-0K|YJYSKSy1NE1 zS#|vaT6j@=JZ?M9ZgfPzK{8kQyyH%my`nU46&hnN?>!ePjM(hg__eCO1?xg~;*Dr@ z{`ro%?Kzl|V~Ljt#W|>dEdn>GXz-KjERC`i!|V~3DF=9MRk?tiz?MTvN%`|K%@Y%; zV1$JfP1rLmH4Qkd8iz~5hq8A&7)Nw{G_RoV1v713ZG!El=6O9oAmiIAJ4jP)@MR~0 zxB3Q$;u&^s<_+d2!w2b-Y}K;^&&;N}!0BUgsFL)>!CfsKli#J4HCL3vDK5d-Psz5k zfh0mQ1zob$qbD<;{tcm#pBF8D0;T z2WzcA43!9!WD$ykO{(~YO>^`okWE&9TS~E<=Gc8#=1v+1s}muf&^mIm$kFnld?zO< zUvIrzD+a*ONwEOD20|#G;Au`3q#0+>hi zs2s8)XO|m~W~=2S8}m$PMqP0f=je>Y1B<*MG^VaLV@^kO5oOI4Ybe-Ps+Ve0mA71m zT%WBAP4GtVW6;Crr|g)g@w}-a9!X*qo1a*5UACS2E#gH_b^whg7_o~6YITUUY)Q$edC*7dxZwQ5s#?@{1M3pV$7-rU?Qc^)_ z`yl?4vZh0N*wfXx%Q&d!kXv5xtpV!Ehc~eIDr5u~PeFP=xwb`LF*LP9R?GQT1e|OE zxsl^Z{rax==dJVT)ARCnKGQHoTRPjOug9X#JH#2zFYFFIL^2z!N=$1Y-UCWK;a>$f z*qa{-IJXDqFf?4F(X&`Xai9g)fdqC@!2lZBy|MInsH;<4Dpun5#MKsW?S5-jn^{(B zwRu2#N(j4Rdg$$^y`ezIwO>K;3&&GD1l8QWrKnld`(#8=(73Tir$M6{b*;7OU`j2? z!1bP#_`zRj{U713?_K`LY_7+e>0$tlVX<5XZNr~Le69np-IQkf4=^^<>w#|jG5Jh^ zmU}ZE$(1=Kfl24E%!Cnoe0(fC5Rpnfg}MNHuD4q)Vu;R!?BXp}naT|Su_495mY0HltKZ(fq@0syGAv<{<&J^Z^!lJGP zE_wj(mQ?^`lJt9)t-?fax#BT*lyaKedr{aYQE&kl_uoO2m2lZPC$zWr@pRJ&i$t4k1?vv_ z>kK3>%!S&W8?-!VPx?xYDEC>XGnzQGVD%Gb{*xy_Ck0*fi z#wTan3vYAbFRWycFo+l!=?HImBF3j-sl34L@Ot}11tD){D_Cj{jk!FI$x&80dgy9g*M=7zv9w#ra*~TYPw9XxqyG81&5ip=M{Yid5NNz z?7QoIn|Yi!oVa?dzz4L?Oz=ZJgfCqgjVZF(_!ozXn2|V$y56{vqLG(xcS&y%@%18*YokXhDomD2b9jO2T$v&9-Lt+rjabn2l< zFudz^QfK`-8?BYtfIpAT-`9(GrWi6+)p~xM(A1xuJ8_Ji#LhqJZTdd})0e`(4NiFv zS=+DUptpSE&g>LZ4PUCT^x!MXM@zS&fjLeEUv}CBa-}kcA zOr*UQPzg15HS`M?7gto~VhhAc{)#=QsPu6E2sbRdTMIo~#qA{`p z4RKRh19>cLzJE}IL0`e56O8gnpP@Qp0{8n#?fjuOW1Orw#rPH#e<&`?TzJP9>tegE z*csm~9Q>!}s$u5o$BStY9}~UkRxNZwDeUP3h;%bHv#@3EkCrPg9v`^u@VSt}G*7}u z*0{Vh%8kfR!;$|q%*uDmlgXRC0JBLdlUL65WHAXHmRht8VQ$>UY+c3wYN0qoY&Mct6m?gY;{{pSAEh?mCge6qwrUy6vmW3Fi8qsW~BOCECMbWsZ* zz1P7h7(}dz#h-d5TRbhU{R5be%?aEQ6FMy)5bMp4g>yB@A z3#&8*rJOQzaQwFlKZck-07%+}$9ExM`NKvizJ4+1CF2x~xuZvSTntqmI-;akEhy;# z-+K!t(>vj;94&!F#KTJ72S}jC3p5W|%k;5}vUP2FL>YD=)M*z;_79pf&Jly^UT(_0 z2cPfO%OhuEm7HUg9psHaKTFa=LwZ%rmg{WUJRh8&;}#phCemJg;wr&7u<({s_=CkG zWje*xb<09ak#Zv*RbaS(vV|J`S7vaR1K(F0L9Qcht($ILJ=+hRbo0L8{Ud*MBoD7v zUSOV4tIcAixu4DYm5Jzz;VJ(ovk(zyQo#y37Zi!yIvu+W7}W36HHIKOO&=FFZvhx3 z09OceE?pmncbo(CqGn-XF}h?A3b6~+|DuI});4Nj4RCxSDrvm1o2lt~KR|*FqYt-A z+cH#KR#B{wqZXRekR1-uJX_f8t7B9-b%s7rpIYJ=Oz5x zOeA(%q8N%&zCXa~O>f|NRz@@y)Aw?VtDgK0_BX^S&`fbvhQs zKY#6WiHn-%J^c02Rcym7>(eUT&K>Kng_3UQCA{%9$wo8J*n6d(rtS9!#WE15dC8Oa zSTjRiGD~E(YQ71fMXDBraBZ7c7&@^1G_pK>k|)>s@VW9}&ZOak$|TUpXJ~QZ#Y#}F zg|A7E252N>#K8ZGDQAM+zqol+h&6-0hE&{ysAkY4GXZ1WbVavIwNe8Sj0g+IwORlh)P9?i_-dD-Z8JgIS7;fj=I*AS|dean2IVE zfP7I`&A@sa5GnNHW;>B!qf>;>zM5(&)WA8u=Lw(wq#QFEqoW?Nrh>HE7ls5@Q^a{woW**Pl z%NhK_8#=I-c~B^M-XogA@`_$83fyDr5O!%ihYHs>yldVWK-EQD4YAg&+=Pf z)Vc}5Apm1jQlsu75gnmGaJk{KbKw$dKZjWm+i_SPifIGCuCW=hP&2N9`-zt5-_R)a z;Y=0NFo5A_+6!2H;U#xIR%sXXdMawba0*4+Te>_CzjosQZZ4jOK3K1}l~xI{XwbM` z&Gw^@LY>mC4|uvr;MJx^Alx*%K{$mIOetj>O3MYase%c6>#aPe9Nf|S<6Q*&2^-yh zw($q!MV7`YTTa}&Nt5ThsrHAyf@z9WDxX3d$*QXrqULNLXpIKdx?>tY(INx&(&K z4?2w0?#NNP)Tx`bG#XuwTcDhbhJ1&nEjyK~MY(qQ*|*3inK_PvwNpn$O{pY1>RvGs-fBD2lk?e*S9T9WixPPCpjH-?n(I>AVcu!Q2k`Z3>}u)#Kpaw2MCvAnMgn z&nh`#oT+FLrD9WP;TU4!1b;o|UBW?%E%ymxvvM4P@`DM(=eNO~bcBw$yyqW$r^*e4 zY~yZTyYCUxJZnE;?77L0@csR5d)XqUFLZC|I{I&-Yj{%nMjVRyYvMa_{BBP`)q5Za zq2G~Aiu%5^!CnvW4yP!aDN)Ww{_a7=K5|yAw@`+Z^R&_Qm!YBM+u?;qr#*MjL6|C) zN0nW#a*^2cM@&bxaQ?7!?!5pCo9AJJ7jox#51#Jc{wXuIuuhq!?XQkjvuQ9+ zYSnujf~yHuiQoHM&5x(eH@Xl)(TTJO1p$PVoiAymm15NIL4BJXd#B)VgtQgCQ!Yhpwb?$8_3K_bcX2B&^Lp-^S?r?0J(G2O-}*u$2*13+QP5^%?_JTo39 zmk$$g#$=RIpVWnv2;8e~%^(LJk14fBW8qgLOwyro35%~LT^q^rxyfP&m4Pb-z|gKp zalO0K)tsqXYs`_cjHH{RdFyga!qT&qMs~~5icy*Qq~X6*0=%E%{SLW_CMEMerb;<~ zJv|n@JJ3#js3jP_baC3b@Q#$_j>P>wNu0gUP}U&&Xx#m%X}nDyJoH_)quXIs9^z2; z0|f+^L`jCyi9?&>^k)Ut-9`F5Kv%3yFiX|OC8Y7fZ{2Xrw1D%y-J~4x9e>!Q8)VgGUPfJA zU3GWpN29cH^GkjEGm{O9wF|3qd4h@YiUJ%Xu{i&io-&_Ua(e}&v5yiKwTQ#si^rgEr z8GWC2<9>E1+^0MX1z1NWYpBOi9G&~9>RxomtUG!JAHUsfQ9LtCzmBV`t7^YDH}3u` z?RRb2hsx3t6$K&Aj4)tq+&NsFdi$&*F^1I7+fqQ?^T^#2qdAXUXn7#2*r!>0=Hq$u|0Ikp)>;R0o{Vkzl3KGJf$N_~ja9=(;2->1c3DWyWp! zb6?m4^Yw6Y$@TH5K5jiytKeAR$U+A>Gbpjr$$wA^%iv8 zN^q!0EH$vkw#OeC09S`$MwCP#q`j2^p7<$#K3A$^Ju)+j&!Mv-=XSn_K_NL%TE9l0 zpW$vba>Y+e?XRT5FJ=)dbTA9!$NO<`Ra9n~X{$sg1r69@g>^=YGpiT~$t?!9UBPnL zeq8vmof9AAdLmXq_@KWAWS#F(G+QG_QrR8c<)~+6C|~sX9$qO(eqBR4(j zmamsr_am&*3nuK*_)H$BKl16kYHhDvV(yhjHnfLl+ysXi^hHsfxb%JXkB!YolhlCC z^4A+F<$1M)vh#?mmGC&j&eGLO*^`>9wbFUAsgatXcHi{4<`2AObwKaqPL>8s+{q)S zWbIs^R#we zQx%sIgP%C0X{=B5(ApJ|=ZH61iB>Z3+s382>1m?Ef;ymbST70bt8#Tq zy68KMR1WKeSHjdF@B?1kJMsC3PfTbgT5MM1;!?XrsqUf07b`zy7QN~1kZ z%v*O_-jgl=xY_`$@hF26%nZVf$RJbO<(ky4J0*e~bPMqUcxBiFW{3UE{=Pgn;i7>e z2R?QlExBRxkqGgK;XvV$c~dnoykO`*I;iWC|XJ-KHTeq z@(0-#U|apgGsC498CLx2IX7xSdgT40oRRiDcB^4kc4;2N%GXepk$Z)?n`x z0^p@K1Sy~xMi2=>NhwTi3UIgA5HCxd;Wl1aSr$B>L}9AIT13Kg;!B_ zU9UA0Yj~@yy(9Q<*DUDN^q_R^Ukn_#Sy%e*Eq?o-eI))IOQSHirHLt^oCp0f4nW8K zVm?MFFo)f$oz%2{EgYc}Gr8tz$QS?Tn7jw)*U$M1O{2NrZ~dIjOM$Y1W%6Fft*lk$ zhRxnnqQ2u%Y~|4l`LgJ-k%AA>cQYya^%7dzUEA(=WTM?mWbdR zh)+vPO955VC)m~Eda+5d3gMVnMB|nbjZ$YctKbj$4=E&5aUJ~6FQYNlSIz#JHPD+c z9Z<3R^$si&PtX`L>d3gFLy?3pULDo|Dyh)VNphV!n_$a@vMa7b4T{=$tJ&O8;)_({ zI}n5>pfu|4(b`t^g@!^-|LL4rtMb6lABW)16<0;j5Y;Cb)ne1XJHv^aC?L>Rg$Xdy z*w}d4@$S<;g-hI~$$rLsz-cKBHI$pr;}J_9rMbC-j8W@Q+-hu!?sB7^ zh)4?l@;`Mxk#-z>9r$Bhs%ATFnGgGz3@A`81IF9ml`~m z_`8-orn;QBmKZ(B6|c?nlKGy2fh8It8Mw1`W;RHW`L|aHheZUl$IL929V5*87l8U+ zn*!r0Na%@d7lSGbN@)XnJDTzi5iL%{58*(F&psEJ%6V`itfoWo)axc_xt<-_-n6iX z8fjH>UYN2YYjZenMOnQndT)lJLE0XcHzs5pdTn|Y+pMEs8FM_;T39->Sw_TevccC0ENC*pIZmfVt=uC^| z(@2}Ni_c!~OzoLN3Kz!Pe`=4ZN)xGMLC>a}WsGLnP^53{1@zi1$S|<)n?{1*Ga`W*0}L2a7u9?U)*Y`e+bu05(Uxb)w9W%6hta7P-7IV^5zn}8SmpuM)eXV`q#&nXvpR9W8D?tD{w&87ScK%r@$qx*E>@6` zQ*Xy5CGI1K9=iem*`5~c@qv{cg@Y}pkdGS)A%tKf-Mxh8+TZ(TKxC4?n?f&kj0Pp% zQf3~P?W}kQqMtXStX!}TSSE^#SMHZMPMGrs*6%&Y^q?gE$$R=T&znnX4pw&ze%)g@JlTR3J|NybtKh$nTE1qZ!$+c?{0B~3x^)^I&*Kf4Q6 zgB1Daaqhvz))X3Le+;z36e9SvXYFE&iufFPs3f((N_9u=hnb;)gjdU3}RBmxkW2DwdC<3Tp zb>$y>0~H;l<1g%^U3cv#+qnv-MQK9vnOl@x3*dz2meBDXq6g|Z3Jg4#o|WBe@fbtr zM-xaK6RE>YNL~}PdSRn5@3zIlP{)t8uq{QOym#r(UO<<86%hUV{wbLLiFy7hn6jYg zqW=Uw|F5?HD5Cy%fB#*!|ERA1<@}!*|37X2H?^1YGt7H_%o&0S=kiav_C`hmBwi+J H==(naO(y=J literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d25d78911782382055e7f99fd9c748a5b8910993 GIT binary patch literal 824 zcmV-81IPS{P){lx z114aHEKi&#{(p&eIvsnjt>v~Wxvi0MjF28RfNdux;7o8G$18*~jC=N?*s`CIa~!vQ z#6(t5P@u_-Wh$rHyz9CThQeYtmSq`&AZ#jt4LxWvfe=|Lm4eY|gxPG?^z!PBClF0m ze2%<>$y5YU6d_5{rUJ-1!}B~_Odtf--QA7V)m7Bi)~*Xl#<>f3aQv7XA0}V0Z&g(X zu)MsC{{DWHm6iPpfUvUlpUbAubUKZSiV6%23^1^+t}gWU_F`lt#O9BjXhdDzZ6uby zVs_>g`!+T879H&!XlQ6aBoaX&5P-wsU|8A2$7Pa|1NGDA^I>{=8Z|XFXl`z1S$B4J zA|8+7(BXR2UwH(qtl)ce33|Q|2W@qbQfc(v??Y*6Dco*1tX3-~CMH+_YKt`DRF6fK zn4F@~D4T3*YJ%VI*HpxAzpMyL!ueC}DA{d=d6xw*$NCT$^?*}ECo63rb2^SQtrkH-Ux#ln0hC!gWt`!SdW5h7>C==eiM zN@gfDpslTq1s)6rF*rDg>gsAnO4ur?Cga~rPDW*GZEa;>dc7WJPM^i-*c+U*Uqg%2 zkMXIeco1x4zUAdM%+1X*F;UQ@iEuc)p{jZ1xtnb(5dbMU6bhlIrw55d0t*XaEG~Y6 z>*!7Bk`nGczr++yMiq(2<8ZlL7#bR4W7<-giD(l1)76I-Q2?|xR5KYw+`9M}-q$Vo zva+c3Rr!NK)_@Zsl{K@pIT2?DAn*cGQVJ!;eJE1M!{26<%FupKYl2j_&tEZfx$G%F zOU3!8d!~Z_>43@omZ@9so4mE-G=II#6X%Jy%YFc-DoXCSdDI>N0000H)_&ENU4Ygg^Hd-c<6t?t#m`iWFkmO)1$LVF6qh zfng?_$)ip3gflS5fD47`WvGAP4+GXi)aVXv&-H1%Ixt?dYxrT3M3buJ%KV4_2h@;SO z9=x8<{fqS9l>UeG-<1CC^52yHZTp{C{$2XN?ebrc{%70&i*fuPyZnD0$A1|1zi&G3 zBm^PEo|cdA2;WCu17J*KB!u8Ri2~oa06}m;QKbP$wTm#9X{GO(W2IOa`C~Ji_=DA_ zIrcX6R$(am7X#U(szBKs9K@5CR~KqCscTk$n)kSNjueh2gY0WpVOV%JCH`nc0KZ|i zC_p-uAWi3DG8h+ljw&TdK8Ch_=rFa5fhv+?wJdc%zZe(ntmHCE0VpXWhohny-ZsZK zb*RkHDC~MfK9^2bx*U5fQO}k=?P+>?#^Z7)5ELa+hg+T0pepL2LhWv#wizywnNDLk zdHyn}77yEks$ido%UzE3KBQ1{x%-A-x!aaB1YLA7FhH^j)Dfe6${`7~9Y91X)VD$S zH5x-#G}VkQA}hADNNk`6jma*orsAshDXAYu`PYe5+MGxJ_5MRGc;=1fYraBy&|vj- z`V@LM0MZ8gqqg4`^D;70>OFT#S_m%zIYWA`AShCBCQro!P7<&o=-(kX2=A$UwUvh$ z3Avv4TNpUO>?x_Z>faI=q+-Xo_rlJH3>i-r7X2k&h@kGrTqw_g8%PC?JC~?BZ&Awc znu!G}>LhPO)yOF-7FV4R0P)G7Gi7sX-aJW+I%P_#WSj4lw*e4ue} z;o;{ocWVI@s1TtJ=SsnN`~BzRX`e=x9wl67t`T{SyB0jEF1pI5KBVTExT=wFKABz8|kmFc->dmBgSx~L1B;ey-?{|TrS!Nn*33pCZO@!XTl z&~q*cl(bpPviB)DbBLuBhkKs8!NOjlb_M38AaGJ0v189a6Et6IjYK5iU7o>*m70ZH z5s2@F8Y0Q6)>>IKfY@l>=kb#R6ABlfnBpRaq8tmcnNSb?sGfKOG&`%v#0IEm`Gn6U zVJ^=2(Q=0)zF%{Ar6t4N(Kz&x8|o`E5;2<9$q`5I=wog>r6Q?^=>aY{`AJ3QvB_|E zWNZceJKGgrw&Lk&>U}FGGfAjNJgpI?YNu*cZ{~%|N-0$4ix^P>tMb%Jr;6AsxGXn4 z&G1mQ>x5!7A3r@C&z7AyWx%F1+2F}fisNV9`A-6OYM5DtWLJ1zk$G{UA=M&{b$95& zoI^=wqibdc>e-KzXI4)3inw3yT0612I5&na5WU(TRt5WZY!7j8lFZ*VN~D?01}sXL zBBZZmP=i|7x$8K%OfDV-oFy^Wj4<-c!)#K1r?2CH_IQ(?oQx0@TpOgTZ|S>r6{>4w z#41n6R4SK5r@njdR}-04u2rGYO^Z%`({7hk&cKW}pNVZ}R}wK=(zt%RZ4g)UKDtJ< zwYp6|RkMuPhK>{Nx@}}=s9<;3jER}K0b#s?7Zr+(S!@S2j!mcw>*?-9=A!%Uv;AUi zkanf6*x5?UpXAmuU=yy|5BCRjM|BJBFD6xZ=_1A|LBU>a zS^~wg?{^y}J}cYX+X;=Ro(#xslX)Xgpfq{^EIkc`lp~wZd;4@Bmtd|WKidKOHAH>+eT%k zrVe)KdawYtxzKjr&Fjxr=&(KTyURTMoUJxopUL z6q{L9?^I0-Z8wNvTyFFiOQ~Mc;0JeM)acrd0?1*9e6!%n1+w8C=;C)E}GKgWzxtCj`Bl_#Fp%=#;h7? z(6jai^+YuwD*mKaxb#$1Lm?erQC@LP)3OdIZHFyAP@={DiptN)snK3MlExO_T2Oik zgM*I6qZ*mdI3;k^|G|2@KT>^?%ySdgX1US2h&6$L^^+@t8ez`-iGaXUug`9+tY4+a zuTC^Qi`12}6hGaJDU=3(dztfzuZ5R^%wM9ib9sFflkyye5k5I>ik`UsDimJ~>>Hpq ztjf>7J6%dL3V3k?LTApJ`-=Uv>7@de{#<#^WSt0l`|10((M45>h;PnZ60X`3+CT~r z-@>|cEkEBF_57EpQ#gntzcr`rJPWYi?7gAu829zfU*!~57zC*LFnRuujDCOCJh)8z zk-ePnk4)J|1mw%qW|Y4U{Yo?24s6I!pKq*NAJ-<5VjM@8rDVqb62)V0pj!pi%uCrd z_4Q~vv{)%i+IFEjg||S!?U*77#Y7@3X`}o1vaR(LNNO66@9p``H_;c*6wD@YN4t#@by`*+Vp?idz;&#FGV=7FOs0qJ&z`)^ZQ6C-Y%n_SgavYMAN01w77U6q!O| zw?FqSfuv9$AtJA0-LH?#!cgyCD_;lTPU%%0McH0Mr!7c55@ ze-$pyYIp8~GuGR-wzjV4IylG+C~n#C&w9K)`m)ygk%oxEu1nI3%*oL0Y5Ce+J5ikYFL^fh9X43Rz_*pch_6$}sUB__lYce|c>sPQ5+gYMgQ zbM@~swVLoO<6D~TmJ-RlcCiT^_gbDkUiscgeigpH!n~LM{z`z9j41yeA2Ko+LnsmO zyhCo@*SHSancvb}(xClEDCOG-Iv*wFK{leyRjYah9ZQopI&kXnDo@Q@T9{v738?Y= z8hzSuLUecgiwl+@iqhkzL+JFhxcB2xSF*>ag@Fx5u_&dB^-gm~25=0@ob#qMNz9vy zc@aA;E$!{HZOu^E?A!B!$oxd{F(GWo7`NDr)Jez(<@_v%cDblbo6iUEr5Hbmohe?s z=R$j&UWnsYyl%#L%UWCc?7Gs4sUsG@$t#V3L}KzJVqKOsoTn&Vf#J1U;trhLn z^znT~)=%mGNAs;%Ky?l5k)|}IW9;XqYd2UG?AYDFN$z)xEqphtQE#axa__LVH1?`^ z)7yQ%k6Ahv`C^92fEi0;*m{beWdy-D8ABxTAiWfXz4Bcs*UH-;eWCYbB^y@%`ZQSd zPMM<$*p$dyeNxk%OT=S$*iKuGpxlDRpEX>=8O}rDgw@RSfyN1J|mGsi3&>^SVURm1|m-=a0W+W4A zC8cU9jbe-vx07xRcb;UOX!G&;`(FvvdmTKvUIa=Zt7zPpX4tIvetRi8htTh-J!PD5 z6m}`?G~SBAmTb@^K8?0K&FYlD|UB|m+ zB8bx5Xu?{=NtGrUI#zm)ZrZ^%^EHv|n}|Nv+YOc)1=)ut{j1`2d5HdbCzFHmRQ{D0FrS<%DF;%^OEOlVBUR8*xr3tv610n&`C8|8EAF3+|Fk~ufQ5YeVR40{X(^}7V9XXp%#51B*`TS=utsX{wIT*J7V zZWR4@)Sdr+hUKssHe{=unj|4DjlPQ4UBio|L!hr*jJ#L7FLcbmWv7xZ&Cba#l)ki` zRL40g&+qvP!om4_$uci=K+E*Z{qj25MQwhnSt5Xl{ZWbiXEVd0H_?=jV3S>GL8MNh z8-XF3o-C)7?^&?kpGjWS+-N!9vlis+R{1p*0zU`QMs9kaU zE)hb;8om`HMR_p+AgX+-qtvZyUGNnWC6I}A&mHuPtr+{~(?fGoekB#q)XwLG(E7bS z+MmMt7}b`v+L^CjeZRqnO?HJ!qkiWD(N?zSDsv8Xq$B8cMxEYVHnR*~5_T)i_UXuN zTg3u;_j$h8k+`k=uKTn1k`l2vJoE*d`(`Q2I^k@o>`C@3%~RcPFOJ?vMQK(~zt3_b zGD_2d+U_5Sd`>Kr4Z#@uXGkAwh}jAwQ$vAJv24WQD}2^rG<2m!p*Tc}+u))tr?)8%x&DH3Vz0Lux#87bVyj@C<@!J zc6k9xdm93u^NzS)I`z#G9HjDB(=1Ob8^#miAZKpF#~v%FlS#@IIyI>ovt=43^6~j} z=IT4Z#ZwOr2Z8-%PW(m<72&x%v=WWym^DO*CU4D5w&y%x|!c#ou_vB`ozArb8s*iu_NSuUdA{9jj<6f# zUiZ4*-ztKKt=hdXFi8byAn7-;DIUdxu5{ehAxarr$5PvUec=%e*^le8ZaT42Z*&`s z#-3Y<6a5oR3i!AD2R!YU+u>&DdZ2{EL-(*sE^5tuV)rP}Lc@~beC@UYr5z*VQ8M@o zqVR+GTmI(LTg0+35}B^k0G`e-VnB;y8!1X1DoLL3xtZr_9pn%~9?)1#>faw7O~l(< z3|1@2anIy>@k)aM=Q#}WWN%xp_KfZkWrtbXj-Aw@yEbSt=J+l#shBv@pTMyPDdS2Li zb}_@H0vG90DQ!|LovQsk6Z0f`9A}hi(GjbSD9w8gb=#Q{M`IO{npfEmJPk$BvqU1) z&!)ooQ{{2vp&K~if=7i(X2K+pt@?%r!#mdO?crzwwqoX*j>P#IV{(FRe1hKM%gzn*ixgjon5N* z2ZfTU^OqApHB5-qmJlF}TqtksERKNtIZ>GS+{uJMFUQc>R$K@X&DF)FGFWvk$?VPy zeq+kT)z$osPK58o471OiRo!*Y%`W!?7LS~tWF20kh9yGD7|Tr$VZiJFD9pFDwaQerY| z=D|#nY<)x#vdPKpHOb>bBWjuMs5@t2i1&fTkMR^2VUM$A+O(HipI=mcdsK#CcegS4 zseyX6AJScm%xXI!N{$9K$LS#gFm@@<_E?TKeOJ4~k5bA;TbEJfHvwl@RCJEqk+%w^z2#CQ6b z3t=+jT^<}sut$t%1G3p(b2VxJ+?6X)&1Xh(KiO$>eUxS&)aL!w)Dlh+_aK3=@P$2w zU#Fz1e-|+&#D?#2$X#hi=+B)O3&@ff>~Zlg6fEsnzGgaY}9}&SiEUzLuae^^k&=EL>ZmrOTSJrF|y@v_n6r0`+jo9 zlJ`gl6ZGd@N2w3X*QnU?`bo6;Xg~B4seReMa~gzhw^_+Mn8n6mE43=LQ&0_r>_>XX zgMXZI{1Y8B!o7&sy7>*2MNI>YItzy(5*6@4P;oB{M~mNTfA9eI%`Q z^N(Y|Qx;m#KVZC&v-B!%qw1?k3Cy8EPmFDsr=%`}zE~;px~Ph}k8XJ1+|Ru{hT3eN z*%cZ=?{&P|%-QQsAlpCLSZM(m zKNr(ZvTI*8;Ng*jUv^@S07$G>b3qN&N~&(dCmEbRof4EyJWTT_vK~cxDLU~qzdh69OpCAWjTKhFDqm) zZV!HV0825CD_<>&Az|95T^vMLhxBc4@Tq@Q^%o9s%;R*(A3sAHzF;rJv$}ZC-8;bO zbi3~sYS{O=srnLG(hF78bxNXd!c- z!NHf$vl~{V)k)1hXj?HQkDjJ<v?5zW0 zO5t`Sm(Z^u`q@Qc3>Xu^)t zEY@Wa(XZv5?Owe?ZPl4>FK-D zd{%NM-sTQ4fr29PXWd2|^UwhCPr1bb%6@)QQeKOFr_c%rGK8C73x8BQBBoeuI1?pO zLzh5;BH1f;H1S<;eWA&ANn1`>ZWDSG&;m#btTVyrgdu%!q>AVQsrW!Mqun4_fAHbRlEGr4GU6_Wa3z_& zW1mN^FX42~Tp`2n0X`W6o!*Kp;@NO~)I+Sk+$!OC3_PuY9kE==Wl8x5CL&1(hFS%1%p(wKL0?VvvHOnN4=s#k?=9w7Nu@ z=Bg!QzG-;2IVH}Vb&OI20070!ZQ<_~bdHSz=U+1o0{TrC1!| z8>C}p=Ae{;LXZD>W%Y)puTn{ttSb$@a2&3e!C$s7d?Rh911N+t6&fXRT5M$=J(}iK z*)B_MF*HUfZaX&ZNZp4*hQYQzt7Nb{TmkkYset*{Z=QLY&z?s};4srI@Zer~5N3FNJ z1>=z+DudhTBQmla$RP6+->=~r`8(`Sb<2Ei_m?L8>}fvo597q~%u}H~{J>Rjou4zF{=JPBa>B#$Y->uCUbVw^;&^MfcEE`{(A{2kBAE`LoD& zcd?yr*O!%@coF5Bg9(TbPq*Uap4uqys#CC?1pn*(PKCM5VK})zgO6ex4{NSAh|vUW zI{D?R#e!PBkmSHpw#_tyQzN$YE?*>Xra5FDSk>5mJ4oPS4%*cQ*6%YPfr^uNk!2I`3`?W?eTAv5{oV)EkgZm-I2};g zZ-O@mG>&+5)Fskh^s%yyRQZvp%H|TQFF(&TBx0+|<6nj^LYlO;LMIi_j59r7X>n9E zCA7hf6===ku}?&bw%uv<8;K8B7+A>UIhTCnJpRxCyROzBvq7TZntg@;Or}?nsvYmI zdqdWFIW#u^U5;%`r(r|$p^22oE*+Q42tpA3$mw(H0j;8O^bKz%1S4V2M{xUcyVqDj z>kvgfS1$T~YA%)Mxi(+07Wx@IdPI^ulz1CG7EX?$g5E6K3zoMDo-90mB>pZNew0Vo znx}jxS3U9)^m>t_5Iiuro)KZ&&F{>Uv}0*NG~4Cpv!Ej^eS^d2Kd7fItQOP>a*lWj z-IMTmI%}DUz@}zkW*%`1`nywwN!xLPE*&>gB+6|8Kf;uXt4YJvOw@7}NhBMZ<5EeS zzG|I;rT36Qz(yIxdB6LrpPH&)dpM($&ko9zvUoVEM^4CyxvVz5pGV~;Jv;FK@K59j z@>XxRV>Q2RV~mVlWam8gk-v&U|E)@ef9S&$`L*_5$fv8Yus_K|ckl6vYXv;$E9b_M z6|wPS(XPlPyNkH?c$`W)Oh&J~60*Uk*7L#D3we*)5wjo%XWk+*7eTvIn%gP8>Hfta{G0(3q&Fb)# zPM?;NZKq*%8&_Y zhK~(<_e*UDxx`8L880mRdwWy6LrHtVyoz%nfD5eV4QGOVc;6w~w8^~JTl(Xr2FuZJ zUEabZNZxHC-z^=W6_w!+9wu5q_WT8VuG{Q4nFpc%gtbXzF;}Bo^)_%hcN?n7i0oJ~ zfk#vpF+8E2RK)d~jK^`ErQuYgL>W&4{r7C?Jz+>y)GC^T zYuiqo;Nf;OTilYNo(JbWD1+a%_^U>RAVvvFfn%GmfSZ+HT)MiC?%*mD^HxN&0@v~% zO@tD+4#78aF54*$b`KFPZ8h`e4154k_lmO#8-Txs@{$eEZ;qi;&X#rTxC*oE!^7gq zL;J1K>paX@BHI%D0o{Q3z@21(5#!IxpN$c|;TS?rQ~%nO(yQ}vaLt=|C3qzD38PYq zNjV2TrAd`PhKC7rzI;soH;2;G_4($T0y}@XGA|FA;lMm08?LgFQdR0g4r&Mg&wj)W zZf;5#6W1lYEo|xXAC%e|Y(9UFGmBDlhS*wL=ksDZS4~OFwzpHi!yacFf1^SgscJ;(N1fTPg!oeEYT+GzYZr z%vWK0@jnFZ@H^Sqwq!aGokRz1wW3FR9Gysw_^SreS-RimdH~D z4$KCRgb@?@1VYe^1VBt$e~6D2Rvhz`zjFgl=E?Ze$$$C=y(xGpP+lpG!w*Ornn208 zGUL59zQ8B}^qNL%z0-^Yrm-4UKC&65g6J5)v{v4_K~)^?yj~|hVq&m3a38tka+j8? z=<=_%E5w4|$&JKfG6k%UA&wls%`hY3_>(#Z_DtZk<}=vRMq0TQ+fF1z)KTL|1n3vEs?rfcXl>VU4(TftahNR=SUw+ z002tzv zOUD(o-l7}#-f)1m`?kuYX!EK}mxa4WDPBi^wD@F^i;kO8mAudOBa(Vn4r|X8cKO52 z?iGgbvpYL(#s&A#s=*gsSv(G_Rd zVHGCPR(kQN89eK?0ac&O#e?AM_e0jWjf097X3t*{~tsM1hL_?s&T`N+0PF51t~bO(AOe+VYq zM&pMo?(u(s^XX(?W-7U&YNy2=n4(RAE_7#$-Ep}OW-8O`VSi&91PVKD=}p;@xGT|~it2WG9!=L}Xa%;zg}ruU2(ezMxKwHcu4d^E zZpd6cA{7{AG#!XoL{m8YV6QSkq=Y!&Wl;spU;VS>WYp)m6-uD~=X}kaC%d6S7Fl$$ zw0Ll9b{*0G64~-xZkKO0H6J;E2vIXJf#7vRSC4 z*Gf(UE~%n~fOEq7e7X1HuImX{PtxlyO&(vyb;Cwz zq%!mY=vx`CXXhD%^hAWIyEhb0u4gZPhCD1FUmxV$kSA&@~f! zu0n6mS~iBqfb@C3^(6X*%TBY|eylLn<=-@#Ac0BmnmCCfQFV3gVEfqlv5bCWj|N87 zSSr{LJuY#bo%oz_Q{>hq5;hNh^e4MJ$Sh&FNycA5WB#Ma7`9}Y+|k$Ce|UxO*Hr~nYiBr5 zuKh1?%{MUgqD5~`>14^ws-Ko0VX=rSeM`Ga%%p?*MSdLqx z4$GFl6qY{F!j~t)lw%tWtiYJs)>7;bJAfB32`VRW;-SSbME44Ol?_f!4T;dKyw-uuQxM8J*(Kyl^n1p zw4eieM`0JZEpj`2^$EY2Cl-v%+qn2uylGvfey%1(CwaI&4l_+i>PEYBe+c-Ofx_*( z%imYmG}xpcHi&x!Pu)bOhTpVojefW?=Qu4WB1EFShzccUdD<$fC7rwAgp+8Pk$)tN zsc^Q}*OoT~*4^1=KJUHt4MFNa06eHy>X0WPr zewVzk2Uw<{i(*O7Z=%nygok2Uuyxx;?3;y&Wak84cd~ zi7ok;9Vo>!n-#lp=F&c7$Kdz_C1RdQ8TVAUBbLtpGxWF<9ZVgu`3(-4_|n&ROb8{m zL0^4lnf})!H_w76f|{HjrBXI$DhBR32d@I+rw2G_w<;*E8PZDtv-ufC5*k$(aSmbL z3!dc;N4#MHs1v`ljR^6a>dEVeM9fvBNDzXaHun$%3X1f*LP}v{-XXoR{@FxiSpyo0 z3QEcRJSIi0vTlc>46GR#`%Fawr59dywq|hK!&W?VXMUI`b4WnZG!H*bMHEnO`~fPmBum`2ici>&;6Zq{^#5o z4uB1(XmB`XLjsn7C143y0+xXP-@wdCZy=M&P7|;UHk%C$%f0--48zeE!PxhTp$dE3 zj?b|F9WY5X$8kK^()@{Y0mTnk-8)*j^Su>lj~j-X`Z<>etWGt;h9{yC8Wf%Sbw zg-ai6;A&+t`^QgoE`d!*NI+Rx89Y2ZL}#BDoOxDIVw`N^<>iHff&%DtIy5&oBPAup z-XA%;4CHQJxRUc7yySi`ZLec~=4a7Xcz8G}D=RTQJ&mfWDg*@uiQp{D((6dT1XchI z3=E{3&CJZ8p`ig`VPO~^9!67BBb{q98R-AZpY$L=84B#~;L(F+tSsxq2=@2)qqnyg z{{H?bEiJ|D>?~SZTBsuyi-lf?>6>w;RRT+lwYRrpVPS!$)6dTj8jS`~QBgF-_wM}y zvInmUT+H|ip{h6-Hdk?m^MwBXUF;gRsU0%r=H`%=mPVbet*yn*&JJ>ObD`C0(bw09 zxVShfFXxSNpN~+I8scqvc{wU7D(F4EUXPxh9&~keVQOk>F9R%f%wjPk^#H#BK7xm3FmTPHSU7p z1FKZLj>Y8(T-UsdhdUc$O$ZVoBRM%4ot>Qs4i3h~#s-v1rI_V^L+>jIVN!Kq~t)@10(EMaAc&WrlPR05Q&M2^!M7@8d_Uh(bm>R zbt#iER2~+-rCq&r183h%gqfFxjCbgZn;&3dO$Uze&u0EPabB%fBR@YMp`oGF-o(TN z>g(&#-Q7(CaS*IiU`J^PgA^J8Pft%5?`3CaQ>l=`bC>&|L_Nf}G+(&dI5gf;L#w|H zR~I>qd>4t0jm5yg02B%ZT{|>1gu1#q+C9YAM_w*MpMUPQCcp~Koun@#BZF3XO-&7b z79StKCnMku{|NZGy$S`~aO1~f3d@qi#Ade>85v1oNhG7Aqxm9D5@CfS+ZpVk5#BH2 zj*f(cgit+?kB`&e@1MU0b;9S++%87nuQlRvETmYaQo+Z^2V-MnqU^|Z7~~5BJL=nU zBSG(7H&1p0ad`cC_3^tf ztWR_vGWS{1e(Vw6Z^$_X)vC#2pk2dev!2i=!La~8Hsh=%y+pu*F`1~@Qvob}=1ag5 iummgtOTZHFQO7?(&M>uf#6F4u00002!!{EP)>3PXBcJ%h8b~`ZnTS3(a?&IwP}5<8!d?~qOtMOh_zOUTB|5(n(7~J+?crT zvg`X1AN4^swoqe4XzE6dE4tYlTWZuIR)ty?kVg?j7=)R*dwyp)vv+3D;tERJbCR1I z;M{Y5_j7)~@9%fc#Q;EsY;87!L?GcvI1-M8BjHFm5{`r;;m9W8NH`LXgd^cdI1-M8 zBjNnIo6^+{)G5!-L>;>90sFHzP3h`jo{t^X`m==NHA>O*`)gCzVchf$9F$I{lW-J$ z2s|Da;)lPDgh?y$-B(AwzNFLta^bXRD_B5fSl7jiFGoggY>O!Dk57n_%$hP+4*rP0l(tog@JK%K*{fYhDdufS{m=cs2K76jdQ*}0(Nq8 zGRn%zkd~GPo6W}O@pwF)0!itZ?4tNVp_NTZNx|jImyw&Bi@}2jBP=Y;7ZzHWB3!1; zIf&>!ucEoWhJ$f6-pB2tfBS`$@SMoVNNn1)3Ab+D!n$?q(7ShUA0&bwxB$-t4jDmE z$S5+7j9#%~1#aB9fxUb8B0fGI&CSicuhD1>7G|Ay`oziUh#xW;O?6dlKcm2?GorSt z7!`L4+5}3??RKMk_wHD^awTrwyoo(~_F&+^f&BM$i3NM2_hZ+e{vRkbIT=A?0NMMXt?jJUWsSS%Lb9K(hU!@`9NF=WUP9(K2H-{!W7r0`P-;fZT>v1($X zp+kpa>C&Z`F=Iy1D&4zx4;dL5*s)^=3JVK0VWhH`31MJc@Y3KnG5y_-(cDl6g$=1= zm+E%GYL3LGSqt#hH=ppnDtk#49>2M10cOvh&4Kjq-=F`EU^SDOj>(2}X||9c0hdt5>-`9XxoD zhxp?FY8M;GrdZNtTj7ySx9>K~k$RD@f5 zA~h`!QI1$NHPkX7F0?#oWa6iAkI^%cb~fBaW5bV#Fgsw>hoWahG%|8iaVGyGA7Jgc zWS@2sqNAfRYt}3d%HeRp>2$(uHe>DDwOsqiJ}uM^J?s*71-)!lRTURnZCpQU9jxI! zQE=rr{(0vb15krI6$KbE>|Kl*{yv%-@3Jlmtf6*vGdbX5(5$9Na z8!o~o0YEI$-JV#7SD{}ViS$+=`z?_^MY~H*Xr%#`Ls0Tf48!>nKA;ibN&fwHC z57KiFa=RYD#sK9P9>=bnxk7RL_E>=)L&Cgh(IU*5GY3|yHE13Rw(;Hv%*^!FI9l!TZXEUeP8lnLdD4e-&%hiK&N5XC7iQJff-s{E z-3wqwrm;3 zjvX5WiRz49yLRznu(Gl;=y{luDv(Fr&7v6o39S3HO~fP#8=v zAno%CrcRy8p^z2^&QFCQEz{k* z`t|EbO-)7LzJ0NJ^=cmDqH-1hyhLV2oRA4G{S08o3mPa@NrCj9;LchKx|dg~t5^H^j3;=Z{G z@A!S}Wc+x)4A;uPd}wCEuknMlU!~hx{X%}N_y6h;cz7=1K>qWRgd^cdII>AN5{`r; x;Yc_Vj)WuONI0@dI1-M8BjHFm63)-0{TGxTTR^DhH2VMm002ovPDHLkV1nv_Art@r literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..76662ba52fec4d1a7d44b4e601bd50141ba8fad6 GIT binary patch literal 2849 zcmZuzXD}R$7WE-my$hm5Z_zho^-grr7Au5AFVS1HwR#r`K}Zl~StX)N5Mp(^VX@JB zjm63mi9Fx?=KcBR{kZp@xpRKpJ9FmDNij3gqoL%WBqAcBG0@kxxSrs@i-PRBTTvx9 z5D`)C7-(x)hG7qj0A`l+EQx=jdzu<#kvr|OnTBupD>Ff#-7N)N3exQu7DA~E2Z1%B z#yUd<1(u8iA9>%$(*bFT?i~Ue@9t;lqBsaQxJcKCB_-k)y%#&S(S!?kj+>;^=>)R| zyGtKJG-2USap1BS36WC844hqn7y#CMA#~di$=EFaFPL3v@W1W9AkEBk9wR+wm9nou zWa}=+A1^x-2=7!#7k;C2{gM(lggRa2nNB3Z0Hw2hr?Xig;L}Um3m{)iD|1AT!TKU& zz#%6>q5p}v&aA2My57TIlr^&^YrE_AQKgH%dD;7<{vI+*beGiTGBKJpgQ8%vp?CcFidnG|t#3G6OAd?*Zt3 zrbW-xMyFfCkUCCgxb#5T$S_2dCFog?Gt~f#jd%|U~#(^_qdgn70=WRS$DM*2#nSl)wrb+ z7uRQ1@HJOx`IC)f)(uxFDXA8pZ>$fhOa>3XwKYW&e1ndbN}%tI$7+9Kd@5B}(YhtK z+po{-JJ}q4dbFe>0qJBOFH#s9eJOR(6l7!tGIG4da1n@4PlgIAq>*6qu}+po$kFIn zJvxMC?GOwG6A=?zlk*rzW>Ac|QZ^}8jH|8|3ARSLJ?ppL)AHMW5M~$_rWkTC*Qeq! zQG5?~c4npYeRZ{Ok_meNVw2R^_H9Z|ze>H)F6_X^%n9F{`!Jr$o+bMcHuGu-_zj$u zmX_8bQD`KjnpbBn{^ypWKW`?FS|$~nq|*j6CyJ=0aUY~FcE&Ka6WAO3mtq87MAET4 zS;plH?)S98o1y7rJU&Rmwvh;K(|}k(@v-^N{9vNage38?ZZL0EKaTBFDU7X(4z;66 z@23#>kb;k%D$`I5KM^3`-rD-2rlZsuyq^f^3VBLia4%4(yQk-Zz^9L4Ik3A!Z_b~< zMlmNNBU7LhDj9*_c@t95{h~K@x1hK<9q5H|ntZJiwG3Z*VnaoFveH{-W>k1#T9Cor z$igO@WmZ=1a?`t{$4C<6^Gk+Xx%&XOF(R&lUOPVzqvla9cDv;&U%vBEe#I3G2G6y4 zFws4y<59JQ%6x!}8Nd~gb|TD9U$S^6l~4$Xk`=K#{}DsTInKGx?gM2?O3Gg7G+?h+ zyf@-?ZAZtY^In}cDQSyllORvT$SwEdp$R=&p&s_h(#l7JKR+I*!X6uzWJ=B-HX!t# z#$6dhjWNxS4Y?_Vjb9~{mGN3xyQ|$JW*?-?RrK7iw{H`}6Ef6U6)Ngt(GBhGia;&i z$%vt~@ z<4QNU=8B}>+X>S*?kdUbM@2(pj5HP(x3y*G};w$+<`qC|;+E%Tx- z+=$aHQT~ChG{oWYmMS(#aN75Wz{o-i?kRmmI1*X0xlJ?CjY1cw#F? z_lH)ULb{iyeKFccj(N_*F|=w8?-n9`1P4W$w*u$zF|gZJ>WYm7Z*9FO0Ns1KSvRba zcUN9Wrv=^HkmO!{s7Ws^7igf(u_wZLq5Y{c;{5UwF3KC;nyt}2>=uR1wT-PpsRe9M znEw3mxPHer_+)(|sC~YxmWKK=%R(_)A}aV^`+q9|Z4_ zvw0J!nJ27o14@%5%qO^BS9_RWNbzJ^HHV=@CGy2lT5_`4?X-D4_TfK=8nrkwVjWQT zqd`gu$L@mBfuCpl;m2=E-c5icKt{UKpB#CtR=EYpmq^>&lSg4|Q%{QO&1T#m5`@n8 z71DB|E{|r2G4~UvoJL-{O}@@(Y?QV_w`A*(o!F(%{g~M=)&~_86{TIjiZf>;szC|x z%b$r<2OZ4iRB1hDqVhxdo@J1wtE2L0^UvM zt=Ji~YvJ)+HF%HNG=-L1x%#zuOQ)*CZPd*;SL?d-0uuj=?|?ev zoBOhG|NPn6P1Zy?!|VH@Jm;OwRl}U@g*L~RJ!(3D zub$i9{p^%hZ#Jl>O?)n^efaXq#B#8D0fb){r$?LAXIJ-Q#Z7nT-wP+Hc+W`#MJ%2R z6}*+ZwwTYYKHtKUEFL@%Co;D^c6QMjB(GQ1i8^p(wtI+6&ARWU1ScDh2<=8r_WKcZ z`P+Ek$J3_m=eSwq>%kcFQCJv1R@h?V+U;F7NAevw5^QE!eS5k?R~;bcSgn$B&+J-F zn?XwUVVYnQ>KCmoSHJS47=9m1*@Dhn>h@f`SX(Id%KrE}iJ(gzwg>Om)K?I%#Ksa8 z-wS*<@(79nir7A|wgj_a%dA%im?)YOjnL}Z9>$@o{V-*sTmrq*o`ekb$lTK?k_c&>g>zlRA~36IAYx&*;LN?eG!>d7T!xPBHFC(x6YQ> zhGOgbbRmdXFbx9Cb#Rfs#H#z(D85zKWjFWsmXOM?$#_JD0WK(`mMFfjO~SRds6@{YX%O_CH_IIN)uCX!JTi^- z&YE0mGo>AtdGfLruFbjF<&U+nzWJE1Wg;gx_pN6Q#UuWoS7an=#qkSlSqv)->2eAL zm-SC;;PT+iBx5Uuk_u5-+1&jA6BCmXviG{j{ci%@Ae&`WE?MP;F(UR^((4n@z;j4{ zBPBc-*zpkZHz&F|{e15A z`T4y&9)JU{fbe)lL;+Di6c7bO0Z~8{5C!A~6mIEFs$PB&m5Te{4%^&PLO2|D@LDhN zUiETwmju7o}wQos5 z2tcFJFb~`9_65_)ZWo~DwK#C(DwgT|(SPrTT=TStF4D1BAh&jtQJ&`+fYoY+kB<+w zZr%D6B&8F{zC+(&ZP+&aalZq9ntIt9MJXa80->RyOa}pxPIrMgcL2z!&1QqYzdy3G zv(eYrhl>|4$^di4JXg@W_n*dwjk_UCnqhi)7dEQ}TCI-#FE=+A0|NsnC@4T!SQyhu zy47kmyF3meDV6}pi~0Ha=;`Uf*|TR65)y*Z(b31z5bJ`X5&Lcqwnb;aGWG~MXmG#3 z<%wA`JUq<)=H=y~ySp2em6eE!ih>{r?2;V!U#FAfd38W3M6zkqCS+x0AvHA>-rnA9 zZE$c9=g*(VxpU_*G&Cd{){3yRh}@inq~uD>OpJkZsKg@#hmz8GJTi5ISBuXGv+(ux zMM_EvGBYy~85t?tymsvx&YU@e=H})F--lZuB(brv$jQk;Vq&7a*3r>{va&K%S65?l za?%+&Ui735Yu3JlgCEyG%p<65c6e!gFf`DP;_{s?ZIEtqQha>8yw=*_{7NoZ7R zXgM7M^agzU!x#9zrBL2OwV>5%VX;_b-N%m~$B7dspwsCvK0XeE!QiyBa8#%@eCet~ z(FUpo3ODt7Dn!cXz`#J(sYXUdTiFInjiC>HdM01=uP_h`5_}C0~an_KvGhYT<8@Q6{xGLb1${#7g$hG5Cb41)UimW zr>AlG@@15kmZGh#O@3Zd%W*tD`{*WC2D~Ar*k5=}AB6Uv>nOW^NOqJoM5*;sI;E$l zW5tRUY|U&ovzAU>vA4H(@m)*u7>Spc7m|~cSy57SBx!7HL}6henwpxR7F(|%PV3xl&$U&Z1y4nVxzN zi|ShBX;MbvpWkJU9zBYIiqC08qgKR5r6MK12*S)1{x+LXaOFLir$@kv zwZf@YOc$k)RCAr3ohT_OK~+^1rlzLkCL$eUJTed|y`(~u^0v0N7CUzA!1nFiQD0v# zdrvx+7(=!|Yv)+kD!zV@K~N+pB9c)0bai!M&z?POj?gS32(m%8q)G3Qf#~ux^1r;b zeED*Dx@&knzB+IVEB)4@sP+SN4YtVXG+zP&0x&i<_JmGKp=S*~kDq?$pNpt)34(Ke z##kM=0qX)bpz_wIE+NR{5`+|G(&-Mwq9U?T9ZARbeOq#&7pHmE?UQq#N9=;jJyPm= z;+p`6XTMl*z~H+Y!{$Np&f$3zO9Mg*RZf5>?_WBQ$Ac70`tzhp`7u-hQ9u+B1w;W+ hKok%KL;-o8`~zcRg8UZ=g`WTb002ovPDHLkV1izv!mR)R literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/group.svg b/apps/desktop/src-tauri/icons/group.svg new file mode 100644 index 00000000..7b5fc869 --- /dev/null +++ b/apps/desktop/src-tauri/icons/group.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..3ba4abb37da2f3574a4c3a2c7f3b298921f022a2 GIT binary patch literal 110800 zcmcHgcT`hf6fTN}4kATS5R@j;qzWP`C13-k1VQN#lnzp)N=X8uC^kS;M8KfZtMs1G zq)IQL2Na|eS_%otzMJ1UcieyOeea!d-}xhpjKRuYbI&!`EZ_W=r?YP$gz4vir}Kr2 z5QrHe&dk(+=ZMG=2n51&+wg`t1i}n{WrlFFgMap(j8MTz#kX(h-+MB-k6vMsAt;1k~UeFjKrBFsBLP7VQFPQHKMB^>C0l93Y4PhHxGZdWXY*a{sSP{eS!W|7WS9{*&kbkD~rxOX>ah z=)*1gUwPI4>-Ya36e#iEqyM*Pg&%ToXkh<8iSfU@kN-XO|Aig=f0OEe%Rc^(efZz9 zkN-n~|F`>C24llAL*)3Fk!%nb2qfdzGrB}5?}hAUjQC~A3<3*87y|JqZpMBeVv9iB zt09{>a{7BSORzv7y}NsN?@IIV5Qnm+K9wy=N@ebbr*^f}sxN-r#&!SB^SgXZPX^*d z6r4GVWrtXoH)Ds`fyGM{Kgo`Ca!t?*CTm2=M(%!*m&?lc64yQAiyn7ztd}~gCF?~A zP$DLXUb!L7d&>`(_u@=oJIhe%T5Q>qSw$Pi>KlS8pOrK=>bf^MzAA+|L$Iocy1U5H ziY{%2_aa|>jeK2LJx?kN+K$XCeB?8nc+%Wv@Xl)T=$)lt&7|)9u|5I4(Eq%Hn&`pn zO-5@h-R2UWp=VPzr*QhfZV~+;rjZ_5U^%Mh{x71e0cnF#h*JD-epw-8Iu*eSRPN3C zBq)|vGG2A(+kb(+U*&UYf1$Dn+>yp=9?Ie}v0dVZaUf|qkaYKwYv)RuXDA8cHDqEK zwN_XhWB-}wKR?y^$0S<~w^@CYz}RX0q4LqyNU&xEnE<14%&xn~AU+bAf75rIQ`FZQ z8z2&Nz9Jf>mQj#pD6Z&2%c1Ajyms%>L=eN#noZIFH&GCr}r@}D}%Fv^nOpwFb zM@dxN)Ftd&pDnC~ZDsM}c3l%XN8grgT@0 z;81X5ZD=u+gkh`TEyXWUOnpYkSA2G{E-o#p^2kn9#!}7z1l!-yMJk* z!DAXX_&x65mqJHwJ^U7_M;HI^;ljIi^Y1nz#P-xDJnA+!Z=)6~g4c_vr^MPOS=rkGIs+P0D_w!M@*ccB7`VkG*fK~aNkoxTVU)6KLWgQ})&2{I0reyu6B3otpBbXRw}3X<;23N|3mzz4WrVIgtY?s& z#{g!~UNY9LXr#$`i3vh3zU~Xkx6-e3Ui=*;`?Ct?HCHAwm)#nU3gANa2+4&2n;vLi zVbwIBiv7?9!>P@joZzDQ`XYpUB}3v&PooTHC9r}Ar|-qv0NjU0Tjsbi)m2Q=d9