├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── codex │ ├── app.go │ └── main.go └── debug │ └── main.go ├── docs ├── SUMMARY.md ├── architecture.md ├── enhancements_prd_v1.md └── testing.md ├── go.mod ├── go.sum ├── internal ├── agent │ ├── history.go │ ├── history_test.go │ ├── interface.go │ └── openai.go ├── config │ ├── config.go │ └── config_test.go ├── debug_helper.go ├── fileops │ ├── custom_patch.go │ ├── fileops.go │ └── patch.go ├── functions │ └── core.go ├── logging │ ├── file_logger.go │ ├── logger.go │ └── nil_logger.go ├── patch │ ├── README.md │ ├── apply.go │ ├── integration_test.go │ ├── parser.go │ ├── parser_test.go │ ├── types.go │ └── utils.go ├── sandbox │ ├── basic.go │ ├── interface.go │ ├── linux.go │ ├── macos.go │ └── sandbox.go └── ui │ ├── approval.go │ ├── chat.go │ ├── diff_formatter.go │ └── text-input.go ├── sandbox └── main.go ├── scripts └── run.sh └── tests └── agent_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Go Binary 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write # Needed to upload release assets 9 | 10 | jobs: 11 | build: 12 | name: Build Go Binary 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # Add other OS/Arch combinations as needed 17 | # Example: 18 | # goos: [linux, windows, darwin] 19 | # goarch: [amd64, arm64] 20 | # include: 21 | # - goos: darwin 22 | # goarch: arm64 23 | # - goos: windows 24 | # goarch: amd64 25 | goos: [linux] 26 | goarch: [amd64] 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: '1.21' # Specify your Go version 36 | 37 | - name: Build 38 | env: 39 | GOOS: ${{ matrix.goos }} 40 | GOARCH: ${{ matrix.goarch }} 41 | run: | 42 | BINARY_NAME="codex-go" 43 | if [ "${{ matrix.goos }}" = "windows" ]; then 44 | BINARY_NAME="${BINARY_NAME}.exe" 45 | fi 46 | echo "Building ${BINARY_NAME} for ${{ matrix.goos }}/${{ matrix.goarch }}..." 47 | go build -v -o "${BINARY_NAME}" ./cmd/codex 48 | echo "BINARY_PATH=${BINARY_NAME}" >> $GITHUB_ENV 49 | echo "ASSET_NAME=${BINARY_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}" >> $GITHUB_ENV 50 | 51 | # Optional: Generate checksum 52 | - name: Generate checksum 53 | id: checksum 54 | run: | 55 | sha256sum "${{ env.BINARY_PATH }}" > "${{ env.BINARY_PATH }}.sha256" 56 | echo "CHECKSUM_PATH=${{ env.BINARY_PATH }}.sha256" >> $GITHUB_ENV 57 | echo "CHECKSUM_ASSET_NAME=${{ env.ASSET_NAME }}.sha256" >> $GITHUB_ENV 58 | 59 | # Optional: Create archive (tar.gz for non-windows, zip for windows) 60 | - name: Create Archive 61 | run: | 62 | ARCHIVE_NAME="${{ env.ASSET_NAME }}" 63 | if [ "${{ matrix.goos }}" == "windows" ]; then 64 | ARCHIVE_NAME="${ARCHIVE_NAME}.zip" 65 | zip "${ARCHIVE_NAME}" "${{ env.BINARY_PATH }}" "${{ env.CHECKSUM_PATH }}" 66 | else 67 | ARCHIVE_NAME="${ARCHIVE_NAME}.tar.gz" 68 | tar -czvf "${ARCHIVE_NAME}" "${{ env.BINARY_PATH }}" "${{ env.CHECKSUM_PATH }}" 69 | fi 70 | echo "ARCHIVE_PATH=${ARCHIVE_NAME}" >> $GITHUB_ENV 71 | 72 | - name: Upload Release Asset (Archive) 73 | uses: actions/upload-release-asset@v1 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | upload_url: ${{ github.event.release.upload_url }} 78 | asset_path: ${{ env.ARCHIVE_PATH }} 79 | asset_name: ${{ env.ARCHIVE_PATH }} 80 | asset_content_type: application/octet-stream 81 | 82 | # - name: Upload Release Asset (Binary - Uncomment if needed) 83 | # uses: actions/upload-release-asset@v1 84 | # env: 85 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | # with: 87 | # upload_url: ${{ github.event.release.upload_url }} 88 | # asset_path: ${{ env.BINARY_PATH }} 89 | # asset_name: ${{ env.ASSET_NAME }} 90 | # asset_content_type: application/octet-stream 91 | 92 | # - name: Upload Release Asset (Checksum - Uncomment if needed) 93 | # uses: actions/upload-release-asset@v1 94 | # env: 95 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | # with: 97 | # upload_url: ${{ github.event.release.upload_url }} 98 | # asset_path: ${{ env.CHECKSUM_PATH }} 99 | # asset_name: ${{ env.CHECKSUM_ASSET_NAME }} 100 | # asset_content_type: text/plain -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | bin/ 11 | codex 12 | !cmd/codex/ 13 | codex-go 14 | log.txt 15 | # Test binary, built with `go test -c` 16 | *.test 17 | cloudpilot-ee/ 18 | latest.log 19 | 20 | # Output of the go coverage tool 21 | *.out 22 | coverage.html 23 | 24 | # Dependency directories 25 | vendor/ 26 | 27 | # Go workspace file 28 | go.work 29 | 30 | # IDE directories 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | 36 | # OS specific files 37 | .DS_Store 38 | Thumbs.db 39 | 40 | # Config files that might contain sensitive information 41 | .env 42 | .env.local 43 | 44 | # Build directories 45 | dist/ 46 | build/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test lint clean install run help 2 | 3 | # Build settings 4 | BINARY_NAME=codex 5 | GO=go 6 | BUILD_DIR=bin 7 | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 8 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 9 | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 10 | LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.GitCommit=$(COMMIT) -X main.BuildDate=$(BUILD_DATE)" 11 | 12 | help: 13 | @echo "Codex-Go Makefile" 14 | @echo "Available targets:" 15 | @echo " build - Build the binary" 16 | @echo " test - Run tests" 17 | @echo " lint - Run linters" 18 | @echo " clean - Clean build artifacts" 19 | @echo " install - Install the binary" 20 | @echo " run - Run the application (with prompt if provided)" 21 | @echo " help - Show this help message" 22 | 23 | build: 24 | @echo "Building Codex-Go..." 25 | @mkdir -p $(BUILD_DIR) 26 | $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/codex 27 | 28 | test: 29 | @echo "Running tests..." 30 | $(GO) test -v ./... 31 | 32 | test-unit: 33 | @echo "Running unit tests only..." 34 | $(GO) test -v -tags=unit ./... 35 | 36 | test-integration: 37 | @echo "Running integration tests..." 38 | $(GO) test -v -tags=integration ./... 39 | 40 | test-coverage: 41 | @echo "Generating test coverage report..." 42 | $(GO) test -coverprofile=coverage.out ./... 43 | $(GO) tool cover -func=coverage.out 44 | $(GO) tool cover -html=coverage.out -o coverage.html 45 | 46 | lint: 47 | @echo "Running linters..." 48 | $(GO) vet ./... 49 | @command -v golangci-lint >/dev/null 2>&1 || { echo "Installing golangci-lint..."; $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; } 50 | golangci-lint run 51 | 52 | clean: 53 | @echo "Cleaning up..." 54 | rm -rf $(BUILD_DIR) 55 | rm -f coverage.out coverage.html 56 | 57 | install: build 58 | @echo "Installing Codex-Go..." 59 | cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/$(BINARY_NAME) 60 | 61 | run: 62 | @echo "Running Codex-Go..." 63 | @if [ -z "$(PROMPT)" ]; then \ 64 | $(GO) run cmd/codex/main.go; \ 65 | else \ 66 | $(GO) run cmd/codex/main.go "$(PROMPT)"; \ 67 | fi 68 | 69 | # Default target 70 | default: build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codex-Go 2 | 3 | An experimental AI coding assistant that runs in your terminal, modeled after the concepts of [openai/codex](https://github.com/openai/codex). 4 | 5 | **Status:** This project is in an early, experimental phase and under active development. Expect breaking changes and potential instability. 6 | 7 | **Note:** This project is part of CloudshipAI's mission to help make OSS tools for AI developers and engineers. [Learn more about CloudshipAI](https://cloudshipai.com). This is a tool we plan to incorporate into our platform and want to make it available to the community. 8 | 9 | ## Overview 10 | 11 | Codex-Go aims to provide a terminal-based coding assistant implemented in Go, with the following long-term goals: 12 | 13 | 1. **Core Functionality (Go Library):** A robust Go library for interacting with LLMs (like OpenAI's models) for code generation, explanation, and modification tasks. 14 | 2. **In-Terminal Agent:** A user-friendly terminal application (powered by Bubble Tea) allowing developers to chat with the agent, request code changes, execute commands, and get help with their codebase. 15 | 3. **MCP Server:** Integration as a [Mission Control Platform (MCP)](https://example.com/mcp-explanation) server component, enabling its capabilities to be used within broader operational workflows (details TBD). 16 | 4. **Library Parity:** Achieve functional parity with the original `openai/codex` reference where applicable, focusing on file operations and command execution within a secure sandbox. 17 | 18 | Currently, the primary focus is on the **In-Terminal Agent**. 19 | 20 | ## Features (Current & Planned) 21 | 22 | - Interact with an AI coding assistant directly in your terminal. 23 | - Ask questions about your code. 24 | - Request code generation or modification. 25 | - Safely execute shell commands proposed by the AI (with user approval). 26 | - Apply file patches proposed by the AI (with user approval). 27 | - Context-aware assistance using project documentation (`codex.md`). 28 | - Configurable safety levels (approval modes). 29 | 30 | ## Installation 31 | 32 | ### 1. From Release (Recommended) 33 | 34 | Pre-built binaries for Linux, macOS, and Windows are available on the [GitHub Releases](https://github.com/epuerta9/codex-go/releases) page. 35 | 36 | **One-Liner Install (Linux/macOS):** 37 | 38 | You can use the following commands to automatically download the latest release, extract it, and install it to `/usr/local/bin`. This requires `curl`, `tar`, and potentially `sudo` privileges. 39 | 40 | **Linux (amd64 / x86_64):** 41 | ```bash 42 | curl -sL https://api.github.com/repos/epuerta9/codex-go/releases/latest \ 43 | | grep "browser_download_url.*codex-go-linux-amd64.tar.gz" \ 44 | | cut -d '"' -f 4 \ 45 | | xargs curl -sL \ 46 | | tar -xz -O codex-go > /tmp/codex-go && chmod +x /tmp/codex-go && sudo mv /tmp/codex-go /usr/local/bin/codex-go 47 | echo "codex-go installed to /usr/local/bin" 48 | ``` 49 | 50 | **Linux (arm64 / aarch64):** 51 | ```bash 52 | curl -sL https://api.github.com/repos/epuerta9/codex-go/releases/latest \ 53 | | grep "browser_download_url.*codex-go-linux-arm64.tar.gz" \ 54 | | cut -d '"' -f 4 \ 55 | | xargs curl -sL \ 56 | | tar -xz -O codex-go > /tmp/codex-go && chmod +x /tmp/codex-go && sudo mv /tmp/codex-go /usr/local/bin/codex-go 57 | echo "codex-go installed to /usr/local/bin" 58 | ``` 59 | 60 | **macOS (amd64 / Intel):** 61 | ```bash 62 | curl -sL https://api.github.com/repos/epuerta9/codex-go/releases/latest \ 63 | | grep "browser_download_url.*codex-go-darwin-amd64.tar.gz" \ 64 | | cut -d '"' -f 4 \ 65 | | xargs curl -sL \ 66 | | tar -xz -O codex-go > /tmp/codex-go && chmod +x /tmp/codex-go && sudo mv /tmp/codex-go /usr/local/bin/codex-go 67 | echo "codex-go installed to /usr/local/bin" 68 | ``` 69 | 70 | **macOS (arm64 / Apple Silicon):** 71 | ```bash 72 | curl -sL https://api.github.com/repos/epuerta9/codex-go/releases/latest \ 73 | | grep "browser_download_url.*codex-go-darwin-arm64.tar.gz" \ 74 | | cut -d '"' -f 4 \ 75 | | xargs curl -sL \ 76 | | tar -xz -O codex-go > /tmp/codex-go && chmod +x /tmp/codex-go && sudo mv /tmp/codex-go /usr/local/bin/codex-go 77 | echo "codex-go installed to /usr/local/bin" 78 | ``` 79 | 80 | *(Note: If you prefer not to use `sudo` or install to `/usr/local/bin`, you can manually download the `.tar.gz` archive from the [Releases](https://github.com/epuerta9/codex-go/releases) page, extract it (`tar -xzf `), and move the `codex-go` binary to a directory in your `$PATH`.)* 81 | 82 | **Windows:** 83 | 84 | Manual installation is recommended for Windows: 85 | 86 | 1. Go to the [Latest Release](https://github.com/epuerta9/codex-go/releases/latest). 87 | 2. Download the `codex-go-windows-amd64.zip` file. 88 | 3. Extract the `codex-go.exe` binary. 89 | 4. Move `codex-go.exe` to a directory included in your system's `PATH` environment variable. 90 | 91 | **Manual Download & Extraction (Alternative):** 92 | 93 | 1. Go to the [Latest Release](https://github.com/epuerta9/codex-go/releases/latest). 94 | 2. Download the appropriate archive (`.tar.gz` or `.zip`) for your operating system and architecture. 95 | 3. Extract the `codex-go` binary from the archive. 96 | 4. (Optional but recommended) Move the `codex-go` binary to a directory included in your system's `PATH` (e.g., `/usr/local/bin`, `~/bin`). 97 | 98 | ```bash 99 | # Example for Linux/macOS: 100 | mv codex-go /usr/local/bin/ 101 | chmod +x /usr/local/bin/codex-go 102 | ``` 103 | 104 | ### 2. Building from Source 105 | 106 | #### Prerequisites 107 | 108 | - Go 1.21 or higher ([Installation Guide](https://go.dev/doc/install)) 109 | - Git 110 | 111 | #### Steps 112 | 113 | ```bash 114 | # Clone the repository 115 | git clone https://github.com/epuerta9/codex-go.git 116 | cd codex-go 117 | 118 | # Build the binary (output will be named 'codex-go' in the current directory) 119 | go build -o codex-go ./cmd/codex 120 | 121 | # (Optional) Install to your Go bin path 122 | go install ./cmd/codex 123 | 124 | # (Optional) Or move the built binary to your preferred location 125 | # mv codex-go /usr/local/bin/ 126 | ``` 127 | 128 | ## Configuration 129 | 130 | 1. **OpenAI API Key:** 131 | Codex-Go requires an OpenAI API key. Set it as an environment variable: 132 | ```bash 133 | export OPENAI_API_KEY="your-api-key-here" 134 | ``` 135 | Add this line to your shell configuration file (e.g., `.bashrc`, `.zshrc`, `.profile`) for persistence. 136 | 137 | 2. **(Optional) Configuration File (`~/.codex/config.yaml`):** 138 | You can customize default behavior: 139 | ```yaml 140 | # Example ~/.codex/config.yaml 141 | model: gpt-4o-mini # Default model 142 | approval_mode: suggest # Default approval mode (suggest, auto-edit, full-auto) 143 | # log_file: ~/.codex/codex-go.log # Uncomment to enable file logging 144 | # log_level: debug # Log level (debug, info, warn, error) 145 | # disable_project_doc: false # Set to true to ignore codex.md files 146 | ``` 147 | 148 | 3. **(Optional) Custom Instructions (`~/.codex/instructions.md`):** 149 | Provide persistent custom instructions to the AI agent by creating this file. 150 | ```markdown 151 | # Example ~/.codex/instructions.md 152 | Always format Go code using gofmt. 153 | Keep responses concise. 154 | ``` 155 | 156 | 4. **(Optional) Project Context (`codex.md`):** 157 | Place `codex.md` files in your project for context: 158 | - `codex.md` at the repository root (found via `.git` directory). 159 | - `codex.md` in the current working directory. 160 | Both will be included if found (unless disabled via config or flag). 161 | 162 | ## Usage 163 | 164 | ### Interactive Mode 165 | 166 | Start the application without arguments: 167 | 168 | ```bash 169 | codex-go 170 | ``` 171 | 172 | Chat with the assistant. Press `Enter` to send your message. 173 | 174 | **Keybindings:** 175 | 176 | - `Enter`: Send message. 177 | - `Ctrl+T`: Toggle message timestamps. 178 | - `Ctrl+S`: Toggle system/debug messages. 179 | - `/clear`: Clear the current conversation history. 180 | - `/help`: Show command help. 181 | - `Ctrl+C` or `Esc` or `q` (when input empty): Quit. 182 | 183 | ### Direct Prompt Mode (Quiet) 184 | 185 | Execute a single prompt non-interactively: 186 | 187 | ```bash 188 | codex-go -q "Refactor this Go function to improve readability: [paste code here]" 189 | ``` 190 | The response will be printed directly to standard output. 191 | 192 | ### Flags 193 | 194 | - `--model`, `-m`: Specify the model (e.g., `gpt-4o`, `gpt-4o-mini`). 195 | - `--approval-mode`, `-a`: Set approval mode (`suggest`, `auto-edit`, `full-auto`). 196 | - `--quiet`, `-q`: Use non-interactive mode (requires a prompt). 197 | - `--no-project-doc`: Don't include `codex.md` files. 198 | - `--project-doc `: Include an additional specific markdown file as context. 199 | - `--config `: Specify a path to a config file (overrides default `~/.codex/config.yaml`). 200 | - `--instructions `: Specify a path to an instructions file (overrides default `~/.codex/instructions.md`). 201 | - `--log-file `: Specify a log file path. 202 | - `--log-level `: Set log level (`debug`, `info`, `warn`, `error`). 203 | 204 | ## Security & Approval Modes 205 | 206 | Control the agent's autonomy with `--approval-mode`: 207 | 208 | | Mode | Allows without asking | Requires approval | 209 | |---------------|--------------------------------------|-----------------------------------------| 210 | | **suggest** | Read files, List directories | File writes/patches, Command execution | 211 | | **auto-edit** | Read files, Apply file patches | Command execution | 212 | | **full-auto** | Read files, Apply patches, Execute commands | --- | 213 | 214 | **Note:** `full-auto` mode can execute *any* command the AI suggests without confirmation. Use with extreme caution. 215 | 216 | Commands are executed within a sandbox environment (using platform features like `sandbox-exec` on macOS where possible) to limit potential harm, but caution is always advised. 217 | 218 | ## Development 219 | 220 | (See [CONTRIBUTING.md](CONTRIBUTING.md) - *if you create one*) 221 | 222 | ### Running Tests 223 | 224 | ```bash 225 | go test ./... 226 | ``` 227 | 228 | ### Using the Makefile 229 | 230 | ```bash 231 | make build 232 | make test 233 | make run PROMPT="Explain Go interfaces" 234 | make help 235 | ``` 236 | 237 | ## License 238 | 239 | Apache-2.0 240 | -------------------------------------------------------------------------------- /cmd/debug/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/epuerta/codex-go/internal" 5 | ) 6 | 7 | func main() { 8 | internal.RunUITest() 9 | } 10 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Project Summary 2 | 3 | ## What We've Accomplished 4 | 5 | 1. **Analysis of the Codex TypeScript Project** 6 | - Reviewed the architecture and components 7 | - Understood the CLI structure and workflow 8 | - Analyzed file operations and security model 9 | - Documented the UI components and user interactions 10 | 11 | 2. **Architecture Documentation** 12 | - Created `docs/architecture.md` explaining the TypeScript architecture 13 | - Documented the planned Go implementation architecture 14 | - Defined package structure and component responsibilities 15 | - Outlined differences and considerations for the Go implementation 16 | 17 | 3. **Go Project Setup** 18 | - Initialized the Go module with dependencies 19 | - Created directory structure following Go best practices 20 | - Set up configuration files (.gitignore, Makefile) 21 | - Added build and run scripts 22 | 23 | 4. **Core Components Implementation** 24 | - Created the CLI entry point using Cobra 25 | - Implemented configuration loading and validation 26 | - Set up the agent interface for AI providers 27 | - Implemented the OpenAI agent for API interactions 28 | - Created sandbox for secure command execution 29 | - Implemented file operations for reading and writing files 30 | - Developed terminal UI using Bubble Tea 31 | 32 | 5. **Testing & Documentation** 33 | - Added tests for the config package 34 | - Created test documentation explaining how to run tests 35 | - Added README with usage instructions 36 | - Created a Makefile for common tasks 37 | 38 | ## Next Steps 39 | 40 | 1. **Complete Agent Implementation** 41 | - Implement conversation history management 42 | - Add support for image inputs 43 | - Improve error handling and recovery 44 | 45 | 2. **Enhance Sandbox Security** 46 | - Implement platform-specific sandbox enforcement 47 | - Add network isolation for command execution 48 | - Validate file paths for security 49 | 50 | 3. **UI Enhancements** 51 | - Implement command approval UI 52 | - Add file diff viewing and approval 53 | - Create help and settings screens 54 | 55 | 4. **Additional Providers** 56 | - Add support for Anthropic models 57 | - Create a provider factory for easy switching 58 | - Implement custom prompt formats for each provider 59 | 60 | 5. **Testing** 61 | - Add more unit tests for all packages 62 | - Create integration tests with mocked AI responses 63 | - Add benchmark tests for performance analysis 64 | 65 | 6. **Documentation** 66 | - Add godoc comments to all exported functions 67 | - Create usage examples for library consumers 68 | - Add contribution guidelines 69 | 70 | ## Conclusion 71 | 72 | We've successfully laid the foundation for a Go port of the Codex project. The core architecture and components are in place, providing a solid base for further development. The project follows Go best practices and is structured to be maintainable and extensible. 73 | 74 | The implementation maintains the key features of the original TypeScript version while leveraging Go's strengths in concurrency, performance, and cross-platform compatibility. -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Codex Architecture Overview 2 | 3 | ## Current TypeScript Architecture 4 | 5 | Based on the analysis of the codex project, here's an overview of its architecture: 6 | 7 | ### Core Components 8 | 9 | 1. **CLI Interface** 10 | - Entry point: `cli.tsx` - handles CLI commands, arguments, and options 11 | - Validation of API keys and configuration 12 | - Multiple modes: interactive, quiet, full-context 13 | 14 | 2. **Agent Loop** 15 | - Core class: `AgentLoop` in `agent-loop.ts` 16 | - Manages interactions with the OpenAI API 17 | - Processes function calls from the model 18 | - Handles streaming responses and cancellation 19 | 20 | 3. **Command Execution** 21 | - Sandbox mechanism for executing commands safely 22 | - Platform-specific implementation (macOS Seatbelt, container for Linux) 23 | - Command approval workflow 24 | 25 | 4. **File Modification** 26 | - Ability to read, write, and modify files 27 | - Uses patches for file modifications 28 | - Versioning and approval workflow 29 | 30 | 5. **UI Components** 31 | - Terminal-based UI using Ink/React 32 | - Chat interface and message history 33 | - Interactive command approval 34 | - Various overlays (help, model selection, history) 35 | 36 | 6. **Configuration** 37 | - User configuration in `~/.codex/` 38 | - Project-specific instructions in `codex.md` 39 | - Model selection and approval policy settings 40 | 41 | ### Data Flow 42 | 43 | 1. User inputs a prompt via CLI 44 | 2. CLI validates and sets up the environment 45 | 3. The agent loop sends the prompt to OpenAI API 46 | 4. The model responds with text or function calls 47 | 5. Function calls are parsed and executed (if approved) 48 | 6. Results are displayed to the user 49 | 7. The process continues until completion or user cancellation 50 | 51 | ## Go Implementation Architecture 52 | 53 | ### Core Components 54 | 55 | 1. **CLI Interface** 56 | - Using Cobra for CLI commands and flags 57 | - Similar command structure to the TypeScript version 58 | - Flags for model selection, approval policy, etc. 59 | 60 | 2. **Agent System** 61 | - Go equivalent of `AgentLoop` 62 | - Interface for multiple AI providers (OpenAI, Anthropic, etc.) 63 | - Streaming response handling using Go channels 64 | 65 | 3. **Command Execution** 66 | - Secure sandboxing using Go's `exec` package with restrictions 67 | - Platform-specific isolation (similar to the TypeScript version) 68 | - Command approval workflow 69 | 70 | 4. **File Operations** 71 | - File reading, writing, and modification 72 | - Diff generation and application 73 | - Path resolution and security checks 74 | 75 | 5. **TUI (Terminal User Interface)** 76 | - Using Charm Bubble Tea for terminal UI 77 | - Stateful components and event handling 78 | - Interactive command approval 79 | - Help and configuration screens 80 | 81 | 6. **Configuration** 82 | - Similar directory structure (`~/.codex/`) 83 | - YAML configuration parsing 84 | - Environment variable handling 85 | 86 | ### Proposed Package Structure 87 | 88 | ``` 89 | codex-go/ 90 | ├── cmd/ 91 | │ └── codex/ 92 | │ └── main.go # CLI entry point 93 | ├── internal/ 94 | │ ├── agent/ # Agent implementation 95 | │ │ ├── openai.go # OpenAI implementation 96 | │ │ ├── anthropic.go # Anthropic implementation 97 | │ │ └── interface.go # Common agent interface 98 | │ ├── config/ # Configuration handling 99 | │ │ ├── config.go 100 | │ │ └── loader.go 101 | │ ├── sandbox/ # Command execution sandbox 102 | │ │ ├── sandbox.go 103 | │ │ ├── macos.go 104 | │ │ └── linux.go 105 | │ ├── ui/ # Terminal UI components 106 | │ │ ├── chat.go 107 | │ │ ├── approval.go 108 | │ │ └── help.go 109 | │ ├── fileops/ # File operations 110 | │ │ ├── diff.go 111 | │ │ └── patch.go 112 | │ └── utils/ # Common utilities 113 | │ └── session.go 114 | ├── pkg/ # Public packages for library use 115 | │ ├── agent/ # Public agent API 116 | │ ├── config/ # Public configuration API 117 | │ └── sandbox/ # Public sandbox API 118 | └── tests/ # Test suite 119 | ``` 120 | 121 | ### Key Differences and Considerations 122 | 123 | 1. **Language-Specific Patterns** 124 | - Go's error handling vs TypeScript's promises/async-await 125 | - Go's struct-based design vs TypeScript's class-based approach 126 | - Go's strong typing and lack of generics (in older versions) 127 | 128 | 2. **Libraries and Dependencies** 129 | - Cobra CLI instead of meow 130 | - Bubble Tea instead of Ink/React 131 | - Go-specific OpenAI client 132 | 133 | 3. **Performance Considerations** 134 | - Go's compile-time checking and performance benefits 135 | - Concurrency using goroutines and channels 136 | - Memory management differences 137 | 138 | 4. **Testing Approach** 139 | - Table-driven tests common in Go 140 | - Mocking the OpenAI API for unit tests 141 | - End-to-end test strategy 142 | 143 | ### Implementation Priorities 144 | 145 | 1. Create the CLI framework with Cobra 146 | 2. Implement the configuration system 147 | 3. Create the agent interface and OpenAI implementation 148 | 4. Build the sandbox for secure command execution 149 | 5. Implement file operations 150 | 6. Develop the TUI with Bubble Tea 151 | 7. Add comprehensive tests 152 | 8. Implement additional providers (Anthropic, etc.) -------------------------------------------------------------------------------- /docs/enhancements_prd_v1.md: -------------------------------------------------------------------------------- 1 | # Codex-Go Enhancements PRD - v1.0 (Draft) 2 | 3 | **Status:** Draft 4 | **Date:** 2024-07-29 5 | **Author:** Codex-Go Assistant (Gemini) & User 6 | 7 | ## 1. Introduction 8 | 9 | This document outlines the requirements for the next phase of development for `codex-go`, a terminal-based AI coding assistant. These enhancements aim to improve the robustness, usability, security, and maintainability of the application, drawing inspiration from the analysis of the `codex-cli` (TypeScript) implementation. 10 | 11 | ## 2. Goals 12 | 13 | * Increase the reliability of core agent functions, particularly file patching and command execution. 14 | * Enhance the user experience through better feedback, logging, and approval mechanisms. 15 | * Improve the application's security posture through refined sandboxing controls. 16 | * Refactor core components for better maintainability and future extensibility. 17 | * Provide better debugging capabilities through structured logging. 18 | 19 | ## 3. Background 20 | 21 | The initial version of `codex-go` successfully implements the core agent interaction loop, streaming responses, basic function calling (read/write/patch file, execute command, list directory), and a terminal UI using Bubble Tea. 22 | 23 | An analysis of the `codex-cli` project revealed several areas where `codex-go` could be improved: 24 | 25 | 1. **Patching Mechanism:** `codex-cli` uses a sophisticated custom patch format and parser, offering potentially more robustness than `codex-go`'s current reliance on the standard `patch` utility. 26 | 2. **Execution/Approval Flow:** `codex-cli` has a more granular command approval process (including an "always approve" cache) and explicit sandbox configuration. 27 | 3. **Agent State Management:** `codex-cli` includes more robust handling for cancellations, especially concerning pending tool calls. 28 | 4. **Logging:** `codex-cli` utilizes structured, asynchronous file-based logging controlled by an environment variable. 29 | 30 | This PRD details the requirements for implementing these improvements in `codex-go`. 31 | 32 | ## 4. Feature Requirements 33 | 34 | ### 4.1. Structured Logging 35 | 36 | * **Goal:** Implement a robust, configurable, and developer-friendly logging system. 37 | * **Requirements:** 38 | * **Conditional Logging:** Logging should be disabled by default and enabled via a command-line flag (e.g., `--debug`) or environment variable. 39 | * **File Output:** When enabled, logs should be written to a dedicated file, not `stderr`. 40 | * The log file path should be configurable via a flag (e.g., `--log-file `). 41 | * A default log directory should be established (e.g., `~/.cache/codex-go/logs/` or platform equivalent). 42 | * Log filenames should include a timestamp (e.g., `codex-go-YYYYMMDD-HHMMSS.log`). 43 | * **Timestamped Entries:** Each log entry must be prefixed with a timestamp. 44 | * **Asynchronous Logging:** Logging should be performed asynchronously (e.g., using channels and a dedicated goroutine) to avoid blocking the main application loop. 45 | * **`latest.log` Symlink:** Create/update a symlink (e.g., `latest.log`) in the log directory pointing to the most recent log file for easy access (`tail -f`). 46 | * **Integration:** Replace all existing `logDebug(...)` calls with the new logging mechanism. 47 | * **Logger Interface:** Define a `Logger` interface and provide implementations for file logging and no-op logging. 48 | * **Initial Log Level:** Start with a single "Debug" level for all messages. Granular levels (Info, Warn, Error) can be a future consideration. 49 | * **User Story:** As a developer using or debugging `codex-go`, I want detailed logs written asynchronously to a predictable file, controlled by a flag, so I can troubleshoot issues without impacting performance or cluttering my terminal. 50 | 51 | ### 4.2. Robust Patching 52 | 53 | * **Goal:** Significantly improve the reliability and flexibility of applying file patches generated by the LLM. 54 | * **Requirements:** 55 | * **Custom Patch Parser:** Implement a new patching mechanism inspired by `codex-cli`. This involves: 56 | * Defining a clear patch format (likely custom, similar to `codex-cli`'s `*** Begin Patch`, `*** Update File:`, `*** Add File:`, `*** Delete File:`, `+`, `-` lines) that the LLM will be instructed to use via the system prompt. 57 | * Creating a Go parser for this custom format. The parser should handle file creation, deletion, and updates within a single patch block. 58 | * Implementing fuzzy matching logic (similar to `codex-cli`'s `find_context`) to increase the chances of successfully applying patches even if the file has slightly diverged or the LLM's context lines aren't perfect. 59 | * **Replace `patch_file`:** The existing `patch_file` function in `internal/functions` and its invocation should be replaced with this new mechanism. The corresponding `agent.FunctionDefinition` should be updated. 60 | * **Clearer Error Feedback:** Provide specific error messages to the user and the agent if patch parsing or application fails (e.g., "Invalid patch format", "Context mismatch for hunk X in file Y"). 61 | * **User Story:** As a user, when the agent generates code changes, I want `codex-go` to apply them reliably using a robust patching mechanism, even with minor discrepancies, and provide clear feedback on success or failure. 62 | 63 | ### 4.3. Refined Execution/Approval 64 | 65 | * **Goal:** Enhance the security, usability, and structure of shell command execution and user approvals. 66 | * **Requirements:** 67 | * **Separation of Concerns:** Refactor the command execution logic currently within `App.handleAgentResponseItem`. Separate the approval/policy checks from the actual sandbox execution call. 68 | * **"Always Approve" Cache:** Implement a session-specific cache. When the user approves a command via the UI prompt, offer an "Always Approve" option. If selected, store a key representing that command (e.g., derived from `deriveCommandKey` logic in `codex-cli`) and automatically approve subsequent identical commands for the rest of the session without prompting. 69 | * **Explicit Sandbox Writable Roots:** Modify the `Sandbox.Execute` interface and implementation (`internal/sandbox/sandbox.go` and `bubblewrap` logic) to accept a list of specific host paths that should be writable *inside* the sandbox for a given command execution. Pass appropriate defaults (e.g., current working directory) but allow for future configuration. 70 | * **(Optional) Retry on Sandbox Failure:** If a command executed *inside* the sandbox fails (non-zero exit code), optionally prompt the user if they want to retry executing the *exact same command* outside the sandbox. This should be configurable (e.g., via `Config.FullAutoErrorMode` similar to `codex-cli`). 71 | * **Refactor Approval Logic:** Update the `askForApproval` UI function and the logic in `App` to accommodate the "Always Approve" option and the potential retry flow. Update `Config.ApprovalMode` handling if necessary. 72 | * **User Story:** As a user, I want finer-grained control over command approvals (approving once per session), improved security via explicit sandbox write controls, and the option to easily retry a failed sandboxed command unsandboxed if needed. 73 | 74 | ### 4.4. Improved Agent Loop State Management 75 | 76 | * **Goal:** Increase the robustness of the agent interaction loop, particularly around cancellation scenarios. 77 | * **Requirements:** 78 | * **Handle Pending Aborts:** Implement logic to track tool calls that were received from the agent but whose execution was cancelled by the user before completion. 79 | * When a user cancels (e.g., Ctrl+C during agent thinking or tool execution), identify any `FunctionCall` IDs that were pending execution. 80 | * Store these pending `callID`s. 81 | * In the *next* call to `Agent.SendMessage` or `Agent.SendFunctionResult`, include dummy/error `FunctionResult` messages for these stored `callID`s (e.g., output="execution cancelled by user", success=false) to satisfy the OpenAI API requirements and prevent errors. 82 | * **Review Concurrency Control:** Re-evaluate the usage of `app.isAgentProcessing` and potentially add more robust locking or state checks if needed to prevent race conditions, especially around the asynchronous operations introduced by file logging and improved agent loop handling. 83 | * **User Story:** As a user, if I cancel an operation while the agent is working, I want the application to handle this gracefully without crashing or causing errors in subsequent interactions with the AI. 84 | 85 | ## 5. Non-Goals (For this Phase) 86 | 87 | * Major UI redesign or addition of new UI components beyond necessary prompts (e.g., for approval). 88 | * Adding fundamentally new agent capabilities or tools (focus is on refining existing ones). 89 | * Support for LLM providers other than OpenAI. 90 | * Full telemetry implementation. 91 | * Configuration file support (flags will be used initially). 92 | * Implementing multi-tool calls per LLM turn (can be considered later). 93 | 94 | ## 6. Open Questions 95 | 96 | * What specific Go library (or custom implementation) should be used for the robust patching logic? Research needed. 97 | * What is the exact desired custom patch format to instruct the LLM to use? Needs careful definition. 98 | * Should the "Retry on Sandbox Failure" feature be enabled by default? 99 | 100 | ## 7. Future Considerations 101 | 102 | * Granular log levels (Info, Warn, Error). 103 | * Allowing multiple tool calls per LLM turn. 104 | * Support for different sandbox technologies (Docker, etc.). 105 | * Loading configuration from a file (`codex.yaml`?). -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing Guide for Codex-Go 2 | 3 | This document provides instructions on how to run and write tests for the Codex-Go project. 4 | 5 | ## Running Tests 6 | 7 | ### Prerequisites 8 | 9 | To run the tests, you need: 10 | 11 | 1. Go 1.18 or higher installed 12 | 2. OpenAI API key (for integration tests) 13 | 14 | ### Running All Tests 15 | 16 | ```bash 17 | # From the project root 18 | go test ./... 19 | ``` 20 | 21 | ### Running Specific Tests 22 | 23 | ```bash 24 | # Run specific test package 25 | go test ./tests 26 | 27 | # Run specific test file 28 | go test ./tests/agent_test.go 29 | 30 | # Run specific test function 31 | go test -run TestOpenAIAgent ./tests 32 | ``` 33 | 34 | ### Test Tags 35 | 36 | Some tests are tagged to control when they run: 37 | 38 | ```bash 39 | # Run only unit tests (no external dependencies) 40 | go test -tags=unit ./... 41 | 42 | # Run integration tests (requires API keys) 43 | go test -tags=integration ./... 44 | ``` 45 | 46 | ## Writing Tests 47 | 48 | ### Test Types 49 | 50 | #### Unit Tests 51 | 52 | Unit tests should be self-contained and not rely on external services. They should be fast and reliable. 53 | 54 | Example: 55 | 56 | ```go 57 | func TestConfig(t *testing.T) { 58 | // Test configuration loading 59 | cfg, err := config.Load() 60 | assert.NoError(t, err) 61 | assert.NotNil(t, cfg) 62 | } 63 | ``` 64 | 65 | #### Integration Tests 66 | 67 | Integration tests can use external services like the OpenAI API. They should be skipped if the necessary credentials are not available. 68 | 69 | Example: 70 | 71 | ```go 72 | func TestOpenAIAgent(t *testing.T) { 73 | // Skip if no API key is provided 74 | apiKey := os.Getenv("OPENAI_API_KEY") 75 | if apiKey == "" { 76 | t.Skip("Skipping test: OPENAI_API_KEY not set") 77 | } 78 | 79 | // Test implementation... 80 | } 81 | ``` 82 | 83 | ### Mocking 84 | 85 | For tests that require external services but need to run without credentials, use mocks: 86 | 87 | 1. Define interfaces for the dependencies 88 | 2. Create mock implementations of those interfaces 89 | 3. Use the mocks in tests 90 | 91 | Example: 92 | 93 | ```go 94 | type MockAgent struct { 95 | agent.Agent // Embed the interface 96 | } 97 | 98 | func (m *MockAgent) SendMessage(ctx context.Context, messages []agent.Message, handler agent.ResponseHandler) error { 99 | // Mock implementation 100 | handler(agent.ResponseItem{ 101 | Type: "message", 102 | Message: &agent.Message{ 103 | Role: "assistant", 104 | Content: "Mock response", 105 | }, 106 | }) 107 | return nil 108 | } 109 | ``` 110 | 111 | ## Test Coverage 112 | 113 | To generate a test coverage report: 114 | 115 | ```bash 116 | # Generate coverage profile 117 | go test -coverprofile=coverage.out ./... 118 | 119 | # View coverage in terminal 120 | go tool cover -func=coverage.out 121 | 122 | # Generate HTML coverage report 123 | go tool cover -html=coverage.out -o coverage.html 124 | ``` 125 | 126 | ## Continuous Integration 127 | 128 | The CI pipeline runs tests on each pull request. Tests are run on multiple platforms: 129 | 130 | - Linux 131 | - macOS 132 | - Windows 133 | 134 | To ensure your tests pass in CI: 135 | 136 | 1. Don't depend on specific environment variables being set 137 | 2. Skip integration tests if required credentials are missing 138 | 3. Use path separators that work cross-platform 139 | 4. Be mindful of resource usage and timeouts 140 | 141 | ## Troubleshooting 142 | 143 | ### Common Issues 144 | 145 | 1. **API Rate Limits**: If you hit OpenAI API rate limits, add delays between tests or reduce the number of API calls. 146 | 147 | 2. **Test Timeouts**: Some tests may take longer than the default timeout. You can increase the timeout: 148 | 149 | ```bash 150 | go test -timeout 5m ./... 151 | ``` 152 | 153 | 3. **Environment Variables**: Make sure all required environment variables are set: 154 | 155 | ```bash 156 | export OPENAI_API_KEY="your-api-key" 157 | go test ./... 158 | ``` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/epuerta/codex-go 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.8 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.4 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/google/uuid v1.6.0 12 | github.com/sashabaranov/go-openai v1.38.1 13 | github.com/spf13/cobra v1.9.1 14 | github.com/spf13/viper v1.20.1 15 | ) 16 | 17 | require ( 18 | github.com/atotto/clipboard v0.1.4 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/charmbracelet/colorprofile v0.3.0 // indirect 21 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 23 | github.com/charmbracelet/x/term v0.2.1 // indirect 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 | github.com/fsnotify/fsnotify v1.9.0 // indirect 26 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/mattn/go-localereader v0.0.1 // indirect 31 | github.com/mattn/go-runewidth v0.0.16 // indirect 32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 33 | github.com/muesli/cancelreader v0.2.2 // indirect 34 | github.com/muesli/termenv v0.16.0 // indirect 35 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 36 | github.com/rivo/uniseg v0.4.7 // indirect 37 | github.com/sagikazarmark/locafero v0.9.0 // indirect 38 | github.com/sourcegraph/conc v0.3.0 // indirect 39 | github.com/spf13/afero v1.14.0 // indirect 40 | github.com/spf13/cast v1.7.1 // indirect 41 | github.com/spf13/pflag v1.0.6 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 44 | go.uber.org/multierr v1.11.0 // indirect 45 | golang.org/x/sync v0.13.0 // indirect 46 | golang.org/x/sys v0.32.0 // indirect 47 | golang.org/x/text v0.24.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 8 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 9 | github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= 10 | github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= 11 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 12 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 13 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 14 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 15 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 16 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 24 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 25 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 26 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 27 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 28 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 29 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 30 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 31 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 33 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 34 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 35 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 36 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 37 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 38 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 39 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 40 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 41 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 42 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 43 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 44 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 45 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 46 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 47 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 48 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 49 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 50 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 51 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 52 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 53 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 54 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 55 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 59 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 60 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 61 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 62 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 63 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 64 | github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= 65 | github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= 66 | github.com/sashabaranov/go-openai v1.38.1 h1:TtZabbFQZa1nEni/IhVtDF/WQjVqDgd+cWR5OeddzF8= 67 | github.com/sashabaranov/go-openai v1.38.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 68 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 69 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 70 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 71 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 72 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 73 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 74 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 75 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 76 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 77 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 78 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 79 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 80 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 81 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 82 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 83 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 84 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 85 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 86 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 87 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 88 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 89 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 90 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 91 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 92 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 95 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 96 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 97 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 98 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 99 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 100 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 101 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 102 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | -------------------------------------------------------------------------------- /internal/agent/history.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "github.com/sashabaranov/go-openai" 14 | ) 15 | 16 | // HistoryOptions defines options for conversation history management 17 | type HistoryOptions struct { 18 | MaxTokenCount int // Maximum number of tokens to keep in history 19 | SessionID string // Unique ID for this conversation session 20 | HistoryPath string // Path to store history files 21 | EnablePersist bool // Whether to persist history to disk 22 | SystemPrompt string // System prompt to prepend to history 23 | } 24 | 25 | // DefaultHistoryOptions returns the default options for history management 26 | func DefaultHistoryOptions() HistoryOptions { 27 | return HistoryOptions{ 28 | MaxTokenCount: 8000, // Default token limit 29 | SessionID: "default", // Default session ID 30 | HistoryPath: "", // Empty means no persistence 31 | EnablePersist: false, // Disabled by default 32 | SystemPrompt: `You are a sophisticated AI coding assistant designed to help with software development tasks in the user's current project context. 33 | 34 | Your primary goal is to fulfill the user's request, which may require multiple steps and the use of available tools. 35 | 36 | Think step-by-step to break down complex requests. 37 | Plan your actions and use the available tools sequentially as needed. 38 | 39 | IMPORTANT: After outlining your plan, immediately proceed to execute the first step using the appropriate tool, unless you need clarification from the user. Do not wait for confirmation to start working. 40 | 41 | Available tools include reading/writing/patching files, listing directories, and executing shell commands. 42 | When using tools: 43 | - For file operations, be precise about paths. 44 | - For shell commands, ensure they are safe and relevant to the user's request. 45 | If the user's request is ambiguous or requires more information, ask clarifying questions BEFORE proceeding. 46 | Strive to complete the user's objective fully. If you believe the objective is met, inform the user. 47 | If you encounter errors or cannot fulfill the request, explain the issue clearly. 48 | Format code blocks and technical details appropriately using markdown. Be concise but thorough.`, 49 | } 50 | } 51 | 52 | // ConversationHistory manages the conversation history between the user and AI 53 | type ConversationHistory struct { 54 | Messages []Message `json:"messages"` 55 | MaxTokenCount int `json:"max_token_count"` 56 | CurrentTokens int `json:"current_tokens"` 57 | CurrentSession string `json:"current_session"` 58 | CreatedAt time.Time `json:"created_at"` 59 | UpdatedAt time.Time `json:"updated_at"` 60 | EnablePersist bool `json:"-"` // Not stored in JSON 61 | HistoryPath string `json:"-"` // Not stored in JSON 62 | } 63 | 64 | // NewConversationHistory creates a new conversation history with the given options 65 | func NewConversationHistory(opts HistoryOptions) (*ConversationHistory, error) { 66 | history := &ConversationHistory{ 67 | Messages: []Message{}, 68 | MaxTokenCount: opts.MaxTokenCount, 69 | CurrentTokens: 0, 70 | CurrentSession: opts.SessionID, 71 | CreatedAt: time.Now(), 72 | UpdatedAt: time.Now(), 73 | EnablePersist: opts.EnablePersist, 74 | HistoryPath: opts.HistoryPath, 75 | } 76 | 77 | // If persistence is enabled, try to load existing history 78 | if opts.EnablePersist && opts.HistoryPath != "" { 79 | historyFile := filepath.Join(opts.HistoryPath, opts.SessionID+".json") 80 | if _, err := os.Stat(historyFile); err == nil { 81 | // File exists, try to load it 82 | data, err := os.ReadFile(historyFile) 83 | if err == nil { 84 | if err := json.Unmarshal(data, history); err == nil { 85 | // Update the history path and persistence flag 86 | history.HistoryPath = opts.HistoryPath 87 | history.EnablePersist = opts.EnablePersist 88 | return history, nil 89 | } 90 | } 91 | } 92 | 93 | // Ensure the directory exists for future saves 94 | if err := os.MkdirAll(opts.HistoryPath, 0755); err != nil { 95 | return nil, fmt.Errorf("failed to create history directory: %w", err) 96 | } 97 | } 98 | 99 | // Add system prompt if provided 100 | if opts.SystemPrompt != "" { 101 | history.AddMessage(Message{ 102 | Role: "system", 103 | Content: opts.SystemPrompt, 104 | }) 105 | } 106 | 107 | return history, nil 108 | } 109 | 110 | // AddMessage adds a single message to the history 111 | func (h *ConversationHistory) AddMessage(message Message) { 112 | h.Messages = append(h.Messages, message) 113 | h.UpdatedAt = time.Now() 114 | 115 | // Update token count estimation 116 | h.CurrentTokens = h.EstimateTokenCount() 117 | 118 | // Prune history if needed 119 | h.pruneIfNeeded() 120 | 121 | // Save to disk if persistence is enabled 122 | if h.EnablePersist && h.HistoryPath != "" { 123 | h.Save(h.HistoryPath) 124 | } 125 | } 126 | 127 | // AddToolMessage adds a tool message to the history 128 | func (h *ConversationHistory) AddToolMessage(toolName string, parameters map[string]interface{}, callID string) { 129 | parametersJSON, _ := json.Marshal(parameters) 130 | 131 | toolMessage := Message{ 132 | Role: "assistant", 133 | ToolCalls: []ToolCall{ 134 | { 135 | ID: callID, 136 | Type: "function", 137 | Function: FunctionCall{ 138 | Name: toolName, 139 | Arguments: string(parametersJSON), 140 | }, 141 | }, 142 | }, 143 | } 144 | h.AddMessage(toolMessage) 145 | } 146 | 147 | // AddToolResultMessage adds a tool result message to the history 148 | func (h *ConversationHistory) AddToolResultMessage(callID, toolName string, content map[string]interface{}) { 149 | contentBytes, _ := json.Marshal(content) 150 | resultMessage := Message{ 151 | Role: "tool", 152 | Content: string(contentBytes), 153 | ToolCallID: callID, 154 | Name: toolName, 155 | } 156 | h.AddMessage(resultMessage) 157 | } 158 | 159 | // AddMessages adds multiple messages to the history 160 | func (h *ConversationHistory) AddMessages(messages []Message) { 161 | for _, msg := range messages { 162 | h.AddMessage(msg) 163 | } 164 | } 165 | 166 | // GetMessagesForContext returns messages suitable for the AI context 167 | func (h *ConversationHistory) GetMessagesForContext() []Message { 168 | return h.Messages 169 | } 170 | 171 | // GetMessages returns all messages in the history 172 | func (h *ConversationHistory) GetMessages() []Message { 173 | return h.Messages 174 | } 175 | 176 | // GetLastMessage returns the most recent message and a boolean indicating if found 177 | func (h *ConversationHistory) GetLastMessage() (Message, bool) { 178 | if len(h.Messages) == 0 { 179 | return Message{}, false 180 | } 181 | return h.Messages[len(h.Messages)-1], true 182 | } 183 | 184 | // Clear removes all messages from the history 185 | func (h *ConversationHistory) Clear() { 186 | h.Messages = []Message{} 187 | h.CurrentTokens = 0 188 | h.UpdatedAt = time.Now() 189 | 190 | // Save empty history if persistence is enabled 191 | if h.EnablePersist && h.HistoryPath != "" { 192 | h.Save(h.HistoryPath) 193 | } 194 | } 195 | 196 | // Save persists the conversation history to disk 197 | func (h *ConversationHistory) Save(path string) error { 198 | if path == "" { 199 | return nil // No-op if path is not specified 200 | } 201 | 202 | // Ensure the directory exists 203 | if err := os.MkdirAll(path, 0755); err != nil { 204 | return fmt.Errorf("failed to create history directory: %w", err) 205 | } 206 | 207 | // Marshal the history to JSON 208 | data, err := json.MarshalIndent(h, "", " ") 209 | if err != nil { 210 | return fmt.Errorf("failed to marshal history: %w", err) 211 | } 212 | 213 | // Write to file 214 | historyFile := filepath.Join(path, h.CurrentSession+".json") 215 | if err := os.WriteFile(historyFile, data, 0644); err != nil { 216 | return fmt.Errorf("failed to write history file: %w", err) 217 | } 218 | 219 | return nil 220 | } 221 | 222 | // EstimateTokenCount estimates the number of tokens in the conversation history 223 | // This is a simple heuristic based on the number of characters 224 | func (h *ConversationHistory) EstimateTokenCount() int { 225 | tokenCount := 0 226 | 227 | for _, msg := range h.Messages { 228 | // Each message has a base overhead 229 | messageOverhead := 4 230 | 231 | // Roughly estimate 4 characters per token 232 | contentTokens := int(math.Ceil(float64(len(msg.Content)) / 4)) 233 | 234 | // Add to total 235 | tokenCount += contentTokens + messageOverhead 236 | } 237 | 238 | return tokenCount 239 | } 240 | 241 | // pruneIfNeeded removes older messages if the token count exceeds the maximum 242 | func (h *ConversationHistory) pruneIfNeeded() { 243 | // If we're under the limit, no pruning needed 244 | if h.CurrentTokens <= h.MaxTokenCount { 245 | return 246 | } 247 | 248 | // We need to prune 249 | // First, identify system messages to preserve 250 | var systemMessages []Message 251 | var otherMessages []Message 252 | 253 | for _, msg := range h.Messages { 254 | if msg.Role == "system" { 255 | systemMessages = append(systemMessages, msg) 256 | } else { 257 | otherMessages = append(otherMessages, msg) 258 | } 259 | } 260 | 261 | // If we have too many messages, start removing older ones 262 | // We'll remove the oldest non-system messages first 263 | for len(otherMessages) > 2 && h.EstimateTokenCount() > h.MaxTokenCount { 264 | // Remove the oldest message (after systems) 265 | otherMessages = otherMessages[1:] 266 | 267 | // Recalculate with the new set 268 | h.Messages = append(systemMessages, otherMessages...) 269 | h.CurrentTokens = h.EstimateTokenCount() 270 | } 271 | 272 | // If we still exceed the token count, use AI to summarize the conversation 273 | if h.CurrentTokens > h.MaxTokenCount { 274 | // Generate a summary of the conversation 275 | summary, err := h.SummarizeCurrentContext() 276 | if err == nil && summary != "" { 277 | // Create a system message with the summary 278 | summaryMsg := Message{ 279 | Role: "system", 280 | Content: summary, 281 | } 282 | 283 | // Keep system messages plus the summary and the most recent exchanges 284 | summarizedMessages := []Message{} 285 | 286 | // Add original system messages (instructions, etc.) 287 | for _, msg := range systemMessages { 288 | // Skip any previous summary messages 289 | if !strings.HasPrefix(msg.Content, "Summary of conversation: ") { 290 | summarizedMessages = append(summarizedMessages, msg) 291 | } 292 | } 293 | 294 | // Add the new summary as a system message 295 | summarizedMessages = append(summarizedMessages, summaryMsg) 296 | 297 | // Add the most recent messages, up to a reasonable number 298 | recentCount := int(math.Min(float64(len(otherMessages)), 4)) 299 | if recentCount > 0 { 300 | summarizedMessages = append(summarizedMessages, otherMessages[len(otherMessages)-recentCount:]...) 301 | } 302 | 303 | h.Messages = summarizedMessages 304 | h.CurrentTokens = h.EstimateTokenCount() 305 | return 306 | } 307 | 308 | // Fallback if summarization fails: just keep a subset of messages 309 | summarizedMessages := systemMessages 310 | 311 | // Add the most recent messages, up to a reasonable number 312 | recentCount := int(math.Min(float64(len(otherMessages)), 4)) 313 | if recentCount > 0 { 314 | summarizedMessages = append(summarizedMessages, otherMessages[len(otherMessages)-recentCount:]...) 315 | } 316 | 317 | h.Messages = summarizedMessages 318 | h.CurrentTokens = h.EstimateTokenCount() 319 | } 320 | } 321 | 322 | // SummarizeCurrentContext uses the AI to summarize the conversation 323 | // This is a placeholder for future implementation 324 | func (h *ConversationHistory) SummarizeCurrentContext() (string, error) { 325 | // Implement actual summarization using OpenAI 326 | // First, get all messages since the last system message that's a summary 327 | var messagesToSummarize []Message 328 | var systemMessages []Message 329 | 330 | // Find messages to summarize (non-system) and preserve system messages 331 | for _, msg := range h.Messages { 332 | if msg.Role == "system" { 333 | // Check if this is already a summary we generated 334 | if strings.HasPrefix(msg.Content, "Summary of conversation: ") { 335 | // Don't include previous summaries in our list to summarize 336 | continue 337 | } 338 | systemMessages = append(systemMessages, msg) 339 | } else { 340 | messagesToSummarize = append(messagesToSummarize, msg) 341 | } 342 | } 343 | 344 | // If we don't have enough messages to summarize, just return a basic count 345 | if len(messagesToSummarize) < 5 { 346 | messageCount := len(h.Messages) 347 | systemCount := len(systemMessages) 348 | userCount := 0 349 | assistantCount := 0 350 | 351 | for _, msg := range messagesToSummarize { 352 | switch msg.Role { 353 | case "user": 354 | userCount++ 355 | case "assistant": 356 | assistantCount++ 357 | } 358 | } 359 | 360 | summary := fmt.Sprintf( 361 | "Summary of conversation: %d messages (%d system, %d user, %d assistant)", 362 | messageCount, systemCount, userCount, assistantCount, 363 | ) 364 | 365 | return summary, nil 366 | } 367 | 368 | // Otherwise, prepare messages for the summarization request 369 | // We need to create a new OpenAI client for this request 370 | apiKey := os.Getenv("OPENAI_API_KEY") 371 | if apiKey == "" { 372 | // Fall back to basic summary if we don't have an API key 373 | return fmt.Sprintf("Summary of conversation: %d messages", len(h.Messages)), nil 374 | } 375 | 376 | client := openai.NewClient(apiKey) 377 | 378 | // Prepare conversation for summarization 379 | var conversationText strings.Builder 380 | for _, msg := range messagesToSummarize { 381 | conversationText.WriteString(fmt.Sprintf("%s: %s\n\n", msg.Role, msg.Content)) 382 | } 383 | 384 | // Create a completion request for summarization 385 | resp, err := client.CreateChatCompletion( 386 | context.Background(), 387 | openai.ChatCompletionRequest{ 388 | Model: "gpt-3.5-turbo", // Use a smaller model for summarization 389 | Messages: []openai.ChatCompletionMessage{ 390 | { 391 | Role: "system", 392 | Content: "You are a helpful assistant that summarizes conversations. Create a concise summary of the following conversation, focusing on the key points and actions taken.", 393 | }, 394 | { 395 | Role: "user", 396 | Content: conversationText.String(), 397 | }, 398 | }, 399 | MaxTokens: 300, 400 | }, 401 | ) 402 | 403 | if err != nil { 404 | // If summarization fails, fall back to basic summary 405 | return fmt.Sprintf("Summary of conversation: %d messages", len(h.Messages)), nil 406 | } 407 | 408 | // Get the summary from the response 409 | if len(resp.Choices) > 0 { 410 | summary := "Summary of conversation: " + resp.Choices[0].Message.Content 411 | return summary, nil 412 | } 413 | 414 | // Fall back to basic summary if something went wrong 415 | return fmt.Sprintf("Summary of conversation: %d messages", len(h.Messages)), nil 416 | } 417 | -------------------------------------------------------------------------------- /internal/agent/history_test.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNewConversationHistory(t *testing.T) { 11 | // Create a temporary directory for the test 12 | tempDir, err := os.MkdirTemp("", "history-test") 13 | if err != nil { 14 | t.Fatalf("Failed to create temp directory: %v", err) 15 | } 16 | defer os.RemoveAll(tempDir) 17 | 18 | // Create test options 19 | opts := HistoryOptions{ 20 | MaxTokenCount: 1000, 21 | SessionID: "test-session", 22 | HistoryPath: tempDir, 23 | EnablePersist: true, 24 | SystemPrompt: "Test system prompt", 25 | } 26 | 27 | // Create new conversation history 28 | history, err := NewConversationHistory(opts) 29 | if err != nil { 30 | t.Fatalf("Failed to create conversation history: %v", err) 31 | } 32 | 33 | // Check if the system prompt was added 34 | if len(history.Messages) != 1 { 35 | t.Fatalf("Expected 1 message (system prompt), got %d", len(history.Messages)) 36 | } 37 | 38 | if history.Messages[0].Role != "system" || history.Messages[0].Content != opts.SystemPrompt { 39 | t.Errorf("System message not added correctly") 40 | } 41 | 42 | // Check other fields 43 | if history.MaxTokenCount != opts.MaxTokenCount { 44 | t.Errorf("Expected MaxTokenCount=%d, got %d", opts.MaxTokenCount, history.MaxTokenCount) 45 | } 46 | 47 | if history.CurrentSession != opts.SessionID { 48 | t.Errorf("Expected CurrentSession=%s, got %s", opts.SessionID, history.CurrentSession) 49 | } 50 | } 51 | 52 | func TestAddMessage(t *testing.T) { 53 | // Create a basic history 54 | history := &ConversationHistory{ 55 | Messages: []Message{}, 56 | MaxTokenCount: 1000, 57 | CurrentSession: "test", 58 | CreatedAt: time.Now(), 59 | UpdatedAt: time.Now(), 60 | } 61 | 62 | // Add a message 63 | message := Message{ 64 | Role: "user", 65 | Content: "Hello, world!", 66 | } 67 | history.AddMessage(message) 68 | 69 | // Check if the message was added 70 | if len(history.Messages) != 1 { 71 | t.Fatalf("Expected 1 message, got %d", len(history.Messages)) 72 | } 73 | 74 | if history.Messages[0].Role != message.Role || history.Messages[0].Content != message.Content { 75 | t.Errorf("Message not added correctly") 76 | } 77 | 78 | // Add another message 79 | message2 := Message{ 80 | Role: "assistant", 81 | Content: "Hi there!", 82 | } 83 | history.AddMessage(message2) 84 | 85 | // Check if both messages are present 86 | if len(history.Messages) != 2 { 87 | t.Fatalf("Expected 2 messages, got %d", len(history.Messages)) 88 | } 89 | 90 | if history.Messages[1].Role != message2.Role || history.Messages[1].Content != message2.Content { 91 | t.Errorf("Second message not added correctly") 92 | } 93 | } 94 | 95 | func TestPruneIfNeeded(t *testing.T) { 96 | // Create a history with a small token limit 97 | history := &ConversationHistory{ 98 | Messages: []Message{}, 99 | MaxTokenCount: 20, // Very small limit to trigger pruning 100 | CurrentSession: "test", 101 | CreatedAt: time.Now(), 102 | UpdatedAt: time.Now(), 103 | } 104 | 105 | // Add a system message 106 | systemMsg := Message{ 107 | Role: "system", 108 | Content: "You are a helpful assistant.", 109 | } 110 | history.AddMessage(systemMsg) 111 | 112 | // Add several messages to exceed the token limit 113 | for i := 0; i < 5; i++ { 114 | userMsg := Message{ 115 | Role: "user", 116 | Content: "This is a test message that should exceed the token limit.", 117 | } 118 | assistantMsg := Message{ 119 | Role: "assistant", 120 | Content: "This is a response that should also contribute to exceeding the token limit.", 121 | } 122 | history.AddMessages([]Message{userMsg, assistantMsg}) 123 | } 124 | 125 | // Check that pruning occurred 126 | if len(history.Messages) >= 12 { // 1 system + 10 messages = 11 127 | t.Errorf("Expected pruning to reduce message count, but got %d messages", len(history.Messages)) 128 | } 129 | 130 | // Ensure system message is still present 131 | foundSystem := false 132 | for _, msg := range history.Messages { 133 | if msg.Role == "system" { 134 | foundSystem = true 135 | break 136 | } 137 | } 138 | if !foundSystem { 139 | t.Errorf("System message was removed during pruning") 140 | } 141 | } 142 | 143 | func TestSaveAndLoad(t *testing.T) { 144 | // Create a temporary directory for the test 145 | tempDir, err := os.MkdirTemp("", "history-save-test") 146 | if err != nil { 147 | t.Fatalf("Failed to create temp directory: %v", err) 148 | } 149 | defer os.RemoveAll(tempDir) 150 | 151 | // Create a session ID 152 | sessionID := "test-save-session" 153 | 154 | // Create test options 155 | opts := HistoryOptions{ 156 | MaxTokenCount: 1000, 157 | SessionID: sessionID, 158 | HistoryPath: tempDir, 159 | EnablePersist: true, 160 | } 161 | 162 | // Create new conversation history 163 | history, err := NewConversationHistory(opts) 164 | if err != nil { 165 | t.Fatalf("Failed to create conversation history: %v", err) 166 | } 167 | 168 | // Add some messages 169 | messages := []Message{ 170 | {Role: "system", Content: "You are a helpful assistant."}, 171 | {Role: "user", Content: "Hello, world!"}, 172 | {Role: "assistant", Content: "Hi there! How can I help you today?"}, 173 | } 174 | 175 | for _, msg := range messages { 176 | history.AddMessage(msg) 177 | } 178 | 179 | // Save the history 180 | if err := history.Save(tempDir); err != nil { 181 | t.Fatalf("Failed to save history: %v", err) 182 | } 183 | 184 | // Check if the file exists 185 | historyFile := filepath.Join(tempDir, sessionID+".json") 186 | if _, err := os.Stat(historyFile); os.IsNotExist(err) { 187 | t.Fatalf("History file %s was not created", historyFile) 188 | } 189 | 190 | // Create a new history instance with the same options to load the saved data 191 | loadedOpts := HistoryOptions{ 192 | MaxTokenCount: 1000, 193 | SessionID: sessionID, 194 | HistoryPath: tempDir, 195 | EnablePersist: true, 196 | } 197 | 198 | loadedHistory, err := NewConversationHistory(loadedOpts) 199 | if err != nil { 200 | t.Fatalf("Failed to create history for loading: %v", err) 201 | } 202 | 203 | // Check if the messages were loaded correctly 204 | if len(loadedHistory.Messages) != len(messages) { 205 | t.Fatalf("Expected %d messages, got %d", len(messages), len(loadedHistory.Messages)) 206 | } 207 | 208 | for i, msg := range loadedHistory.Messages { 209 | if msg.Role != messages[i].Role || msg.Content != messages[i].Content { 210 | t.Errorf("Message %d not loaded correctly", i) 211 | } 212 | } 213 | } 214 | 215 | func TestEstimateTokenCount(t *testing.T) { 216 | // Create a basic history 217 | history := &ConversationHistory{ 218 | Messages: []Message{}, 219 | MaxTokenCount: 1000, 220 | CurrentSession: "test", 221 | CreatedAt: time.Now(), 222 | UpdatedAt: time.Now(), 223 | } 224 | 225 | // Add messages with known content length 226 | messages := []Message{ 227 | {Role: "system", Content: "A message with 24 characters"}, // ~6 tokens + 4 overhead 228 | {Role: "user", Content: "A slightly longer message with 39 chars"}, // ~10 tokens + 4 overhead 229 | {Role: "assistant", Content: "A short reply"}, // ~3 tokens + 4 overhead 230 | } 231 | 232 | for _, msg := range messages { 233 | history.AddMessage(msg) 234 | } 235 | 236 | // Estimate tokens 237 | tokenCount := history.EstimateTokenCount() 238 | 239 | // Check that the estimate is reasonable (this is approximate) 240 | // 6 + 4 + 10 + 4 + 3 + 4 = roughly 31 tokens 241 | expectedMinimum := 20 // Lower bound 242 | expectedMaximum := 40 // Upper bound 243 | 244 | if tokenCount < expectedMinimum || tokenCount > expectedMaximum { 245 | t.Errorf("Token count estimate %d outside expected range %d-%d", 246 | tokenCount, expectedMinimum, expectedMaximum) 247 | } 248 | } 249 | 250 | func TestClear(t *testing.T) { 251 | // Create a history with some messages 252 | history := &ConversationHistory{ 253 | Messages: []Message{ 254 | {Role: "system", Content: "You are a helpful assistant."}, 255 | {Role: "user", Content: "Hello, world!"}, 256 | {Role: "assistant", Content: "Hi there! How can I help you today?"}, 257 | }, 258 | MaxTokenCount: 1000, 259 | CurrentSession: "test", 260 | CreatedAt: time.Now(), 261 | UpdatedAt: time.Now(), 262 | } 263 | 264 | // Clear the history 265 | history.Clear() 266 | 267 | // Check that all messages are removed 268 | if len(history.Messages) != 0 { 269 | t.Errorf("Expected 0 messages after clear, got %d", len(history.Messages)) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /internal/agent/interface.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Message represents a single message in a conversation 8 | type Message struct { 9 | Role string `json:"role"` 10 | Content string `json:"content"` 11 | ToolCallID string `json:"tool_call_id,omitempty"` 12 | ToolCalls []ToolCall `json:"tool_calls,omitempty"` 13 | Name string `json:"name,omitempty"` 14 | } 15 | 16 | // ToolCall represents a tool call in a message 17 | type ToolCall struct { 18 | ID string `json:"id"` 19 | Type string `json:"type"` 20 | Function FunctionCall `json:"function"` 21 | } 22 | 23 | // FunctionCall represents a function call from the AI 24 | type FunctionCall struct { 25 | Name string // Name of the function to call 26 | Arguments string // JSON string of arguments 27 | ID string // Unique ID for the function call 28 | Metadata map[string]string // Additional metadata 29 | } 30 | 31 | // FunctionCallOutput represents the output of a function call 32 | type FunctionCallOutput struct { 33 | CallID string // ID of the function call 34 | Output string // Output of the function call (typically JSON) 35 | Error string // Error message if any 36 | Success bool // Whether the function call was successful 37 | } 38 | 39 | // ResponseItem represents a single response item from the AI 40 | type ResponseItem struct { 41 | Type string `json:"type"` // "message", "function_call", "followup_complete" 42 | Message *Message `json:"message,omitempty"` 43 | FunctionCall *FunctionCall `json:"functionCall,omitempty"` 44 | FunctionOutput *FunctionCallOutput `json:"functionOutput,omitempty"` 45 | ThinkingDuration int64 `json:"thinkingDuration"` 46 | } 47 | 48 | // ResponseHandler is a callback for handling streaming response items 49 | type ResponseHandler func(itemJSON string) 50 | 51 | // CommandConfirmation represents user confirmation for a command 52 | type CommandConfirmation struct { 53 | Approved bool // Whether the command is approved 54 | DenyMessage string // Message to show if denied 55 | ModifiedCommand string // Modified command if any 56 | } 57 | 58 | // FileChangeConfirmation represents user confirmation for a file change 59 | type FileChangeConfirmation struct { 60 | Approved bool // Whether the file change is approved 61 | DenyMessage string // Message to show if denied 62 | ModifiedDiff string // Modified diff if any 63 | } 64 | 65 | // Agent defines the interface for AI agents 66 | type Agent interface { 67 | // SendMessage sends a message to the AI and streams the response 68 | // Returns true if the stream finished requesting tool calls, false otherwise. 69 | SendMessage(ctx context.Context, messages []Message, handler ResponseHandler) (bool, error) 70 | 71 | // SendFileChange sends a file change to the AI for approval 72 | SendFileChange(ctx context.Context, filePath string, diff string) (*FileChangeConfirmation, error) 73 | 74 | // GetCommandConfirmation gets user confirmation for a command 75 | GetCommandConfirmation(ctx context.Context, command string, args []string) (*CommandConfirmation, error) 76 | 77 | // ClearHistory clears the conversation history 78 | ClearHistory() 79 | 80 | // GetHistory returns the conversation history 81 | GetHistory() *ConversationHistory 82 | 83 | // Cancel cancels the current streaming response 84 | Cancel() 85 | 86 | // Close closes the agent and releases any resources 87 | Close() error 88 | 89 | // SendFunctionResult sends a function result back to the agent 90 | SendFunctionResult(ctx context.Context, callID, functionName, output string, success bool) error 91 | } 92 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // ApprovalMode represents the approval policy for commands and file changes 13 | type ApprovalMode string 14 | 15 | const ( 16 | // Suggest mode requires approval for both file edits and commands 17 | Suggest ApprovalMode = "suggest" 18 | // AutoEdit mode automatically approves file edits but requires approval for commands 19 | AutoEdit ApprovalMode = "auto-edit" 20 | // FullAuto mode automatically approves both file edits and commands (sandbox enforced) 21 | FullAuto ApprovalMode = "full-auto" 22 | // DangerousAutoApprove mode automatically approves everything without sandboxing 23 | // EXTREMELY DANGEROUS - use only in ephemeral environments 24 | DangerousAutoApprove ApprovalMode = "dangerous" 25 | ) 26 | 27 | // Config holds all configuration options for the application 28 | type Config struct { 29 | // API configuration 30 | APIKey string `mapstructure:"api_key"` 31 | Model string `mapstructure:"model"` 32 | BaseURL string `mapstructure:"base_url"` 33 | APITimeout int `mapstructure:"api_timeout"` // in seconds 34 | 35 | // Project configuration 36 | CWD string `mapstructure:"cwd"` 37 | ProjectDocPath string `mapstructure:"project_doc_path"` 38 | DisableProjectDoc bool `mapstructure:"disable_project_doc"` 39 | Instructions string `mapstructure:"instructions"` 40 | 41 | // UI configuration 42 | FullStdout bool `mapstructure:"full_stdout"` // Don't truncate command output 43 | 44 | // Approval configuration 45 | ApprovalMode ApprovalMode `mapstructure:"approval_mode"` 46 | 47 | // Logging configuration 48 | Debug bool `mapstructure:"debug"` // Enable debug logging 49 | LogFile string `mapstructure:"log_file"` // Path to log file 50 | } 51 | 52 | const ( 53 | // Default configuration values 54 | DefaultModel = "gpt-4o" 55 | DefaultBaseURL = "https://api.openai.com/v1" 56 | DefaultAPITimeout = 60 // seconds 57 | DefaultConfigDir = ".codex" 58 | ) 59 | 60 | // Load loads configuration from files, environment variables, and flags 61 | func Load() (*Config, error) { 62 | // Initialize config with defaults 63 | config := &Config{ 64 | Model: DefaultModel, 65 | BaseURL: DefaultBaseURL, 66 | APITimeout: DefaultAPITimeout, 67 | ApprovalMode: Suggest, 68 | CWD: getWorkingDirectory(), 69 | } 70 | 71 | // Set up viper 72 | v := viper.New() 73 | v.SetConfigName("config") 74 | v.SetConfigType("yaml") 75 | 76 | // Add config path 77 | configDir := getConfigDir() 78 | v.AddConfigPath(configDir) 79 | 80 | // Set environment variable prefix 81 | v.SetEnvPrefix("CODEX") 82 | v.AutomaticEnv() 83 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 84 | 85 | // Allow special handling for OpenAI API key 86 | if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" { 87 | config.APIKey = apiKey 88 | } 89 | 90 | // Attempt to read the config file 91 | if err := v.ReadInConfig(); err != nil { 92 | // Config file not found is not an error 93 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 94 | return nil, fmt.Errorf("error reading config file: %w", err) 95 | } 96 | } 97 | 98 | // Unmarshal config to struct 99 | if err := v.Unmarshal(config); err != nil { 100 | return nil, fmt.Errorf("error unmarshaling config: %w", err) 101 | } 102 | 103 | // Load instructions from file if it exists 104 | instructionsPath := filepath.Join(configDir, "instructions.md") 105 | if _, err := os.Stat(instructionsPath); err == nil { 106 | data, err := os.ReadFile(instructionsPath) 107 | if err == nil { 108 | config.Instructions = string(data) 109 | } 110 | } 111 | 112 | // Load project doc if it exists and is not disabled 113 | if !config.DisableProjectDoc && config.ProjectDocPath == "" { 114 | // Check for codex.md in current directory 115 | projectDocPath := filepath.Join(config.CWD, "codex.md") 116 | if _, err := os.Stat(projectDocPath); err == nil { 117 | config.ProjectDocPath = projectDocPath 118 | } 119 | } 120 | 121 | return config, nil 122 | } 123 | 124 | // LoadProjectDoc loads the content of the project documentation file if specified 125 | func (c *Config) LoadProjectDoc() (string, error) { 126 | if c.DisableProjectDoc || c.ProjectDocPath == "" { 127 | return "", nil 128 | } 129 | 130 | data, err := os.ReadFile(c.ProjectDocPath) 131 | if err != nil { 132 | return "", fmt.Errorf("error reading project doc: %w", err) 133 | } 134 | 135 | return string(data), nil 136 | } 137 | 138 | // getConfigDir returns the path to the config directory 139 | func getConfigDir() string { 140 | homeDir, err := os.UserHomeDir() 141 | if err != nil { 142 | return "." 143 | } 144 | 145 | configDir := filepath.Join(homeDir, DefaultConfigDir) 146 | 147 | // Create the directory if it doesn't exist 148 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 149 | os.MkdirAll(configDir, 0755) 150 | } 151 | 152 | return configDir 153 | } 154 | 155 | // getWorkingDirectory returns the current working directory 156 | func getWorkingDirectory() string { 157 | cwd, err := os.Getwd() 158 | if err != nil { 159 | return "." 160 | } 161 | return cwd 162 | } 163 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestDefaultConfig(t *testing.T) { 10 | // Create a temporary HOME directory for this test 11 | tmpHome, err := os.MkdirTemp("", "codex-test-home") 12 | if err != nil { 13 | t.Fatalf("Failed to create temp home directory: %v", err) 14 | } 15 | defer os.RemoveAll(tmpHome) 16 | 17 | // Save the original HOME and restore it after the test 18 | origHome := os.Getenv("HOME") 19 | t.Cleanup(func() { 20 | os.Setenv("HOME", origHome) 21 | }) 22 | os.Setenv("HOME", tmpHome) 23 | 24 | // Create a test config directory 25 | configDir := filepath.Join(tmpHome, DefaultConfigDir) 26 | if err := os.MkdirAll(configDir, 0755); err != nil { 27 | t.Fatalf("Failed to create config directory: %v", err) 28 | } 29 | 30 | // Load config with no existing files 31 | cfg, err := Load() 32 | if err != nil { 33 | t.Fatalf("Load() failed: %v", err) 34 | } 35 | 36 | // Verify defaults 37 | if cfg.Model != DefaultModel { 38 | t.Errorf("Expected Model=%s, got %s", DefaultModel, cfg.Model) 39 | } 40 | 41 | if cfg.BaseURL != DefaultBaseURL { 42 | t.Errorf("Expected BaseURL=%s, got %s", DefaultBaseURL, cfg.BaseURL) 43 | } 44 | 45 | if cfg.APITimeout != DefaultAPITimeout { 46 | t.Errorf("Expected APITimeout=%d, got %d", DefaultAPITimeout, cfg.APITimeout) 47 | } 48 | 49 | if cfg.ApprovalMode != Suggest { 50 | t.Errorf("Expected ApprovalMode=%s, got %s", Suggest, cfg.ApprovalMode) 51 | } 52 | } 53 | 54 | func TestLoadWithAPIKey(t *testing.T) { 55 | // Create a temporary HOME directory for this test 56 | tmpHome, err := os.MkdirTemp("", "codex-test-home") 57 | if err != nil { 58 | t.Fatalf("Failed to create temp home directory: %v", err) 59 | } 60 | defer os.RemoveAll(tmpHome) 61 | 62 | // Save the original HOME and API key, restore them after the test 63 | origHome := os.Getenv("HOME") 64 | origAPIKey := os.Getenv("OPENAI_API_KEY") 65 | t.Cleanup(func() { 66 | os.Setenv("HOME", origHome) 67 | os.Setenv("OPENAI_API_KEY", origAPIKey) 68 | }) 69 | os.Setenv("HOME", tmpHome) 70 | 71 | // Set a test API key 72 | testAPIKey := "test-api-key" 73 | os.Setenv("OPENAI_API_KEY", testAPIKey) 74 | 75 | // Load config 76 | cfg, err := Load() 77 | if err != nil { 78 | t.Fatalf("Load() failed: %v", err) 79 | } 80 | 81 | // Verify the API key was set 82 | if cfg.APIKey != testAPIKey { 83 | t.Errorf("Expected APIKey=%s, got %s", testAPIKey, cfg.APIKey) 84 | } 85 | } 86 | 87 | func TestLoadProjectDoc(t *testing.T) { 88 | // Create a temporary directory for this test 89 | tmpDir, err := os.MkdirTemp("", "codex-test-project") 90 | if err != nil { 91 | t.Fatalf("Failed to create temp directory: %v", err) 92 | } 93 | defer os.RemoveAll(tmpDir) 94 | 95 | // Create a test project doc 96 | projectDocPath := filepath.Join(tmpDir, "test-doc.md") 97 | testContent := "# Test Project\n\nThis is a test project doc." 98 | if err := os.WriteFile(projectDocPath, []byte(testContent), 0644); err != nil { 99 | t.Fatalf("Failed to write test project doc: %v", err) 100 | } 101 | 102 | // Create a config with the test project doc 103 | cfg := &Config{ 104 | ProjectDocPath: projectDocPath, 105 | } 106 | 107 | // Load the project doc 108 | content, err := cfg.LoadProjectDoc() 109 | if err != nil { 110 | t.Fatalf("LoadProjectDoc() failed: %v", err) 111 | } 112 | 113 | // Verify the content 114 | if content != testContent { 115 | t.Errorf("Expected content=%q, got %q", testContent, content) 116 | } 117 | 118 | // Test with disabled project doc 119 | cfg.DisableProjectDoc = true 120 | content, err = cfg.LoadProjectDoc() 121 | if err != nil { 122 | t.Fatalf("LoadProjectDoc() failed: %v", err) 123 | } 124 | if content != "" { 125 | t.Errorf("Expected empty content with disabled project doc, got %q", content) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/debug_helper.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/epuerta/codex-go/internal/ui" 9 | ) 10 | 11 | // A simple test program for the UI 12 | func RunUITest() { 13 | // Create UI model 14 | chatModel := ui.NewChatModel() 15 | chatModel.SetSessionInfo( 16 | "test-session", 17 | "/current/dir", 18 | "gpt-4o", 19 | "suggest", 20 | ) 21 | 22 | // Add some test messages 23 | chatModel.AddUserMessage("Hello, can you help me?") 24 | chatModel.AddSystemMessage("System test message") 25 | chatModel.AddAssistantMessage("I'm here to help! What can I do for you today?") 26 | 27 | // Add a function call message 28 | chatModel.AddFunctionCallMessage("read_file", `{"path": "test.txt"}`) 29 | chatModel.AddFunctionResultMessage("Sample file contents", false) 30 | 31 | // Add follow-up messages 32 | chatModel.AddUserMessage("Thanks!") 33 | chatModel.AddAssistantMessage("You're welcome! Let me know if you need anything else.") 34 | 35 | // Create the program 36 | p := tea.NewProgram(chatModel, tea.WithAltScreen()) 37 | if _, err := p.Run(); err != nil { 38 | fmt.Fprintf(os.Stderr, "Error running program: %v\n", err) 39 | os.Exit(1) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/fileops/custom_patch.go: -------------------------------------------------------------------------------- 1 | package fileops 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | // CustomPatchOperation represents an operation in our custom patch format 13 | type CustomPatchOperation struct { 14 | Type string // "update", "add", "delete" 15 | Path string // Path to the file 16 | Content string // Content for the file (add, full update) 17 | IsHunk bool // Whether this is a partial update (hunk) or full file update 18 | Context []string // Context lines for fuzzy matching (hunk) 19 | AddLines []string // Lines to add (hunk) 20 | DelLines []string // Lines to delete (hunk) 21 | } 22 | 23 | // CustomPatchResult represents the result of applying one custom patch operation 24 | type CustomPatchResult struct { 25 | Success bool 26 | Error error 27 | Path string 28 | OriginalLines int // Line count of the file *before* this specific operation 29 | NewLines int // Line count of the file *after* this specific operation (if successful) 30 | Operation string // "add", "delete", "update", "update_hunk" 31 | // TODO: Maybe add HunkIndex later if needed for UI clarity 32 | } 33 | 34 | // Regex patterns for parsing 35 | var ( 36 | patchStartRegex = regexp.MustCompile(`^\*\*\* Begin Patch\s*$`) // Renamed from beginRegex 37 | updateFileRegex = regexp.MustCompile(`^\*\*\* Update File:\s+(.+)\s*$`) 38 | addFileRegex = regexp.MustCompile(`^\*\*\* Add File:\s+(.+)\s*$`) 39 | deleteFileRegex = regexp.MustCompile(`^\*\*\* Delete File:\s+(.+)\s*$`) 40 | endRegex = regexp.MustCompile(`^\*\*\* End Patch\s*$`) // Renamed from patchEndRegex 41 | addLineRegex = regexp.MustCompile(`^\+\s*(.*)$`) // Add line within a hunk 42 | delLineRegex = regexp.MustCompile(`^-\s*(.*)$`) // Delete line within a hunk 43 | contextLineRegex = regexp.MustCompile(`^\s*(.*)$`) // Context line within a hunk (leading space) 44 | existingCodeRegex = regexp.MustCompile(`^\/\/ \.\.\. existing code \.\.\.$`) // Added for clarity in format examples 45 | fileMarkerRegex = regexp.MustCompile(`^\*\*\* FILE:\s+(.+)\s*$`) 46 | ) 47 | 48 | // ParseCustomPatch parses a patch in our custom format OR the simplified Agent format 49 | // It now separates full file updates from hunks more explicitly during parsing. 50 | func ParseCustomPatch(patchText string) ([]CustomPatchOperation, error) { 51 | var operations []CustomPatchOperation 52 | // var currentOp *CustomPatchOperation // Removed as unused 53 | // var inPatch bool // Not strictly needed for the simple format, but keep for potential future mixed format use 54 | var currentFile string 55 | var addLines []string 56 | var delLines []string 57 | 58 | scanner := bufio.NewScanner(strings.NewReader(patchText)) 59 | lineNum := 0 60 | 61 | finalizeOperation := func() { 62 | if currentFile != "" && (len(addLines) > 0 || len(delLines) > 0) { 63 | // Create a single 'update' operation with additions and deletions 64 | // For simplicity, we treat this as a single non-hunk update for now. 65 | // A more sophisticated approach might create multiple hunk operations. 66 | operations = append(operations, CustomPatchOperation{ 67 | Type: "update", // Treat combined ADD/DEL as update 68 | Path: currentFile, 69 | IsHunk: true, // Mark as hunk because it's partial changes 70 | AddLines: addLines, 71 | DelLines: delLines, 72 | // Context is omitted in this simple format 73 | }) 74 | addLines = []string{} 75 | delLines = []string{} 76 | currentFile = "" // Reset for the next potential file 77 | } 78 | } 79 | 80 | for scanner.Scan() { 81 | lineNum++ 82 | line := scanner.Text() 83 | 84 | // Check for FILE marker first 85 | if fileMatch := fileMarkerRegex.FindStringSubmatch(line); fileMatch != nil { 86 | finalizeOperation() // Finalize any operation for the previous file 87 | currentFile = fileMatch[1] 88 | continue 89 | } 90 | 91 | // Skip lines if no file context is set yet 92 | if currentFile == "" { 93 | continue 94 | } 95 | 96 | // Handle simplified ADD: prefix 97 | if strings.HasPrefix(line, "ADD:") { 98 | content := strings.TrimSpace(strings.TrimPrefix(line, "ADD:")) 99 | addLines = append(addLines, content) 100 | continue 101 | } 102 | 103 | // Handle simplified DEL: prefix 104 | if strings.HasPrefix(line, "DEL:") { 105 | content := strings.TrimSpace(strings.TrimPrefix(line, "DEL:")) 106 | delLines = append(delLines, content) 107 | continue 108 | } 109 | 110 | // Ignore other lines (like comments // EDIT:, // END_EDIT) in the simple format 111 | } 112 | 113 | finalizeOperation() // Finalize any remaining operation 114 | 115 | if err := scanner.Err(); err != nil { 116 | return nil, fmt.Errorf("error scanning patch text: %w", err) 117 | } 118 | 119 | // // Original complex parser logic commented out for now 120 | // /* 121 | // var operations []CustomPatchOperation 122 | // var currentOp *CustomPatchOperation 123 | // var inPatch bool 124 | // var inContent bool 125 | // var contentLines []string 126 | // ... (rest of the original complex parser code) ... 127 | // */ 128 | 129 | return operations, nil 130 | } 131 | 132 | // ApplyCustomPatch applies a sequence of custom patch operations to the filesystem. 133 | // It returns a slice of results, one for each operation attempt. 134 | func ApplyCustomPatch(operations []CustomPatchOperation) ([]*CustomPatchResult, error) { 135 | var results []*CustomPatchResult 136 | fileContentsCache := make(map[string][]string) // Cache file content for multi-hunk updates 137 | failedHunks := make(map[string]bool) // Track if a hunk failed for a specific file 138 | 139 | for i, op := range operations { // Use index for logging/debugging if needed 140 | var result *CustomPatchResult 141 | var err error 142 | opDescription := fmt.Sprintf("Operation %d: %s %s", i+1, strings.ToUpper(op.Type), op.Path) 143 | if op.IsHunk { 144 | opDescription += " (Hunk)" 145 | } 146 | 147 | // If a previous hunk failed for this file, skip subsequent hunks for the same file 148 | // Note: With the simplified parser, we usually have only one 'hunk' per file now. 149 | if op.IsHunk && failedHunks[op.Path] { 150 | result = &CustomPatchResult{ 151 | Operation: op.Type, // Be specific: "update_hunk" or "update" 152 | Path: op.Path, 153 | Success: false, 154 | Error: fmt.Errorf("previous operation failed for file %s, skipping this one", op.Path), 155 | } 156 | results = append(results, result) 157 | log.Printf("%s - SKIPPED: %v", opDescription, result.Error) 158 | continue 159 | } 160 | 161 | switch op.Type { 162 | // Removed case "add" as it's unreachable with the current parser 163 | // case "add": 164 | // result, err = applyAddFile(op) 165 | // Removed case "delete" as it's unreachable with the current parser 166 | // case "delete": 167 | // result, err = applyDeleteFile(op) 168 | case "update": 169 | if op.IsHunk { 170 | // This is the path taken by the simplified ADD:/DEL: parser 171 | result, err = applySingleHunk(op, fileContentsCache) // Pass cache 172 | if result != nil { 173 | result.Operation = "update_hunk" // Be specific 174 | } 175 | } // Removed else block for full file update as it's unreachable 176 | // else { 177 | // result, err = applyUpdateFile(op) 178 | // if result != nil { 179 | // result.Operation = "update_full" 180 | // } 181 | // } 182 | default: 183 | err = fmt.Errorf("unknown or unreachable patch operation type: %s", op.Type) 184 | result = &CustomPatchResult{ 185 | Operation: "unknown", 186 | Path: op.Path, 187 | Success: false, 188 | Error: err, 189 | } 190 | } 191 | 192 | // Ensure result is never nil even if apply functions misbehave 193 | if result == nil { 194 | result = &CustomPatchResult{ 195 | Operation: op.Type, 196 | Path: op.Path, 197 | Success: false, 198 | Error: fmt.Errorf("internal error: apply function returned nil result for %s", op.Type), 199 | } 200 | if err != nil { // Combine errors if possible 201 | result.Error = fmt.Errorf("%w; %w", result.Error, err) 202 | } 203 | } else if err != nil && result.Error == nil { 204 | // If the apply function returned an error but didn't set it in the result 205 | result.Error = err 206 | result.Success = false 207 | } 208 | 209 | results = append(results, result) 210 | 211 | if !result.Success { 212 | log.Printf("%s - FAILED: %v", opDescription, result.Error) 213 | if op.IsHunk { 214 | failedHunks[op.Path] = true // Mark file as failed for subsequent hunks 215 | } 216 | } else { 217 | log.Printf("%s - SUCCESS: Original Lines: %d, New Lines: %d", opDescription, result.OriginalLines, result.NewLines) 218 | // Invalidate cache for this file if it was modified successfully 219 | // applySingleHunk should update the cache internally if successful 220 | // delete(fileContentsCache, op.Path) // Or let applySingleHunk manage it 221 | } 222 | } 223 | 224 | // Check for overall errors (e.g., permission issues not tied to a specific operation) 225 | // This simplistic loop doesn't introduce overall errors, but a more complex apply might. 226 | // For now, we just return the collected results. 227 | return results, nil // Return nil error, individual errors are in results 228 | } 229 | 230 | // applyAddFile creates a new file with the specified content. 231 | // ... existing code ... 232 | // applyDeleteFile deletes the specified file. 233 | // ... existing code ... 234 | // applyUpdateFile replaces the entire content of a file. 235 | // ... existing code ... 236 | 237 | // applySingleHunk attempts to apply a single set of changes (context, deletions, additions) to a file. 238 | // It now uses and potentially updates the file content cache. 239 | func applySingleHunk(op CustomPatchOperation, fileContentsCache map[string][]string) (*CustomPatchResult, error) { 240 | result := &CustomPatchResult{ 241 | Operation: "update_hunk", // Default operation type 242 | Path: op.Path, 243 | Success: false, // Assume failure 244 | } 245 | 246 | // Get file content, using cache if available 247 | fileLines, ok := fileContentsCache[op.Path] 248 | if !ok { 249 | contentBytes, err := os.ReadFile(op.Path) 250 | if err != nil { 251 | if os.IsNotExist(err) { 252 | // If the simplified parser created a hunk for a non-existent file, it's an error 253 | result.Error = fmt.Errorf("file does not exist and cannot apply hunk: %w", err) 254 | return result, result.Error // Return early 255 | } 256 | result.Error = fmt.Errorf("failed to read file: %w", err) 257 | return result, result.Error // Return early 258 | } 259 | fileLines = strings.Split(string(contentBytes), "\n") 260 | fileContentsCache[op.Path] = fileLines // Cache the read content 261 | } 262 | originalLinesCount := len(fileLines) 263 | result.OriginalLines = originalLinesCount 264 | 265 | // --- Simplified Application Logic for ADD:/DEL: (No Context Matching) --- 266 | // This section assumes the simple format where DelLines should be removed 267 | // and AddLines should be appended. This is a basic interpretation. 268 | // A better approach might insert them relative to deletions or context (if available) 269 | newFileLines := append(fileLines, op.AddLines...) 270 | 271 | // --- End Simplified Logic --- 272 | 273 | // // --- Original Hunk Logic (Commented Out) --- 274 | // /* 275 | // // Find the starting position of the hunk using fuzzy matching on context lines 276 | // matchIndex := -1 277 | // if len(op.Context) > 0 { 278 | // matchIndex = findFuzzyMatch(fileLines, op.Context, op.DelLines) 279 | // } else if len(op.DelLines) > 0 { 280 | // // Attempt direct match on delete lines if no context provided 281 | // matchIndex = findFuzzyMatch(fileLines, op.DelLines, nil) // Use DelLines as context 282 | // } 283 | 284 | // if matchIndex == -1 && (len(op.Context) > 0 || len(op.DelLines) > 0) { 285 | // result.Error = errors.New("failed to find matching context/deletion lines in file") 286 | // return result, result.Error // Return early 287 | // } 288 | 289 | // // Verify that the lines immediately following the context match the DelLines 290 | // if len(op.DelLines) > 0 { 291 | // expectedDelEndIndex := matchIndex + len(op.Context) + len(op.DelLines) 292 | // if expectedDelEndIndex > len(fileLines) { 293 | // result.Error = fmt.Errorf("deletion block extends beyond end of file (expected end %d, file lines %d)", expectedDelEndIndex, len(fileLines)) 294 | // return result, result.Error 295 | // } 296 | // actualDelLines := fileLines[matchIndex+len(op.Context) : expectedDelEndIndex] 297 | 298 | // // Use flexible matching for deletion verification 299 | // if !blocksMatchTrimSpace(op.DelLines, actualDelLines) { // Allow whitespace differences 300 | // result.Error = fmt.Errorf("deletion lines do not match file content at offset %d. Expected '%v', got '%v'", 301 | // matchIndex+len(op.Context), op.DelLines, actualDelLines) 302 | // return result, result.Error 303 | // } 304 | // } 305 | 306 | // // Construct the new file content 307 | // var newFileLines []string 308 | // // Add lines before the hunk 309 | // newFileLines = append(newFileLines, fileLines[:matchIndex+len(op.Context)]...) 310 | // // Add the new lines (AddLines) 311 | // newFileLines = append(newFileLines, op.AddLines...) 312 | // // Add lines after the deleted section 313 | // deleteEndIndex := matchIndex + len(op.Context) + len(op.DelLines) 314 | // if deleteEndIndex < len(fileLines) { 315 | // newFileLines = append(newFileLines, fileLines[deleteEndIndex:]...) 316 | // } 317 | // */ 318 | // // --- End Original Hunk Logic --- 319 | 320 | // Write the modified content back to the file 321 | newContent := strings.Join(newFileLines, "\n") 322 | // Use WriteFile for atomic write operations 323 | // Ensure correct permissions, e.g., 0644 or derive from original 324 | // Get original file permissions 325 | info, statErr := os.Stat(op.Path) 326 | perms := os.FileMode(0644) // Default permissions 327 | if statErr == nil { 328 | perms = info.Mode().Perm() 329 | } else if !os.IsNotExist(statErr) { 330 | // Handle stat errors other than NotExist if necessary 331 | log.Printf("Warning: Could not stat original file %s to get permissions: %v. Using default 0644.", op.Path, statErr) 332 | } 333 | 334 | err := os.WriteFile(op.Path, []byte(newContent), perms) 335 | if err != nil { 336 | result.Error = fmt.Errorf("failed to write updated file content: %w", err) 337 | return result, result.Error // Return failure 338 | } 339 | 340 | // Update cache with the new content 341 | fileContentsCache[op.Path] = newFileLines 342 | 343 | result.Success = true 344 | result.NewLines = len(newFileLines) 345 | log.Printf("Successfully applied hunk to %s. Original lines: %d, New lines: %d", op.Path, originalLinesCount, result.NewLines) 346 | return result, nil // Success 347 | } 348 | 349 | // findFuzzyMatch tries to find the starting line index of a block (context or delLines) 350 | // ... existing code ... 351 | // blocksMatchExact checks if two slices of strings are identical. 352 | // ... existing code ... 353 | // blocksMatchTrimSuffixSpace checks if two slices of strings match after trimming trailing spaces. 354 | // ... existing code ... 355 | // blocksMatchTrimSpace checks if two slices of strings match after trimming leading/trailing spaces. 356 | // ... existing code ... 357 | -------------------------------------------------------------------------------- /internal/fileops/fileops.go: -------------------------------------------------------------------------------- 1 | package fileops 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // FileInfo represents information about a file 13 | type FileInfo struct { 14 | Path string 15 | Content string 16 | Size int64 17 | Mode os.FileMode 18 | IsDir bool 19 | ModTime int64 20 | Exists bool 21 | IsSymlink bool 22 | } 23 | 24 | // GetFile reads a file and returns its contents 25 | func GetFile(path string) (*FileInfo, error) { 26 | // Check if the file exists 27 | info, err := os.Lstat(path) 28 | if err != nil { 29 | if os.IsNotExist(err) { 30 | return &FileInfo{ 31 | Path: path, 32 | Exists: false, 33 | }, nil 34 | } 35 | return nil, fmt.Errorf("error getting file info: %w", err) 36 | } 37 | 38 | // Create FileInfo 39 | fileInfo := &FileInfo{ 40 | Path: path, 41 | Size: info.Size(), 42 | Mode: info.Mode(), 43 | IsDir: info.IsDir(), 44 | ModTime: info.ModTime().Unix(), 45 | Exists: true, 46 | IsSymlink: info.Mode()&os.ModeSymlink != 0, 47 | } 48 | 49 | // If it's a symlink, resolve it 50 | if fileInfo.IsSymlink { 51 | target, err := os.Readlink(path) 52 | if err != nil { 53 | return nil, fmt.Errorf("error reading symlink: %w", err) 54 | } 55 | // If the target is relative, make it absolute 56 | if !filepath.IsAbs(target) { 57 | target = filepath.Join(filepath.Dir(path), target) 58 | } 59 | // Get info about the target 60 | targetInfo, err := GetFile(target) 61 | if err != nil { 62 | return nil, fmt.Errorf("error getting symlink target info: %w", err) 63 | } 64 | // Update the FileInfo with target info 65 | fileInfo.IsDir = targetInfo.IsDir 66 | fileInfo.Size = targetInfo.Size 67 | } 68 | 69 | // If it's a directory, don't read the content 70 | if fileInfo.IsDir { 71 | return fileInfo, nil 72 | } 73 | 74 | // Read the file content 75 | content, err := ioutil.ReadFile(path) 76 | if err != nil { 77 | return nil, fmt.Errorf("error reading file: %w", err) 78 | } 79 | fileInfo.Content = string(content) 80 | 81 | return fileInfo, nil 82 | } 83 | 84 | // WriteFile writes content to a file 85 | func WriteFile(path string, content string, mode os.FileMode) error { 86 | // Create parent directories if they don't exist 87 | dir := filepath.Dir(path) 88 | if err := os.MkdirAll(dir, 0755); err != nil { 89 | return fmt.Errorf("error creating directories: %w", err) 90 | } 91 | 92 | // Write the file 93 | if err := ioutil.WriteFile(path, []byte(content), mode); err != nil { 94 | return fmt.Errorf("error writing file: %w", err) 95 | } 96 | 97 | return nil 98 | } 99 | 100 | // ListDir lists the contents of a directory 101 | func ListDir(path string) ([]FileInfo, error) { 102 | // Check if the directory exists 103 | info, err := os.Stat(path) 104 | if err != nil { 105 | return nil, fmt.Errorf("error getting directory info: %w", err) 106 | } 107 | 108 | // Check if it's a directory 109 | if !info.IsDir() { 110 | return nil, errors.New("path is not a directory") 111 | } 112 | 113 | // Read the directory 114 | entries, err := ioutil.ReadDir(path) 115 | if err != nil { 116 | return nil, fmt.Errorf("error reading directory: %w", err) 117 | } 118 | 119 | // Create FileInfo for each entry 120 | var result []FileInfo 121 | for _, entry := range entries { 122 | result = append(result, FileInfo{ 123 | Path: filepath.Join(path, entry.Name()), 124 | Size: entry.Size(), 125 | Mode: entry.Mode(), 126 | IsDir: entry.IsDir(), 127 | ModTime: entry.ModTime().Unix(), 128 | Exists: true, 129 | IsSymlink: entry.Mode()&os.ModeSymlink != 0, 130 | }) 131 | } 132 | 133 | return result, nil 134 | } 135 | 136 | // Exists checks if a file or directory exists 137 | func Exists(path string) bool { 138 | _, err := os.Stat(path) 139 | return err == nil 140 | } 141 | 142 | // IsDir checks if a path is a directory 143 | func IsDir(path string) bool { 144 | info, err := os.Stat(path) 145 | return err == nil && info.IsDir() 146 | } 147 | 148 | // IsFile checks if a path is a file 149 | func IsFile(path string) bool { 150 | info, err := os.Stat(path) 151 | return err == nil && !info.IsDir() 152 | } 153 | 154 | // Diff represents a diff between two files 155 | type Diff struct { 156 | Path string 157 | OldText string 158 | NewText string 159 | Hunks []DiffHunk 160 | } 161 | 162 | // DiffHunk represents a hunk in a diff 163 | type DiffHunk struct { 164 | OldStart int 165 | OldLines int 166 | NewStart int 167 | NewLines int 168 | Content string 169 | } 170 | 171 | // CreateDiff creates a diff between two strings 172 | func CreateDiff(path, oldText, newText string) (*Diff, error) { 173 | // This is a placeholder for a real diff implementation 174 | // In a real implementation, we would use a proper diff algorithm 175 | 176 | // For now, just return a simple diff 177 | return &Diff{ 178 | Path: path, 179 | OldText: oldText, 180 | NewText: newText, 181 | Hunks: []DiffHunk{ 182 | { 183 | OldStart: 1, 184 | OldLines: len(strings.Split(oldText, "\n")), 185 | NewStart: 1, 186 | NewLines: len(strings.Split(newText, "\n")), 187 | Content: newText, 188 | }, 189 | }, 190 | }, nil 191 | } 192 | 193 | // ApplyDiff applies a diff to a file 194 | func ApplyDiff(diff *Diff) error { 195 | // Read the current file content 196 | fileInfo, err := GetFile(diff.Path) 197 | if err != nil { 198 | return fmt.Errorf("error reading file: %w", err) 199 | } 200 | 201 | // Check if the file exists 202 | if !fileInfo.Exists { 203 | // If the file doesn't exist, create it 204 | return WriteFile(diff.Path, diff.NewText, 0644) 205 | } 206 | 207 | // Check if the current content matches the expected old content 208 | if fileInfo.Content != diff.OldText { 209 | return errors.New("file content has changed since diff was created") 210 | } 211 | 212 | // Apply the diff (for now, just replace the content) 213 | return WriteFile(diff.Path, diff.NewText, fileInfo.Mode) 214 | } 215 | -------------------------------------------------------------------------------- /internal/functions/core.go: -------------------------------------------------------------------------------- 1 | package functions 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/epuerta/codex-go/internal/fileops" 13 | "github.com/epuerta/codex-go/internal/sandbox" 14 | ) 15 | 16 | // Registry holds registered functions 17 | type Registry struct { 18 | functions map[string]Function 19 | } 20 | 21 | // Function represents a function that can be called by the agent 22 | type Function func(args string) (string, error) 23 | 24 | // NewRegistry creates a new function registry 25 | func NewRegistry() *Registry { 26 | return &Registry{ 27 | functions: make(map[string]Function), 28 | } 29 | } 30 | 31 | // Register adds a function to the registry 32 | func (r *Registry) Register(name string, fn Function) { 33 | r.functions[name] = fn 34 | } 35 | 36 | // Get retrieves a function from the registry 37 | func (r *Registry) Get(name string) Function { 38 | return r.functions[name] 39 | } 40 | 41 | // ReadFile reads the contents of a file 42 | func ReadFile(args string) (string, error) { 43 | // Parse arguments 44 | var params struct { 45 | Path string `json:"path"` 46 | } 47 | if err := json.Unmarshal([]byte(args), ¶ms); err != nil { 48 | return "", fmt.Errorf("failed to parse arguments: %w", err) 49 | } 50 | 51 | // Check if the path is valid 52 | if params.Path == "" { 53 | return "", fmt.Errorf("path parameter is required") 54 | } 55 | 56 | // Resolve the path 57 | absPath, err := filepath.Abs(params.Path) 58 | if err != nil { 59 | return "", fmt.Errorf("failed to resolve absolute path: %w", err) 60 | } 61 | 62 | // Read the file 63 | content, err := ioutil.ReadFile(absPath) 64 | if err != nil { 65 | return "", fmt.Errorf("failed to read file: %w", err) 66 | } 67 | 68 | return string(content), nil 69 | } 70 | 71 | // WriteFile writes content to a file 72 | func WriteFile(args string) (string, error) { 73 | // Parse arguments 74 | var params struct { 75 | Path string `json:"path"` 76 | Content string `json:"content"` 77 | } 78 | if err := json.Unmarshal([]byte(args), ¶ms); err != nil { 79 | return "", fmt.Errorf("failed to parse arguments: %w", err) 80 | } 81 | 82 | // Check if parameters are valid 83 | if params.Path == "" { 84 | return "", fmt.Errorf("path parameter is required") 85 | } 86 | 87 | // Resolve the path 88 | absPath, err := filepath.Abs(params.Path) 89 | if err != nil { 90 | return "", fmt.Errorf("failed to resolve absolute path: %w", err) 91 | } 92 | 93 | // Create the directory if it doesn't exist 94 | dir := filepath.Dir(absPath) 95 | if err := os.MkdirAll(dir, 0755); err != nil { 96 | return "", fmt.Errorf("failed to create directory: %w", err) 97 | } 98 | 99 | // Write the file 100 | if err := ioutil.WriteFile(absPath, []byte(params.Content), 0644); err != nil { 101 | return "", fmt.Errorf("failed to write file: %w", err) 102 | } 103 | 104 | return fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), params.Path), nil 105 | } 106 | 107 | // PatchFile applies a patch to a file 108 | func PatchFile(args string) (string, error) { 109 | // Parse arguments 110 | var params struct { 111 | Path string `json:"path"` 112 | Patch string `json:"patch"` 113 | StartLine int `json:"startLine"` 114 | EndLine int `json:"endLine"` 115 | Type string `json:"type"` 116 | Content string `json:"content"` 117 | } 118 | if err := json.Unmarshal([]byte(args), ¶ms); err != nil { 119 | return "", fmt.Errorf("failed to parse arguments: %w", err) 120 | } 121 | 122 | // Check if parameters are valid 123 | if params.Path == "" { 124 | return "", fmt.Errorf("path parameter is required") 125 | } 126 | if params.Type == "" { 127 | params.Type = "replace" // Default to replace 128 | } 129 | 130 | // Create a patch operation 131 | op := fileops.PatchOperation{ 132 | Type: params.Type, 133 | Path: params.Path, 134 | Content: params.Content, 135 | StartLine: params.StartLine, 136 | EndLine: params.EndLine, 137 | } 138 | 139 | // Apply the patch 140 | result, err := fileops.ApplyPatch(op) 141 | if err != nil { 142 | return "", fmt.Errorf("failed to apply patch: %w", err) 143 | } 144 | 145 | return fmt.Sprintf("Successfully patched %s (%d -> %d lines)", params.Path, result.OriginalLines, result.NewLines), nil 146 | } 147 | 148 | // ExecuteCommand executes a shell command 149 | func ExecuteCommand(args string) (string, error) { 150 | // Parse arguments 151 | var params struct { 152 | Command string `json:"command"` 153 | WorkingDir string `json:"workingDir"` 154 | Env map[string]string `json:"env"` 155 | Timeout int `json:"timeout"` 156 | AllowNetwork bool `json:"allowNetwork"` 157 | } 158 | if err := json.Unmarshal([]byte(args), ¶ms); err != nil { 159 | return "", fmt.Errorf("failed to parse arguments: %w", err) 160 | } 161 | 162 | // Check if command is valid 163 | if params.Command == "" { 164 | return "", fmt.Errorf("command parameter is required") 165 | } 166 | 167 | // Set working directory to current directory if not specified 168 | if params.WorkingDir == "" { 169 | var err error 170 | params.WorkingDir, err = os.Getwd() 171 | if err != nil { 172 | return "", fmt.Errorf("failed to get current directory: %w", err) 173 | } 174 | } 175 | 176 | // Set timeout 177 | timeout := time.Duration(params.Timeout) * time.Second 178 | if timeout == 0 { 179 | timeout = 60 * time.Second // Default timeout: 60 seconds 180 | } 181 | 182 | // Create sandbox options 183 | opts := sandbox.SandboxOptions{ 184 | Command: params.Command, 185 | WorkingDir: params.WorkingDir, 186 | AllowNetwork: params.AllowNetwork, 187 | AllowFileWrites: true, // Allow writes to the working directory 188 | Timeout: timeout, 189 | Env: params.Env, 190 | } 191 | 192 | // Create a sandbox 193 | sb := sandbox.NewSandbox() 194 | 195 | // Execute the command 196 | ctx := context.Background() 197 | result, err := sb.Execute(ctx, opts) 198 | if err != nil { 199 | return "", fmt.Errorf("failed to execute command: %w", err) 200 | } 201 | 202 | // Check if the command was successful 203 | if !result.Success { 204 | return "", fmt.Errorf("command failed with exit code %d: %s", result.ExitCode, result.Stderr) 205 | } 206 | 207 | return result.Stdout, nil 208 | } 209 | 210 | // ListDirectory lists the contents of a directory 211 | func ListDirectory(args string) (string, error) { 212 | // Parse arguments 213 | var params struct { 214 | Path string `json:"path"` 215 | } 216 | // Only unmarshal if args is not empty 217 | if args != "" { 218 | if err := json.Unmarshal([]byte(args), ¶ms); err != nil { 219 | return "", fmt.Errorf("failed to parse arguments: %w", err) 220 | } 221 | } 222 | 223 | // Use current directory if path is not specified 224 | if params.Path == "" { 225 | var err error 226 | params.Path, err = os.Getwd() 227 | if err != nil { 228 | return "", fmt.Errorf("failed to get current directory: %w", err) 229 | } 230 | } 231 | 232 | // Resolve the path 233 | absPath, err := filepath.Abs(params.Path) 234 | if err != nil { 235 | return "", fmt.Errorf("failed to resolve absolute path: %w", err) 236 | } 237 | 238 | // List the directory 239 | files, err := ioutil.ReadDir(absPath) 240 | if err != nil { 241 | return "", fmt.Errorf("failed to read directory: %w", err) 242 | } 243 | 244 | // Format the output 245 | var result string 246 | result = fmt.Sprintf("Contents of %s:\n\n", absPath) 247 | 248 | for _, file := range files { 249 | fileType := "file" 250 | if file.IsDir() { 251 | fileType = "dir" 252 | } 253 | 254 | size := file.Size() 255 | var sizeStr string 256 | if size < 1024 { 257 | sizeStr = fmt.Sprintf("%dB", size) 258 | } else if size < 1024*1024 { 259 | sizeStr = fmt.Sprintf("%.1fKB", float64(size)/1024) 260 | } else { 261 | sizeStr = fmt.Sprintf("%.1fMB", float64(size)/(1024*1024)) 262 | } 263 | 264 | result += fmt.Sprintf("[%s] %s (%s, %s)\n", fileType, file.Name(), sizeStr, file.ModTime().Format("2006-01-02 15:04:05")) 265 | } 266 | 267 | return result, nil 268 | } 269 | -------------------------------------------------------------------------------- /internal/logging/file_logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // FileLogger implements the Logger interface, writing logs asynchronously to a file. 12 | type FileLogger struct { 13 | logChan chan string 14 | file *os.File 15 | waiter sync.WaitGroup 16 | mu sync.Mutex // Protects file handle during close 17 | } 18 | 19 | // NewFileLogger creates a new logger that writes to the specified file path. 20 | // It creates the directory if it doesn't exist. 21 | func NewFileLogger(filePath string) (*FileLogger, error) { 22 | // Ensure the directory exists 23 | dir := filepath.Dir(filePath) 24 | if err := os.MkdirAll(dir, 0750); err != nil { 25 | return nil, fmt.Errorf("failed to create log directory %s: %w", dir, err) 26 | } 27 | 28 | // Open the file for appending, create if it doesn't exist 29 | f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to open log file %s: %w", filePath, err) 32 | } 33 | 34 | logger := &FileLogger{ 35 | logChan: make(chan string, 100), // Buffered channel 36 | file: f, 37 | } 38 | 39 | // Start the background writer goroutine 40 | logger.waiter.Add(1) 41 | go logger.writer() 42 | 43 | return logger, nil 44 | } 45 | 46 | // writer runs in a background goroutine, reading from logChan and writing to the file. 47 | func (l *FileLogger) writer() { 48 | defer l.waiter.Done() 49 | for msg := range l.logChan { 50 | l.mu.Lock() 51 | if l.file != nil { // Check if file is still open 52 | _, _ = l.file.WriteString(msg) // Ignore write errors for now 53 | } 54 | l.mu.Unlock() 55 | } 56 | // Channel closed, flush any remaining writes if necessary (though buffered channel helps) 57 | } 58 | 59 | // Log formats the message and sends it to the log channel. 60 | func (l *FileLogger) Log(format string, args ...interface{}) { 61 | // Format the message with a timestamp 62 | now := time.Now().Format("2006-01-02T15:04:05.000Z07:00") 63 | msg := fmt.Sprintf("[%s] %s\n", now, fmt.Sprintf(format, args...)) 64 | 65 | // Send to the channel (non-blocking if buffer is full, potentially dropping logs) 66 | // A select with a default could handle buffer full, but simple send is often ok. 67 | select { 68 | case l.logChan <- msg: 69 | default: 70 | // Log channel buffer is full, message dropped. Consider logging this drop to stderr? 71 | // fmt.Fprintf(os.Stderr, "Warning: Log channel buffer full, message dropped.\n") 72 | } 73 | } 74 | 75 | // IsEnabled returns true for FileLogger. 76 | func (l *FileLogger) IsEnabled() bool { 77 | return true 78 | } 79 | 80 | // Close signals the writer goroutine to exit and closes the log file. 81 | func (l *FileLogger) Close() error { 82 | // Signal the writer to stop by closing the channel 83 | close(l.logChan) 84 | 85 | // Wait for the writer goroutine to finish processing remaining messages 86 | l.waiter.Wait() 87 | 88 | // Safely close the file 89 | l.mu.Lock() 90 | defer l.mu.Unlock() 91 | if l.file != nil { 92 | err := l.file.Close() 93 | l.file = nil // Prevent further writes 94 | return err 95 | } 96 | return nil 97 | } 98 | 99 | // Ensure FileLogger implements the Logger interface. 100 | var _ Logger = (*FileLogger)(nil) 101 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | // Logger defines the interface for logging messages. 4 | type Logger interface { 5 | // Log formats and writes a log message. 6 | Log(format string, args ...interface{}) 7 | // IsEnabled returns true if the logger is active (e.g., debug mode is on). 8 | IsEnabled() bool 9 | // Close cleans up any resources used by the logger (e.g., closes file handles). 10 | Close() error 11 | } 12 | -------------------------------------------------------------------------------- /internal/logging/nil_logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | // NilLogger is a logger implementation that performs no operations. 4 | type NilLogger struct{} 5 | 6 | // NewNilLogger creates a new no-op logger. 7 | func NewNilLogger() *NilLogger { 8 | return &NilLogger{} 9 | } 10 | 11 | // Log does nothing. 12 | func (l *NilLogger) Log(format string, args ...interface{}) {} 13 | 14 | // IsEnabled always returns false. 15 | func (l *NilLogger) IsEnabled() bool { 16 | return false 17 | } 18 | 19 | // Close does nothing and returns nil error. 20 | func (l *NilLogger) Close() error { 21 | return nil 22 | } 23 | 24 | // Ensure NilLogger implements the Logger interface. 25 | var _ Logger = (*NilLogger)(nil) 26 | -------------------------------------------------------------------------------- /internal/patch/README.md: -------------------------------------------------------------------------------- 1 | # Patch Package 2 | 3 | The patch package provides functionality for parsing and applying patches to files. It supports a simple patch format that is easy for LLMs to generate and a more robust patch format for complex operations. 4 | 5 | ## Simple Patch Format 6 | 7 | The simple patch format is designed to be easily generated by LLMs. It has a straightforward structure: 8 | 9 | ``` 10 | *** Begin Patch 11 | *** Add File: path/to/new/file.go 12 | + package main 13 | + 14 | + func main() { 15 | + fmt.Println("Hello, world!") 16 | + } 17 | *** Update File: path/to/existing/file.go 18 | Context line 1 19 | Context line 2 20 | - line to remove 21 | + line to add 22 | Context line 3 23 | *** Delete File: path/to/unwanted/file.go 24 | *** End Patch 25 | ``` 26 | 27 | ### Operation Types 28 | 29 | 1. **Add File**: Creates a new file with the specified content. 30 | - Each line of content must be prefixed with `+` 31 | - The content is the line minus the `+` prefix 32 | 33 | 2. **Update File**: Modifies an existing file. 34 | - Context lines (no prefix): Used for locating where to make changes in the file 35 | - Lines to delete (prefix `-`): Lines to be removed 36 | - Lines to add (prefix `+`): Lines to be inserted 37 | 38 | 3. **Delete File**: Removes a file. 39 | 40 | 4. **Move File**: (Implemented in the function API, not directly in the patch format) 41 | - Can be achieved by combining other operations or using the API directly 42 | 43 | ## API Usage 44 | 45 | The patch package provides several functions to parse and apply patches: 46 | 47 | ### Parsing a Patch 48 | 49 | ```go 50 | // Parse a patch from a string 51 | operations, err := patch.ParseSimplePatch(patchText) 52 | if err != nil { 53 | return err 54 | } 55 | ``` 56 | 57 | ### Applying a Patch 58 | 59 | ```go 60 | // Process and apply a patch 61 | results, err := patch.ProcessPatch(patchText) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // Check the results 67 | for _, result := range results { 68 | if !result.Success { 69 | fmt.Printf("Failed to apply %s operation to %s: %v\n", 70 | result.OperationType, result.FilePath, result.Error) 71 | } 72 | } 73 | ``` 74 | 75 | ### Advanced API 76 | 77 | For more control over patch operations, you can use the lower-level API: 78 | 79 | ```go 80 | // Apply individual operations 81 | addOp := &patch.PatchOperation{ 82 | Type: "add", 83 | Path: "path/to/new/file.txt", 84 | Content: "This is file content", 85 | } 86 | err = patch.applyAddOperation(*addOp) 87 | 88 | moveOp := &patch.PatchOperation{ 89 | Type: "move", 90 | Path: "path/to/source.txt", 91 | MoveTo: "path/to/destination.txt", 92 | } 93 | err = patch.applyMoveOperation(*moveOp) 94 | ``` 95 | 96 | ## Robustness Features 97 | 98 | The patch system includes several features to improve robustness: 99 | 100 | 1. **Context-aware updates**: When updating a file, the system uses the context lines to locate the right position to make changes. 101 | 102 | 2. **Fuzzy matching**: If exact context matching fails, the system will attempt fuzzy matching to find the best location to apply changes. 103 | 104 | 3. **Directory creation**: When creating or moving files, missing directories are automatically created. 105 | 106 | 4. **Error handling**: Detailed error reporting helps identify issues when applying patches. 107 | 108 | ## Examples 109 | 110 | See the `internal/patch/integration_test.go` file for examples of how to use the patching system. -------------------------------------------------------------------------------- /internal/patch/integration_test.go: -------------------------------------------------------------------------------- 1 | package patch 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestIntegrationPatchProcess(t *testing.T) { 11 | // Skip if not in a normal test run to avoid filesystem changes 12 | if testing.Short() { 13 | t.Skip("Skipping integration test in short mode") 14 | } 15 | 16 | // Create a temporary directory for testing 17 | tempDir, err := os.MkdirTemp("", "patch-test") 18 | if err != nil { 19 | t.Fatalf("Failed to create temporary directory: %v", err) 20 | } 21 | defer os.RemoveAll(tempDir) 22 | 23 | // Create a test file 24 | existingFilePath := filepath.Join(tempDir, "existing.txt") 25 | existingContent := "Line 1\nLine 2\nLine 3\nLine 4\n" 26 | err = os.WriteFile(existingFilePath, []byte(existingContent), 0644) 27 | if err != nil { 28 | t.Fatalf("Failed to create test file: %v", err) 29 | } 30 | 31 | // Define paths we'll use 32 | newFilePath := filepath.Join(tempDir, "new.txt") 33 | movedFilePath := filepath.Join(tempDir, "moved.txt") 34 | 35 | // Test simple file creation 36 | addOp := &PatchOperation{ 37 | Type: "add", 38 | Path: newFilePath, 39 | Content: "This is a new file\nWith multiple lines", 40 | } 41 | err = applyAddOperation(*addOp) 42 | if err != nil { 43 | t.Fatalf("Failed to apply add operation: %v", err) 44 | } 45 | 46 | // Test simple file content update 47 | updatedContent := "Line 1\nLine 2\nLine 3 (modified)\nLine 4\n" 48 | err = os.WriteFile(existingFilePath, []byte(updatedContent), 0644) 49 | if err != nil { 50 | t.Fatalf("Failed to update test file: %v", err) 51 | } 52 | 53 | // Test file move 54 | moveOp := &PatchOperation{ 55 | Type: "move", 56 | Path: existingFilePath, 57 | MoveTo: movedFilePath, 58 | } 59 | err = applyMoveOperation(*moveOp) 60 | if err != nil { 61 | t.Fatalf("Failed to apply move operation: %v", err) 62 | } 63 | 64 | // Check if the new file was created with correct content 65 | newContent, err := os.ReadFile(newFilePath) 66 | if err != nil { 67 | t.Errorf("Failed to read new file: %v", err) 68 | } else if string(newContent) != "This is a new file\nWith multiple lines" { 69 | t.Errorf("New file content not correct:\nExpected: %q\nGot: %q", 70 | "This is a new file\nWith multiple lines", string(newContent)) 71 | } 72 | 73 | // Check if the file was moved with correct content 74 | movedContent, err := os.ReadFile(movedFilePath) 75 | if err != nil { 76 | t.Errorf("Failed to read moved file: %v", err) 77 | } else if !strings.Contains(string(movedContent), "Line 3 (modified)") { 78 | t.Errorf("Moved file content not correct:\nExpected to contain: %q\nGot: %q", 79 | "Line 3 (modified)", string(movedContent)) 80 | } 81 | 82 | // Check if the original file no longer exists 83 | _, err = os.Stat(existingFilePath) 84 | if !os.IsNotExist(err) { 85 | t.Errorf("Original file should no longer exist") 86 | } 87 | } 88 | 89 | // Test the full patch processing pipeline with a valid patch string 90 | func TestFullPatchProcessing(t *testing.T) { 91 | // Skip if not in a normal test run to avoid filesystem changes 92 | if testing.Short() { 93 | t.Skip("Skipping integration test in short mode") 94 | } 95 | 96 | // Create a temporary directory for testing 97 | tempDir, err := os.MkdirTemp("", "patch-test") 98 | if err != nil { 99 | t.Fatalf("Failed to create temporary directory: %v", err) 100 | } 101 | defer os.RemoveAll(tempDir) 102 | 103 | // Create a test file 104 | existingFilePath := filepath.Join(tempDir, "existing.txt") 105 | existingContent := "Line 1\nLine 2\nLine 3\nLine 4\n" 106 | err = os.WriteFile(existingFilePath, []byte(existingContent), 0644) 107 | if err != nil { 108 | t.Fatalf("Failed to create test file: %v", err) 109 | } 110 | 111 | // Define a patch that will: 112 | // 1. Update the existing file 113 | // 2. Create a new file 114 | patchText := `*** Begin Patch 115 | *** Add File: ` + filepath.Join(tempDir, "new.txt") + ` 116 | + This is a new file 117 | + With multiple lines 118 | *** End Patch` 119 | 120 | // Process the patch 121 | results, err := ProcessPatch(patchText) 122 | if err != nil { 123 | t.Fatalf("Failed to process patch: %v", err) 124 | } 125 | 126 | // Check the results 127 | if len(results) != 1 { 128 | t.Errorf("Expected 1 result, got %d", len(results)) 129 | } 130 | 131 | // Check if the new file was created 132 | newFilePath := filepath.Join(tempDir, "new.txt") 133 | newContent, err := os.ReadFile(newFilePath) 134 | if err != nil { 135 | t.Errorf("Failed to read new file: %v", err) 136 | } else if string(newContent) != "This is a new file\nWith multiple lines" { 137 | t.Errorf("New file content not correct:\nExpected: %q\nGot: %q", 138 | "This is a new file\nWith multiple lines", string(newContent)) 139 | } 140 | } 141 | 142 | func TestSimplePatchToRobustPatch(t *testing.T) { 143 | // Define a simple patch 144 | simplePatchText := `*** Begin Patch 145 | *** Add File: newfile.txt 146 | + Line 1 147 | + Line 2 148 | *** Update File: existingfile.txt 149 | Context line 1 150 | - Old line 151 | + New line 152 | Context line 2 153 | *** Delete File: oldfile.txt 154 | *** End Patch` 155 | 156 | // Parse the simple patch 157 | operations, err := ParseSimplePatch(simplePatchText) 158 | if err != nil { 159 | t.Fatalf("Failed to parse simple patch: %v", err) 160 | } 161 | 162 | // Convert to robust patch format 163 | robustPatchText := ConvertToCustomPatchFormat(operations) 164 | 165 | // Ensure it contains all expected markers 166 | expectedMarkers := []string{ 167 | PatchBeginMarker, 168 | AddFilePrefix + "newfile.txt", 169 | "+Line 1", 170 | "+Line 2", 171 | UpdateFilePrefix + "existingfile.txt", 172 | " Context line 1", 173 | " Context line 2", 174 | "-Old line", 175 | "+New line", 176 | EndOfFileMarker, 177 | DeleteFilePrefix + "oldfile.txt", 178 | PatchEndMarker, 179 | } 180 | 181 | for _, marker := range expectedMarkers { 182 | if !strings.Contains(robustPatchText, marker) { 183 | t.Errorf("Expected robust patch to contain %q, but it doesn't", marker) 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /internal/patch/parser.go: -------------------------------------------------------------------------------- 1 | package patch 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Constants for patch parsing 8 | const ( 9 | PatchBeginMarker = "*** Begin Patch" 10 | PatchEndMarker = "*** End Patch" 11 | UpdateFilePrefix = "*** Update File: " 12 | AddFilePrefix = "*** Add File: " 13 | DeleteFilePrefix = "*** Delete File: " 14 | MoveToPrefix = "*** Move to: " 15 | EndOfFileMarker = "*** End of File" 16 | ) 17 | 18 | // Parser is a struct that handles parsing patch text into operations 19 | type Parser struct { 20 | CurrentFiles map[string]string 21 | Lines []string 22 | Index int 23 | Patch Patch 24 | Fuzz int 25 | } 26 | 27 | // NewParser creates a new parser instance 28 | func NewParser(currentFiles map[string]string, lines []string) *Parser { 29 | return &Parser{ 30 | CurrentFiles: currentFiles, 31 | Lines: lines, 32 | Index: 0, 33 | Patch: Patch{ 34 | Actions: make(map[string]PatchAction), 35 | }, 36 | Fuzz: 0, 37 | } 38 | } 39 | 40 | // isDone checks if parsing is complete 41 | func (p *Parser) isDone(prefixes []string) bool { 42 | if p.Index >= len(p.Lines) { 43 | return true 44 | } 45 | 46 | if prefixes != nil { 47 | for _, prefix := range prefixes { 48 | if strings.HasPrefix(p.Lines[p.Index], prefix) { 49 | return true 50 | } 51 | } 52 | } 53 | 54 | return false 55 | } 56 | 57 | // startsWith checks if the current line starts with a prefix 58 | func (p *Parser) startsWith(prefixes []string) bool { 59 | if p.Index >= len(p.Lines) { 60 | return false 61 | } 62 | 63 | for _, prefix := range prefixes { 64 | if strings.HasPrefix(p.Lines[p.Index], prefix) { 65 | return true 66 | } 67 | } 68 | 69 | return false 70 | } 71 | 72 | // readString reads a line with the given prefix and returns the rest 73 | func (p *Parser) readString(prefix string, returnEverything bool) string { 74 | if p.Index >= len(p.Lines) { 75 | return "" 76 | } 77 | 78 | if strings.HasPrefix(p.Lines[p.Index], prefix) { 79 | text := p.Lines[p.Index] 80 | if !returnEverything { 81 | text = strings.TrimPrefix(text, prefix) 82 | } 83 | p.Index++ 84 | return text 85 | } 86 | 87 | return "" 88 | } 89 | 90 | // Parse parses the patch text into patch actions 91 | func (p *Parser) Parse() error { 92 | for !p.isDone([]string{PatchEndMarker}) { 93 | path := p.readString(UpdateFilePrefix, false) 94 | if path != "" { 95 | if _, exists := p.Patch.Actions[path]; exists { 96 | return &DiffError{Message: "Update File Error: Duplicate Path: " + path} 97 | } 98 | 99 | moveTo := p.readString(MoveToPrefix, false) 100 | 101 | if _, exists := p.CurrentFiles[path]; !exists { 102 | return &DiffError{Message: "Update File Error: Missing File: " + path} 103 | } 104 | 105 | text := p.CurrentFiles[path] 106 | action, err := p.parseUpdateFile(text) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if moveTo != "" { 112 | action.MovePath = moveTo 113 | } 114 | 115 | p.Patch.Actions[path] = action 116 | continue 117 | } 118 | 119 | path = p.readString(DeleteFilePrefix, false) 120 | if path != "" { 121 | if _, exists := p.Patch.Actions[path]; exists { 122 | return &DiffError{Message: "Delete File Error: Duplicate Path: " + path} 123 | } 124 | 125 | if _, exists := p.CurrentFiles[path]; !exists { 126 | return &DiffError{Message: "Delete File Error: Missing File: " + path} 127 | } 128 | 129 | p.Patch.Actions[path] = PatchAction{ 130 | Type: ActionDelete, 131 | FilePath: path, 132 | Chunks: []Chunk{}, 133 | } 134 | continue 135 | } 136 | 137 | path = p.readString(AddFilePrefix, false) 138 | if path != "" { 139 | if _, exists := p.Patch.Actions[path]; exists { 140 | return &DiffError{Message: "Add File Error: Duplicate Path: " + path} 141 | } 142 | 143 | if _, exists := p.CurrentFiles[path]; exists { 144 | return &DiffError{Message: "Add File Error: File already exists: " + path} 145 | } 146 | 147 | action, err := p.parseAddFile() 148 | if err != nil { 149 | return err 150 | } 151 | action.FilePath = path 152 | 153 | p.Patch.Actions[path] = action 154 | continue 155 | } 156 | 157 | if p.Index < len(p.Lines) { 158 | return &DiffError{Message: "Unknown Line: " + p.Lines[p.Index]} 159 | } else { 160 | return &DiffError{Message: "Unexpected end of patch"} 161 | } 162 | } 163 | 164 | if !p.startsWith([]string{PatchEndMarker}) { 165 | return &DiffError{Message: "Missing End Patch"} 166 | } 167 | 168 | p.Index++ 169 | return nil 170 | } 171 | 172 | // parseUpdateFile parses an update file section 173 | func (p *Parser) parseUpdateFile(text string) (PatchAction, error) { 174 | action := PatchAction{ 175 | Type: ActionUpdate, 176 | Chunks: []Chunk{}, 177 | } 178 | 179 | fileLines := strings.Split(text, "\n") 180 | index := 0 181 | 182 | for !p.isDone([]string{ 183 | PatchEndMarker, 184 | UpdateFilePrefix, 185 | DeleteFilePrefix, 186 | AddFilePrefix, 187 | EndOfFileMarker, 188 | }) { 189 | // Parse the modification markers in the current section 190 | oldContext, chunks, endIndex, eof, err := peekNextSection(p.Lines, p.Index) 191 | if err != nil { 192 | return action, err 193 | } 194 | 195 | // Try to find where in the file this change applies 196 | newIndex, fuzz := findContext(fileLines, oldContext, index, eof) 197 | if newIndex == -1 { 198 | return action, &DiffError{Message: "Could not find context in file"} 199 | } 200 | 201 | // Track the highest fuzziness score 202 | if fuzz > p.Fuzz { 203 | p.Fuzz = fuzz 204 | } 205 | 206 | // Adjust the chunks to point to the right line numbers 207 | for i := range chunks { 208 | chunks[i].OrigIndex = newIndex + chunks[i].OrigIndex 209 | } 210 | 211 | action.Chunks = append(action.Chunks, chunks...) 212 | index = newIndex + len(oldContext) 213 | p.Index = endIndex 214 | } 215 | 216 | // Skip the EndOfFileMarker if present 217 | if p.Index < len(p.Lines) && p.Lines[p.Index] == EndOfFileMarker { 218 | p.Index++ 219 | } 220 | 221 | return action, nil 222 | } 223 | 224 | // parseAddFile parses an add file section 225 | func (p *Parser) parseAddFile() (PatchAction, error) { 226 | var lines []string 227 | 228 | for !p.isDone([]string{ 229 | PatchEndMarker, 230 | UpdateFilePrefix, 231 | DeleteFilePrefix, 232 | AddFilePrefix, 233 | }) { 234 | line := p.readString("", true) 235 | if !strings.HasPrefix(line, "+") { 236 | return PatchAction{}, &DiffError{Message: "Invalid Add File Line: " + line} 237 | } 238 | 239 | // Remove the "+" prefix 240 | lines = append(lines, line[1:]) 241 | } 242 | 243 | return PatchAction{ 244 | Type: ActionAdd, 245 | NewFile: strings.Join(lines, "\n"), 246 | Chunks: []Chunk{}, 247 | }, nil 248 | } 249 | 250 | // TextToPatch converts patch text to a Patch object 251 | func TextToPatch(text string, orig map[string]string) (Patch, int, error) { 252 | lines := strings.Split(strings.TrimSpace(text), "\n") 253 | 254 | if len(lines) < 2 || !strings.HasPrefix(lines[0], PatchBeginMarker) || lines[len(lines)-1] != PatchEndMarker { 255 | return Patch{}, 0, &DiffError{Message: "Invalid patch format: must begin with '*** Begin Patch' and end with '*** End Patch'"} 256 | } 257 | 258 | parser := NewParser(orig, lines) 259 | parser.Index = 1 // Skip the Begin Patch line 260 | 261 | err := parser.Parse() 262 | if err != nil { 263 | return Patch{}, 0, err 264 | } 265 | 266 | return parser.Patch, parser.Fuzz, nil 267 | } 268 | 269 | // peekNextSection analyzes the next section of the patch to extract context and chunks 270 | func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool, error) { 271 | index := initialIndex 272 | var oldContext []string 273 | var delLines []string 274 | var insLines []string 275 | var chunks []Chunk 276 | mode := "keep" 277 | 278 | for index < len(lines) { 279 | s := lines[index] 280 | 281 | // End of section markers 282 | if strings.HasPrefix(s, "@@") || 283 | strings.HasPrefix(s, PatchEndMarker) || 284 | strings.HasPrefix(s, UpdateFilePrefix) || 285 | strings.HasPrefix(s, DeleteFilePrefix) || 286 | strings.HasPrefix(s, AddFilePrefix) || 287 | strings.HasPrefix(s, EndOfFileMarker) { 288 | break 289 | } 290 | 291 | // Skip separator markers 292 | if s == "***" { 293 | index++ 294 | continue 295 | } 296 | 297 | // Invalid section marker 298 | if strings.HasPrefix(s, "***") { 299 | return nil, nil, 0, false, &DiffError{Message: "Invalid Line: " + s} 300 | } 301 | 302 | index++ 303 | lastMode := mode 304 | line := s 305 | 306 | // Determine line type based on prefix 307 | switch { 308 | case strings.HasPrefix(line, "+"): 309 | mode = "add" 310 | line = line[1:] 311 | case strings.HasPrefix(line, "-"): 312 | mode = "delete" 313 | line = line[1:] 314 | case strings.HasPrefix(line, " "): 315 | mode = "keep" 316 | line = line[1:] 317 | default: 318 | // Be tolerant of missing leading space in context lines 319 | mode = "keep" 320 | } 321 | 322 | // When we switch modes, finalize the current chunk if needed 323 | if mode == "keep" && lastMode != mode { 324 | if len(insLines) > 0 || len(delLines) > 0 { 325 | chunks = append(chunks, Chunk{ 326 | OrigIndex: len(oldContext) - len(delLines), 327 | DelLines: delLines, 328 | InsLines: insLines, 329 | }) 330 | 331 | delLines = []string{} 332 | insLines = []string{} 333 | } 334 | } 335 | 336 | // Add the line to the appropriate collection 337 | if mode == "delete" { 338 | delLines = append(delLines, line) 339 | oldContext = append(oldContext, line) 340 | } else if mode == "add" { 341 | insLines = append(insLines, line) 342 | } else { 343 | oldContext = append(oldContext, line) 344 | } 345 | } 346 | 347 | // Finalize the last chunk if there are pending lines 348 | if len(insLines) > 0 || len(delLines) > 0 { 349 | chunks = append(chunks, Chunk{ 350 | OrigIndex: len(oldContext) - len(delLines), 351 | DelLines: delLines, 352 | InsLines: insLines, 353 | }) 354 | } 355 | 356 | // Check if we reached end of file marker 357 | eof := false 358 | if index < len(lines) && lines[index] == EndOfFileMarker { 359 | index++ 360 | eof = true 361 | } 362 | 363 | return oldContext, chunks, index, eof, nil 364 | } 365 | 366 | // findContext finds the best match for a set of context lines within a file 367 | func findContext(lines []string, context []string, start int, eof bool) (int, int) { 368 | if len(context) == 0 { 369 | return start, 0 370 | } 371 | 372 | // If we're at EOF, try searching from the end of the file first 373 | if eof { 374 | if len(lines) >= len(context) { 375 | newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context)) 376 | if newIndex != -1 { 377 | return newIndex, fuzz 378 | } 379 | } 380 | 381 | // If that fails, try from the start 382 | newIndex, fuzz := findContextCore(lines, context, start) 383 | if newIndex != -1 { 384 | // Add fuzz penalty for not being at EOF 385 | return newIndex, fuzz + 10000 386 | } 387 | 388 | return -1, 0 389 | } 390 | 391 | // Normal search from the start position 392 | return findContextCore(lines, context, start) 393 | } 394 | 395 | // findContextCore implements the core context matching logic with different levels of fuzzy matching 396 | func findContextCore(lines []string, context []string, start int) (int, int) { 397 | if len(context) == 0 { 398 | return start, 0 399 | } 400 | 401 | // Try exact match first 402 | for i := start; i <= len(lines)-len(context); i++ { 403 | match := true 404 | for j := 0; j < len(context); j++ { 405 | if lines[i+j] != context[j] { 406 | match = false 407 | break 408 | } 409 | } 410 | if match { 411 | return i, 0 412 | } 413 | } 414 | 415 | // Try trimming line endings 416 | for i := start; i <= len(lines)-len(context); i++ { 417 | match := true 418 | for j := 0; j < len(context); j++ { 419 | if strings.TrimRight(lines[i+j], " \t") != strings.TrimRight(context[j], " \t") { 420 | match = false 421 | break 422 | } 423 | } 424 | if match { 425 | return i, 1 426 | } 427 | } 428 | 429 | // Try fully trimmed lines 430 | for i := start; i <= len(lines)-len(context); i++ { 431 | match := true 432 | for j := 0; j < len(context); j++ { 433 | if strings.TrimSpace(lines[i+j]) != strings.TrimSpace(context[j]) { 434 | match = false 435 | break 436 | } 437 | } 438 | if match { 439 | return i, 100 440 | } 441 | } 442 | 443 | // No match found 444 | return -1, 0 445 | } 446 | -------------------------------------------------------------------------------- /internal/patch/parser_test.go: -------------------------------------------------------------------------------- 1 | package patch 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestTextToPatch(t *testing.T) { 9 | // Define a basic patch 10 | patchText := `*** Begin Patch 11 | *** Update File: testfile.txt 12 | Line 1 13 | Line 2 14 | -Line 3 15 | +Line 3 modified 16 | Line 4 17 | *** End Patch` 18 | 19 | // Create a mock file system 20 | mockFiles := map[string]string{ 21 | "testfile.txt": "Line 1\nLine 2\nLine 3\nLine 4", 22 | } 23 | 24 | // Parse the patch 25 | patch, fuzz, err := TextToPatch(patchText, mockFiles) 26 | if err != nil { 27 | t.Fatalf("Failed to parse patch: %v", err) 28 | } 29 | 30 | // Check if the patch was parsed correctly 31 | if len(patch.Actions) != 1 { 32 | t.Errorf("Expected 1 action, got %d", len(patch.Actions)) 33 | } 34 | 35 | // Check the action 36 | action, ok := patch.Actions["testfile.txt"] 37 | if !ok { 38 | t.Fatalf("Action for testfile.txt not found") 39 | } 40 | 41 | if action.Type != ActionUpdate { 42 | t.Errorf("Expected action type %s, got %s", ActionUpdate, action.Type) 43 | } 44 | 45 | if len(action.Chunks) != 1 { 46 | t.Fatalf("Expected 1 chunk, got %d", len(action.Chunks)) 47 | } 48 | 49 | // Check the chunk 50 | chunk := action.Chunks[0] 51 | if chunk.OrigIndex != 2 { // 0-indexed 52 | t.Errorf("Expected original index 2, got %d", chunk.OrigIndex) 53 | } 54 | 55 | if len(chunk.DelLines) != 1 || chunk.DelLines[0] != "Line 3" { 56 | t.Errorf("Deleted lines not correct: %v", chunk.DelLines) 57 | } 58 | 59 | if len(chunk.InsLines) != 1 || chunk.InsLines[0] != "Line 3 modified" { 60 | t.Errorf("Inserted lines not correct: %v", chunk.InsLines) 61 | } 62 | 63 | // Check fuzz level 64 | if fuzz != 0 { 65 | t.Errorf("Expected fuzz level 0, got %d", fuzz) 66 | } 67 | } 68 | 69 | func TestUpdateFileWithChunks(t *testing.T) { 70 | // Original file content 71 | text := "Line 1\nLine 2\nLine 3\nLine 4" 72 | 73 | // Create a patch action 74 | action := PatchAction{ 75 | Type: ActionUpdate, 76 | Chunks: []Chunk{ 77 | { 78 | OrigIndex: 2, // 0-indexed, Line 3 79 | DelLines: []string{"Line 3"}, 80 | InsLines: []string{"Line 3 modified"}, 81 | }, 82 | }, 83 | } 84 | 85 | // Apply the chunks 86 | result, err := UpdateFileWithChunks(text, action, "testfile.txt") 87 | if err != nil { 88 | t.Fatalf("Failed to update file with chunks: %v", err) 89 | } 90 | 91 | // Expected result 92 | expected := "Line 1\nLine 2\nLine 3 modified\nLine 4" 93 | 94 | // Check if the result is correct 95 | if result != expected { 96 | t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result) 97 | } 98 | } 99 | 100 | func TestParseSimplePatch(t *testing.T) { 101 | // Define a simple patch 102 | patchText := `*** Begin Patch 103 | *** Add File: newfile.txt 104 | + Line 1 105 | + Line 2 106 | *** Update File: existingfile.txt 107 | Context line 1 108 | - Old line 109 | + New line 110 | Context line 2 111 | *** Delete File: oldfile.txt 112 | *** End Patch` 113 | 114 | // Parse the patch 115 | operations, err := ParseSimplePatch(patchText) 116 | if err != nil { 117 | t.Fatalf("Failed to parse simple patch: %v", err) 118 | } 119 | 120 | // Check if the operations were parsed correctly 121 | if len(operations) != 3 { 122 | t.Errorf("Expected 3 operations, got %d", len(operations)) 123 | } 124 | 125 | // Check the add operation 126 | if operations[0].Type != "add" || operations[0].Path != "newfile.txt" { 127 | t.Errorf("Add operation not parsed correctly: %s, %s", operations[0].Type, operations[0].Path) 128 | } 129 | expectedContent := "Line 1\nLine 2" 130 | if operations[0].Content != expectedContent { 131 | t.Errorf("Add operation content not correct:\nExpected: %q\nGot: %q", expectedContent, operations[0].Content) 132 | } 133 | 134 | // Check the update operation 135 | if operations[1].Type != "update" || operations[1].Path != "existingfile.txt" { 136 | t.Errorf("Update operation not parsed correctly: %s, %s", operations[1].Type, operations[1].Path) 137 | } 138 | if len(operations[1].Context) != 2 || operations[1].Context[0] != "Context line 1" || operations[1].Context[1] != "Context line 2" { 139 | t.Errorf("Update operation context not correct: %v", operations[1].Context) 140 | } 141 | if len(operations[1].DelLines) != 1 || operations[1].DelLines[0] != "Old line" { 142 | t.Errorf("Update operation deleted lines not correct: %v", operations[1].DelLines) 143 | } 144 | if len(operations[1].AddLines) != 1 || operations[1].AddLines[0] != "New line" { 145 | t.Errorf("Update operation added lines not correct: %v", operations[1].AddLines) 146 | } 147 | 148 | // Check the delete operation 149 | if operations[2].Type != "delete" || operations[2].Path != "oldfile.txt" { 150 | t.Errorf("Delete operation not parsed correctly: %s, %s", operations[2].Type, operations[2].Path) 151 | } 152 | } 153 | 154 | func TestConvertToCustomPatchFormat(t *testing.T) { 155 | // Create a slice of PatchOperation 156 | operations := []*PatchOperation{ 157 | { 158 | Type: "add", 159 | Path: "newfile.txt", 160 | Content: "Line 1\nLine 2", 161 | }, 162 | { 163 | Type: "update", 164 | Path: "existingfile.txt", 165 | Context: []string{"Context line 1", "Context line 2"}, 166 | DelLines: []string{"Old line"}, 167 | AddLines: []string{"New line"}, 168 | }, 169 | { 170 | Type: "delete", 171 | Path: "oldfile.txt", 172 | }, 173 | } 174 | 175 | // Convert to custom patch format 176 | patchText := ConvertToCustomPatchFormat(operations) 177 | 178 | // Make sure it contains the expected markers 179 | expectedMarkers := []string{ 180 | PatchBeginMarker, 181 | AddFilePrefix + "newfile.txt", 182 | "+Line 1", 183 | "+Line 2", 184 | UpdateFilePrefix + "existingfile.txt", 185 | " Context line 1", 186 | " Context line 2", 187 | "-Old line", 188 | "+New line", 189 | EndOfFileMarker, 190 | DeleteFilePrefix + "oldfile.txt", 191 | PatchEndMarker, 192 | } 193 | 194 | for _, marker := range expectedMarkers { 195 | if !strings.Contains(patchText, marker) { 196 | t.Errorf("Expected patch to contain %q, but it doesn't", marker) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /internal/patch/types.go: -------------------------------------------------------------------------------- 1 | package patch 2 | 3 | // ActionType defines the type of patch action 4 | type ActionType string 5 | 6 | const ( 7 | // ActionAdd represents adding a new file 8 | ActionAdd ActionType = "add" 9 | // ActionDelete represents deleting an existing file 10 | ActionDelete ActionType = "delete" 11 | // ActionUpdate represents updating an existing file 12 | ActionUpdate ActionType = "update" 13 | ) 14 | 15 | // Chunk represents a change in a specific part of a file 16 | type Chunk struct { 17 | OrigIndex int // Line index in the original file 18 | DelLines []string // Lines to be deleted 19 | InsLines []string // Lines to be inserted 20 | } 21 | 22 | // PatchAction represents an action to be performed on a file 23 | type PatchAction struct { 24 | Type ActionType 25 | FilePath string 26 | NewFile string // Content for new files (only used for ActionAdd) 27 | Chunks []Chunk // Chunks for updates 28 | MovePath string // Path to move the file to (optional) 29 | } 30 | 31 | // Patch represents a collection of actions to be applied 32 | type Patch struct { 33 | Actions map[string]PatchAction // Map of filepath to action 34 | } 35 | 36 | // FileChange represents the change to be made to a file 37 | type FileChange struct { 38 | Type ActionType 39 | OldContent string 40 | NewContent string 41 | MovePath string 42 | } 43 | 44 | // Commit represents a set of changes to be applied to files 45 | type Commit struct { 46 | Changes map[string]FileChange // Map of filepath to change 47 | } 48 | 49 | // PatchResult represents the result of a patch operation 50 | type PatchResult struct { 51 | FilePath string 52 | OperationType string 53 | Success bool 54 | Error error 55 | Message string 56 | LineStats struct { 57 | Added int 58 | Deleted int 59 | Original int 60 | New int 61 | } 62 | } 63 | 64 | // DiffError represents an error that occurred during patch processing 65 | type DiffError struct { 66 | Message string 67 | } 68 | 69 | func (e DiffError) Error() string { 70 | return e.Message 71 | } 72 | -------------------------------------------------------------------------------- /internal/patch/utils.go: -------------------------------------------------------------------------------- 1 | package patch 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // ParseSimplePatch parses a simplified patch format that's easier for LLMs to generate 10 | // It handles a simple format like: 11 | // ``` 12 | // *** Begin Patch 13 | // *** Add File: path/to/new/file.go 14 | // + package main 15 | // + 16 | // + func main() { 17 | // + fmt.Println("Hello, world!") 18 | // + } 19 | // *** Update File: path/to/existing/file.go 20 | // Context line 1 21 | // Context line 2 22 | // - line to remove 23 | // + line to add 24 | // Context line 3 25 | // *** Delete File: path/to/unwanted/file.go 26 | // *** End Patch 27 | // ``` 28 | func ParseSimplePatch(patchText string) ([]*PatchOperation, error) { 29 | // Define regex patterns for parsing 30 | patchStartRegex := regexp.MustCompile(`^\*\*\* Begin Patch\s*$`) 31 | updateFileRegex := regexp.MustCompile(`^\*\*\* Update File:\s+(.+)\s*$`) 32 | addFileRegex := regexp.MustCompile(`^\*\*\* Add File:\s+(.+)\s*$`) 33 | deleteFileRegex := regexp.MustCompile(`^\*\*\* Delete File:\s+(.+)\s*$`) 34 | endRegex := regexp.MustCompile(`^\*\*\* End Patch\s*$`) 35 | addLineRegex := regexp.MustCompile(`^\+\s(.*)$`) 36 | delLineRegex := regexp.MustCompile(`^-\s(.*)$`) 37 | 38 | // Split text into lines 39 | lines := strings.Split(strings.TrimSpace(patchText), "\n") 40 | 41 | // Check if it starts with Begin Patch 42 | if len(lines) == 0 || !patchStartRegex.MatchString(lines[0]) { 43 | return nil, fmt.Errorf("patch must start with '*** Begin Patch'") 44 | } 45 | 46 | // Check if it ends with End Patch 47 | if len(lines) < 2 || !endRegex.MatchString(lines[len(lines)-1]) { 48 | return nil, fmt.Errorf("patch must end with '*** End Patch'") 49 | } 50 | 51 | // Process the lines and create operations 52 | var operations []*PatchOperation 53 | var currentOp *PatchOperation 54 | 55 | // Skip first line (Begin Patch) and last line (End Patch) 56 | for i := 1; i < len(lines)-1; i++ { 57 | line := lines[i] 58 | 59 | // Check for operation headers 60 | if matches := updateFileRegex.FindStringSubmatch(line); len(matches) > 1 { 61 | // Finish previous operation if any 62 | if currentOp != nil { 63 | operations = append(operations, currentOp) 64 | } 65 | 66 | // Start new update operation 67 | currentOp = &PatchOperation{ 68 | Type: "update", 69 | Path: matches[1], 70 | // These will be filled in as we process lines 71 | Context: make([]string, 0), 72 | AddLines: make([]string, 0), 73 | DelLines: make([]string, 0), 74 | } 75 | continue 76 | } 77 | 78 | if matches := addFileRegex.FindStringSubmatch(line); len(matches) > 1 { 79 | // Finish previous operation if any 80 | if currentOp != nil { 81 | operations = append(operations, currentOp) 82 | } 83 | 84 | // Start new add operation 85 | currentOp = &PatchOperation{ 86 | Type: "add", 87 | Path: matches[1], 88 | Content: "", 89 | } 90 | continue 91 | } 92 | 93 | if matches := deleteFileRegex.FindStringSubmatch(line); len(matches) > 1 { 94 | // Finish previous operation if any 95 | if currentOp != nil { 96 | operations = append(operations, currentOp) 97 | } 98 | 99 | // Add delete operation directly 100 | operations = append(operations, &PatchOperation{ 101 | Type: "delete", 102 | Path: matches[1], 103 | }) 104 | 105 | // Reset current operation 106 | currentOp = nil 107 | continue 108 | } 109 | 110 | // Process content based on the current operation 111 | if currentOp == nil { 112 | continue // Skip lines not in an operation 113 | } 114 | 115 | // Handle content based on operation type 116 | switch currentOp.Type { 117 | case "add": 118 | if matches := addLineRegex.FindStringSubmatch(line); len(matches) > 0 { 119 | // Add file content (extracted from regex group) 120 | content := matches[1] 121 | if currentOp.Content == "" { 122 | currentOp.Content = content 123 | } else { 124 | currentOp.Content += "\n" + content 125 | } 126 | } 127 | 128 | case "update": 129 | if matches := addLineRegex.FindStringSubmatch(line); len(matches) > 0 { 130 | // Added line (extracted from regex group) 131 | currentOp.AddLines = append(currentOp.AddLines, matches[1]) 132 | } else if matches := delLineRegex.FindStringSubmatch(line); len(matches) > 0 { 133 | // Deleted line (extracted from regex group) 134 | currentOp.DelLines = append(currentOp.DelLines, matches[1]) 135 | } else { 136 | // Context line 137 | currentOp.Context = append(currentOp.Context, line) 138 | } 139 | } 140 | } 141 | 142 | // Add the last operation if any 143 | if currentOp != nil { 144 | operations = append(operations, currentOp) 145 | } 146 | 147 | return operations, nil 148 | } 149 | 150 | // PatchOperation represents a simplified patch operation 151 | // This is used as an intermediate representation that can be converted to the more powerful 152 | // format used by the robust patching system 153 | type PatchOperation struct { 154 | Type string // "update", "add", "delete", "move" 155 | Path string // Path to the file 156 | Content string // Content for the file or hunk 157 | Context []string // Context lines for fuzzy matching 158 | AddLines []string // Lines to add 159 | DelLines []string // Lines to delete 160 | MoveTo string // Path to move the file to (for move operations) 161 | } 162 | 163 | // ConvertToCustomPatchFormat converts a slice of PatchOperation to the robust patch format 164 | func ConvertToCustomPatchFormat(operations []*PatchOperation) string { 165 | var sb strings.Builder 166 | 167 | sb.WriteString(PatchBeginMarker + "\n") 168 | 169 | for _, op := range operations { 170 | switch op.Type { 171 | case "add": 172 | sb.WriteString(AddFilePrefix + op.Path + "\n") 173 | for _, line := range strings.Split(op.Content, "\n") { 174 | sb.WriteString("+" + line + "\n") 175 | } 176 | 177 | case "delete": 178 | sb.WriteString(DeleteFilePrefix + op.Path + "\n") 179 | 180 | case "update": 181 | sb.WriteString(UpdateFilePrefix + op.Path + "\n") 182 | 183 | // Write context and modification lines 184 | // We'll need to interleave them to preserve order 185 | for _, line := range op.Context { 186 | sb.WriteString(" " + line + "\n") 187 | } 188 | 189 | for i := 0; i < len(op.DelLines); i++ { 190 | sb.WriteString("-" + op.DelLines[i] + "\n") 191 | } 192 | 193 | for i := 0; i < len(op.AddLines); i++ { 194 | sb.WriteString("+" + op.AddLines[i] + "\n") 195 | } 196 | 197 | sb.WriteString(EndOfFileMarker + "\n") 198 | } 199 | } 200 | 201 | sb.WriteString(PatchEndMarker + "\n") 202 | 203 | return sb.String() 204 | } 205 | 206 | // GetLinesAddedDeleted counts how many lines were added and deleted 207 | func GetLinesAddedDeleted(results []*PatchResult) (int, int) { 208 | added := 0 209 | deleted := 0 210 | 211 | for _, result := range results { 212 | added += result.LineStats.Added 213 | if result.OperationType == "delete" { 214 | deleted += result.LineStats.Original 215 | } else if result.OperationType == "update" { 216 | deleted += result.LineStats.Original - result.LineStats.New 217 | } 218 | } 219 | 220 | return added, deleted 221 | } 222 | -------------------------------------------------------------------------------- /internal/sandbox/basic.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "time" 11 | ) 12 | 13 | // BasicSandbox implements the Sandbox interface with minimal restrictions 14 | // It's intended as a fallback when platform-specific sandboxes are not available 15 | type BasicSandbox struct{} 16 | 17 | // NewBasicSandbox creates a new basic sandbox 18 | func NewBasicSandbox() Sandbox { 19 | return &BasicSandbox{} 20 | } 21 | 22 | // Name returns the name of the sandbox 23 | func (s *BasicSandbox) Name() string { 24 | return "Basic Environment Sandbox" 25 | } 26 | 27 | // IsAvailable always returns true since this is a fallback implementation 28 | func (s *BasicSandbox) IsAvailable() bool { 29 | return true 30 | } 31 | 32 | // Execute runs a command with basic restrictions 33 | func (s *BasicSandbox) Execute(ctx context.Context, opts SandboxOptions) (*CommandResult, error) { 34 | startTime := time.Now() 35 | 36 | // Build the command 37 | cmd := exec.CommandContext(ctx, "/bin/sh", "-c", opts.Command) 38 | cmd.Dir = opts.WorkingDir 39 | 40 | // Set up restricted environment 41 | env := []string{ 42 | "PATH=/usr/local/bin:/usr/bin:/bin", 43 | "HOME=" + os.Getenv("HOME"), 44 | "USER=" + os.Getenv("USER"), 45 | "TERM=" + os.Getenv("TERM"), 46 | "LANG=" + os.Getenv("LANG"), 47 | "CODEX_SANDBOX=1", // Mark that we're running in a sandbox 48 | } 49 | 50 | // Add custom environment variables 51 | if opts.Env != nil { 52 | for k, v := range opts.Env { 53 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 54 | } 55 | } 56 | 57 | cmd.Env = env 58 | 59 | // Set up stdin, stdout, stderr 60 | if opts.Stdin != nil { 61 | cmd.Stdin = opts.Stdin 62 | } 63 | 64 | var stdout, stderr bytes.Buffer 65 | if opts.Stdout != nil { 66 | cmd.Stdout = io.MultiWriter(&stdout, opts.Stdout) 67 | } else { 68 | cmd.Stdout = &stdout 69 | } 70 | 71 | if opts.Stderr != nil { 72 | cmd.Stderr = io.MultiWriter(&stderr, opts.Stderr) 73 | } else { 74 | cmd.Stderr = &stderr 75 | } 76 | 77 | // Apply timeout if specified 78 | if opts.Timeout > 0 { 79 | var cancel context.CancelFunc 80 | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) 81 | defer cancel() 82 | cmd = exec.CommandContext(ctx, "/bin/sh", "-c", opts.Command) 83 | cmd.Dir = opts.WorkingDir 84 | cmd.Env = env 85 | if opts.Stdin != nil { 86 | cmd.Stdin = opts.Stdin 87 | } 88 | cmd.Stdout = cmd.Stdout 89 | cmd.Stderr = cmd.Stderr 90 | } 91 | 92 | // Execute the command 93 | err := cmd.Run() 94 | duration := time.Since(startTime) 95 | 96 | // Build the result 97 | result := &CommandResult{ 98 | Stdout: stdout.String(), 99 | Stderr: stderr.String(), 100 | Duration: duration, 101 | Command: opts.Command, 102 | WorkingDir: opts.WorkingDir, 103 | Success: err == nil, 104 | } 105 | 106 | if err != nil { 107 | result.Error = err 108 | if exitErr, ok := err.(*exec.ExitError); ok { 109 | result.ExitCode = exitErr.ExitCode() 110 | } else { 111 | result.ExitCode = -1 112 | } 113 | } 114 | 115 | return result, nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/sandbox/interface.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | ) 8 | 9 | // CommandResult represents the result of executing a command 10 | type CommandResult struct { 11 | Stdout string 12 | Stderr string 13 | ExitCode int 14 | Success bool 15 | Error error 16 | Duration time.Duration 17 | Command string 18 | WorkingDir string 19 | } 20 | 21 | // SandboxOptions configures the sandbox behavior 22 | type SandboxOptions struct { 23 | // Command to execute 24 | Command string 25 | 26 | // Working directory 27 | WorkingDir string 28 | 29 | // Allow network access 30 | AllowNetwork bool 31 | 32 | // Allow file writes outside working directory 33 | AllowFileWrites bool 34 | 35 | // Timeout for command execution 36 | Timeout time.Duration 37 | 38 | // Environment variables to set 39 | Env map[string]string 40 | 41 | // Input to provide to the command 42 | Stdin io.Reader 43 | 44 | // Capture stdout and stderr 45 | Stdout io.Writer 46 | Stderr io.Writer 47 | } 48 | 49 | // Sandbox defines the interface for sandboxed command execution 50 | type Sandbox interface { 51 | // Execute runs a command in the sandbox with the given options 52 | Execute(ctx context.Context, opts SandboxOptions) (*CommandResult, error) 53 | 54 | // IsAvailable checks if this sandbox implementation is available on the current system 55 | IsAvailable() bool 56 | 57 | // Name returns the name of the sandbox implementation 58 | Name() string 59 | } 60 | 61 | // NewSandbox creates a new sandbox based on the current platform 62 | func NewSandbox() Sandbox { 63 | // Try platform-specific sandboxes in order of preference 64 | if sb := NewMacOSSandbox(); sb.IsAvailable() { 65 | return sb 66 | } 67 | 68 | if sb := NewLinuxSandbox(); sb.IsAvailable() { 69 | return sb 70 | } 71 | 72 | // Fall back to basic sandbox 73 | return NewBasicSandbox() 74 | } 75 | -------------------------------------------------------------------------------- /internal/sandbox/linux.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "time" 12 | ) 13 | 14 | // LinuxSandbox implements the Sandbox interface using environment restrictions 15 | type LinuxSandbox struct{} 16 | 17 | // NewLinuxSandbox creates a new Linux sandbox 18 | func NewLinuxSandbox() Sandbox { 19 | return &LinuxSandbox{} 20 | } 21 | 22 | // Name returns the name of the sandbox 23 | func (s *LinuxSandbox) Name() string { 24 | return "Linux Environment Sandbox" 25 | } 26 | 27 | // IsAvailable checks if this sandbox is available on the system 28 | func (s *LinuxSandbox) IsAvailable() bool { 29 | return runtime.GOOS == "linux" 30 | } 31 | 32 | // Execute runs a command in the sandbox 33 | func (s *LinuxSandbox) Execute(ctx context.Context, opts SandboxOptions) (*CommandResult, error) { 34 | startTime := time.Now() 35 | 36 | // Build the command 37 | cmd := exec.CommandContext(ctx, "/bin/sh", "-c", opts.Command) 38 | cmd.Dir = opts.WorkingDir 39 | 40 | // Set up restricted environment 41 | env := []string{ 42 | "PATH=/usr/local/bin:/usr/bin:/bin", 43 | "HOME=" + os.Getenv("HOME"), 44 | "USER=" + os.Getenv("USER"), 45 | "TERM=" + os.Getenv("TERM"), 46 | "CODEX_SANDBOX=1", // Mark that we're running in a sandbox 47 | } 48 | 49 | // Add custom environment variables 50 | if opts.Env != nil { 51 | for k, v := range opts.Env { 52 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 53 | } 54 | } 55 | 56 | cmd.Env = env 57 | 58 | // Set up stdin, stdout, stderr 59 | if opts.Stdin != nil { 60 | cmd.Stdin = opts.Stdin 61 | } 62 | 63 | var stdout, stderr bytes.Buffer 64 | if opts.Stdout != nil { 65 | cmd.Stdout = io.MultiWriter(&stdout, opts.Stdout) 66 | } else { 67 | cmd.Stdout = &stdout 68 | } 69 | 70 | if opts.Stderr != nil { 71 | cmd.Stderr = io.MultiWriter(&stderr, opts.Stderr) 72 | } else { 73 | cmd.Stderr = &stderr 74 | } 75 | 76 | // Execute the command 77 | err := cmd.Run() 78 | duration := time.Since(startTime) 79 | 80 | // Build the result 81 | result := &CommandResult{ 82 | Stdout: stdout.String(), 83 | Stderr: stderr.String(), 84 | Duration: duration, 85 | Command: opts.Command, 86 | WorkingDir: opts.WorkingDir, 87 | Success: err == nil, 88 | } 89 | 90 | if err != nil { 91 | result.Error = err 92 | if exitErr, ok := err.(*exec.ExitError); ok { 93 | result.ExitCode = exitErr.ExitCode() 94 | } else { 95 | result.ExitCode = -1 96 | } 97 | } 98 | 99 | return result, nil 100 | } 101 | 102 | // Future enhancement: Implement UnshareCommand for namespaces isolation 103 | // func (s *LinuxSandbox) createUnshareCommand(opts SandboxOptions) (*exec.Cmd, error) { 104 | // // This would use Linux namespaces with unshare for better isolation 105 | // // Similar to the Docker approach but without requiring Docker 106 | // // Implementation would use "unshare" command with appropriate flags 107 | // // for mount, network, pid isolation 108 | // } 109 | -------------------------------------------------------------------------------- /internal/sandbox/macos.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "time" 12 | ) 13 | 14 | // MacOSSandbox implements the Sandbox interface using macOS Seatbelt 15 | type MacOSSandbox struct{} 16 | 17 | // NewMacOSSandbox creates a new macOS sandbox 18 | func NewMacOSSandbox() Sandbox { 19 | return &MacOSSandbox{} 20 | } 21 | 22 | // Name returns the name of the sandbox 23 | func (s *MacOSSandbox) Name() string { 24 | return "macOS Seatbelt" 25 | } 26 | 27 | // IsAvailable checks if sandbox-exec is available on the system 28 | func (s *MacOSSandbox) IsAvailable() bool { 29 | _, err := exec.LookPath("sandbox-exec") 30 | return err == nil 31 | } 32 | 33 | // Execute runs a command in the sandbox 34 | func (s *MacOSSandbox) Execute(ctx context.Context, opts SandboxOptions) (*CommandResult, error) { 35 | startTime := time.Now() 36 | 37 | // Create the sandbox profile 38 | profile, err := s.createSandboxProfile(opts) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to create sandbox profile: %w", err) 41 | } 42 | 43 | // Write the profile to a temporary file 44 | profileFile, err := os.CreateTemp("", "codex-sandbox-*.sb") 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to create temporary file for sandbox profile: %w", err) 47 | } 48 | defer os.Remove(profileFile.Name()) 49 | 50 | if _, err := profileFile.WriteString(profile); err != nil { 51 | return nil, fmt.Errorf("failed to write sandbox profile: %w", err) 52 | } 53 | if err := profileFile.Close(); err != nil { 54 | return nil, fmt.Errorf("failed to close sandbox profile file: %w", err) 55 | } 56 | 57 | // Build the command 58 | cmd := exec.CommandContext(ctx, "sandbox-exec", "-f", profileFile.Name(), "/bin/sh", "-c", opts.Command) 59 | cmd.Dir = opts.WorkingDir 60 | 61 | // Set up environment 62 | if opts.Env != nil { 63 | env := os.Environ() 64 | for k, v := range opts.Env { 65 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 66 | } 67 | cmd.Env = env 68 | } 69 | 70 | // Set up stdin, stdout, stderr 71 | if opts.Stdin != nil { 72 | cmd.Stdin = opts.Stdin 73 | } 74 | 75 | var stdout, stderr bytes.Buffer 76 | if opts.Stdout != nil { 77 | cmd.Stdout = io.MultiWriter(&stdout, opts.Stdout) 78 | } else { 79 | cmd.Stdout = &stdout 80 | } 81 | 82 | if opts.Stderr != nil { 83 | cmd.Stderr = io.MultiWriter(&stderr, opts.Stderr) 84 | } else { 85 | cmd.Stderr = &stderr 86 | } 87 | 88 | // Execute the command 89 | err = cmd.Run() 90 | duration := time.Since(startTime) 91 | 92 | // Build the result 93 | result := &CommandResult{ 94 | Stdout: stdout.String(), 95 | Stderr: stderr.String(), 96 | Duration: duration, 97 | Command: opts.Command, 98 | WorkingDir: opts.WorkingDir, 99 | Success: err == nil, 100 | } 101 | 102 | if err != nil { 103 | result.Error = err 104 | if exitErr, ok := err.(*exec.ExitError); ok { 105 | result.ExitCode = exitErr.ExitCode() 106 | } else { 107 | result.ExitCode = -1 108 | } 109 | } 110 | 111 | return result, nil 112 | } 113 | 114 | // createSandboxProfile creates a macOS Seatbelt profile based on the options 115 | func (s *MacOSSandbox) createSandboxProfile(opts SandboxOptions) (string, error) { 116 | // Get absolute path of working directory 117 | workDir, err := filepath.Abs(opts.WorkingDir) 118 | if err != nil { 119 | return "", fmt.Errorf("failed to get absolute path of working directory: %w", err) 120 | } 121 | 122 | // Home directory for configuration files 123 | homeDir, err := os.UserHomeDir() 124 | if err != nil { 125 | return "", fmt.Errorf("failed to get user home directory: %w", err) 126 | } 127 | 128 | // Get temporary directory 129 | tempDir := os.TempDir() 130 | 131 | // Build the sandbox profile 132 | profile := `(version 1) 133 | 134 | ; Basic process controls 135 | (allow process*) 136 | (allow sysctl*) 137 | (allow signal) 138 | (allow mach*) 139 | (allow file-ioctl) 140 | 141 | ; Default deny 142 | (deny default) 143 | 144 | ; Allow file reads from system paths 145 | (allow file-read* 146 | (subpath "/usr") 147 | (subpath "/bin") 148 | (subpath "/sbin") 149 | (subpath "/Library") 150 | (subpath "/System") 151 | (literal "/etc") 152 | (subpath "/etc") 153 | (subpath "/dev") 154 | (literal "/tmp") 155 | (subpath "/private/var") 156 | ) 157 | 158 | ; Allow reads from home directory config files 159 | (allow file-read* 160 | (subpath "` + filepath.Join(homeDir, ".codex") + `") 161 | (subpath "` + filepath.Join(homeDir, ".config") + `") 162 | ) 163 | 164 | ; Allow reads from temporary directory 165 | (allow file-read* 166 | (subpath "` + tempDir + `") 167 | ) 168 | 169 | ; Allow reads from working directory 170 | (allow file-read* 171 | (subpath "` + workDir + `") 172 | ) 173 | 174 | ; Conditionally allow file writes based on options 175 | ` 176 | 177 | // Allow writes to the working directory by default 178 | profile += ` 179 | (allow file-write* 180 | (subpath "` + workDir + `") 181 | (subpath "` + tempDir + `") 182 | ) 183 | ` 184 | 185 | // Handle network access 186 | if !opts.AllowNetwork { 187 | profile += ` 188 | ; Deny network access 189 | (deny network*) 190 | ` 191 | } else { 192 | profile += ` 193 | ; Allow network access 194 | (allow network*) 195 | ` 196 | } 197 | 198 | return profile, nil 199 | } 200 | -------------------------------------------------------------------------------- /internal/sandbox/sandbox.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "runtime" 12 | "time" 13 | ) 14 | 15 | // ExecutionResult represents the result of a command execution 16 | type ExecutionResult struct { 17 | Command string 18 | Args []string 19 | Output string 20 | Error string 21 | ExitCode int 22 | Duration time.Duration 23 | StartTime time.Time 24 | NetworkEnabled bool 25 | } 26 | 27 | // Options represents options for command execution 28 | type Options struct { 29 | Cwd string 30 | NetworkEnabled bool 31 | AllowedPaths []string 32 | AllowedCommands []string 33 | MaxOutputSize int 34 | Timeout time.Duration 35 | EnvironmentVars []string 36 | WorkingDirectory string 37 | } 38 | 39 | // DefaultOptions returns the default sandbox options 40 | func DefaultOptions() Options { 41 | cwd, _ := os.Getwd() 42 | return Options{ 43 | Cwd: cwd, 44 | NetworkEnabled: false, 45 | AllowedPaths: []string{cwd}, 46 | MaxOutputSize: 1024 * 1024, // 1 MB 47 | Timeout: 60 * time.Second, 48 | EnvironmentVars: os.Environ(), 49 | } 50 | } 51 | 52 | // Executor defines the interface for executing commands 53 | type Executor interface { 54 | // Execute executes a command with the given options 55 | Execute(ctx context.Context, command string, args []string, options Options) (*ExecutionResult, error) 56 | } 57 | 58 | // CreateExecutor creates the appropriate executor for the current operating system 59 | func CreateExecutor() (Executor, error) { 60 | switch runtime.GOOS { 61 | case "darwin": 62 | return NewMacOSExecutor(), nil 63 | case "linux": 64 | return NewLinuxExecutor(), nil 65 | default: 66 | return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) 67 | } 68 | } 69 | 70 | // BasicExecutor is a basic implementation of Executor 71 | type BasicExecutor struct{} 72 | 73 | // Execute executes a command in a basic sandbox 74 | func (e *BasicExecutor) Execute(ctx context.Context, command string, args []string, options Options) (*ExecutionResult, error) { 75 | startTime := time.Now() 76 | 77 | // Validate the command and arguments 78 | if !isCommandAllowed(command, options.AllowedCommands) { 79 | return &ExecutionResult{ 80 | Command: command, 81 | Args: args, 82 | Error: fmt.Sprintf("command not allowed: %s", command), 83 | ExitCode: -1, 84 | StartTime: startTime, 85 | Duration: time.Since(startTime), 86 | }, errors.New("command not allowed") 87 | } 88 | 89 | // Create a new command 90 | cmd := exec.CommandContext(ctx, command, args...) 91 | 92 | // Set working directory 93 | if options.WorkingDirectory != "" { 94 | cmd.Dir = options.WorkingDirectory 95 | } else { 96 | cmd.Dir = options.Cwd 97 | } 98 | 99 | // Set environment variables 100 | cmd.Env = options.EnvironmentVars 101 | 102 | // If network is disabled, modify the command to run in a network-disabled environment 103 | if !options.NetworkEnabled { 104 | // This would be handled differently depending on the OS 105 | // For now, just set a flag 106 | cmd.Env = append(cmd.Env, "CODEX_NETWORK_DISABLED=1") 107 | } 108 | 109 | // Capture stdout and stderr 110 | var stdout, stderr bytes.Buffer 111 | cmd.Stdout = &stdout 112 | cmd.Stderr = &stderr 113 | 114 | // Run the command 115 | err := cmd.Run() 116 | 117 | // Create the result 118 | result := &ExecutionResult{ 119 | Command: command, 120 | Args: args, 121 | Output: stdout.String(), 122 | Error: stderr.String(), 123 | ExitCode: 0, 124 | StartTime: startTime, 125 | Duration: time.Since(startTime), 126 | NetworkEnabled: options.NetworkEnabled, 127 | } 128 | 129 | // Handle errors 130 | if err != nil { 131 | if exitErr, ok := err.(*exec.ExitError); ok { 132 | result.ExitCode = exitErr.ExitCode() 133 | } else { 134 | result.ExitCode = -1 135 | result.Error = err.Error() 136 | } 137 | } 138 | 139 | return result, nil 140 | } 141 | 142 | // isCommandAllowed checks if a command is allowed to run 143 | func isCommandAllowed(command string, allowedCommands []string) bool { 144 | // If allowed commands is empty, allow all commands 145 | if len(allowedCommands) == 0 { 146 | return true 147 | } 148 | 149 | // Check if the command is in the allowedCommands list 150 | for _, allowed := range allowedCommands { 151 | if command == allowed { 152 | return true 153 | } 154 | } 155 | 156 | return false 157 | } 158 | 159 | // TruncateOutput truncates the output to the maximum size 160 | func TruncateOutput(output string, maxSize int) string { 161 | if len(output) <= maxSize { 162 | return output 163 | } 164 | 165 | half := maxSize / 2 166 | return output[:half] + "\n...[truncated]...\n" + output[len(output)-half:] 167 | } 168 | 169 | // NewMacOSExecutor creates a new executor for macOS 170 | func NewMacOSExecutor() Executor { 171 | return &BasicExecutor{} 172 | } 173 | 174 | // NewLinuxExecutor creates a new executor for Linux 175 | func NewLinuxExecutor() Executor { 176 | return &BasicExecutor{} 177 | } 178 | 179 | // RunCommand runs a command with the default options 180 | func RunCommand(ctx context.Context, command string, args []string) (*ExecutionResult, error) { 181 | executor, err := CreateExecutor() 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | options := DefaultOptions() 187 | return executor.Execute(ctx, command, args, options) 188 | } 189 | 190 | // ExecuteCommand executes a command with the given options and approval mode 191 | func ExecuteCommand(cmd string, approvalMode string, sandboxed bool) (*CommandResult, error) { 192 | // If we're in dangerous mode, bypass sandbox 193 | if approvalMode == "dangerous" { 194 | return executeUnsandboxedCommand(cmd) 195 | } 196 | 197 | // Choose the right sandbox based on the configuration 198 | var sb Sandbox 199 | if sandboxed { 200 | sb = NewSandbox() 201 | } else { 202 | sb = NewBasicSandbox() 203 | } 204 | 205 | // Set up the options 206 | opts := SandboxOptions{ 207 | Command: cmd, 208 | Timeout: 30 * time.Second, // Default timeout 209 | } 210 | 211 | // Execute the command 212 | return sb.Execute(context.Background(), opts) 213 | } 214 | 215 | // executeUnsandboxedCommand runs a command directly without any sandboxing 216 | // DANGER: This is extremely dangerous and should only be used in trusted environments 217 | func executeUnsandboxedCommand(cmd string) (*CommandResult, error) { 218 | startTime := time.Now() 219 | 220 | // Prepare the command for execution 221 | execCmd := exec.Command("sh", "-c", cmd) 222 | 223 | // Set up pipes for stdout and stderr 224 | stdout, err := execCmd.StdoutPipe() 225 | if err != nil { 226 | return nil, fmt.Errorf("failed to create stdout pipe: %w", err) 227 | } 228 | 229 | stderr, err := execCmd.StderrPipe() 230 | if err != nil { 231 | return nil, fmt.Errorf("failed to create stderr pipe: %w", err) 232 | } 233 | 234 | // Start the command 235 | if err := execCmd.Start(); err != nil { 236 | return nil, fmt.Errorf("failed to start command: %w", err) 237 | } 238 | 239 | // Read stdout and stderr 240 | stdoutBytes, err := io.ReadAll(stdout) 241 | if err != nil { 242 | return nil, fmt.Errorf("failed to read stdout: %w", err) 243 | } 244 | 245 | stderrBytes, err := io.ReadAll(stderr) 246 | if err != nil { 247 | return nil, fmt.Errorf("failed to read stderr: %w", err) 248 | } 249 | 250 | // Wait for the command to finish 251 | err = execCmd.Wait() 252 | duration := time.Since(startTime) 253 | 254 | // Get the exit code 255 | exitCode := 0 256 | if err != nil { 257 | if exitErr, ok := err.(*exec.ExitError); ok { 258 | exitCode = exitErr.ExitCode() 259 | } 260 | } 261 | 262 | // Create the result 263 | result := &CommandResult{ 264 | Stdout: string(stdoutBytes), 265 | Stderr: string(stderrBytes), 266 | ExitCode: exitCode, 267 | Success: exitCode == 0, 268 | Error: err, 269 | Duration: duration, 270 | Command: cmd, 271 | } 272 | 273 | return result, nil 274 | } 275 | -------------------------------------------------------------------------------- /internal/ui/approval.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/viewport" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | // ApprovalResultMsg is sent when the user makes a choice in the approval UI 14 | type ApprovalResultMsg struct { 15 | Approved bool // true if approved, false if denied or cancelled 16 | } 17 | 18 | // Styles for approval UI 19 | var ( 20 | approvalTitleStyle = lipgloss.NewStyle(). 21 | Bold(true). 22 | Foreground(lipgloss.Color("5")). // Magenta-ish 23 | Padding(0, 1). // Add some padding 24 | MarginBottom(1) 25 | 26 | approvalDescriptionStyle = lipgloss.NewStyle(). 27 | Foreground(lipgloss.Color("7")). // White/Gray 28 | Padding(0, 1). // Add some padding 29 | MarginBottom(1) 30 | 31 | approvalActionStyle = lipgloss.NewStyle(). 32 | Border(lipgloss.RoundedBorder()). 33 | BorderForeground(lipgloss.Color("63")). // Light Purple 34 | Padding(0, 1) 35 | 36 | approvalButtonStyle = lipgloss.NewStyle(). 37 | Padding(0, 2). 38 | Margin(1, 1) // Add margin around buttons 39 | 40 | approvalButtonActiveStyle = approvalButtonStyle.Copy(). 41 | Foreground(lipgloss.Color("0")). // Black text 42 | Background(lipgloss.Color("10")) // Green background 43 | 44 | approvalButtonInactiveStyle = approvalButtonStyle.Copy(). 45 | Foreground(lipgloss.Color("244")) // Gray text 46 | 47 | approvalHelpStyle = lipgloss.NewStyle(). 48 | Foreground(lipgloss.Color("8")). // Dark Gray 49 | MarginTop(1) 50 | 51 | approvalDialogStyle = lipgloss.NewStyle(). 52 | Border(lipgloss.DoubleBorder()). 53 | BorderForeground(lipgloss.Color("6")). // Cyan 54 | Padding(1) 55 | 56 | // Styles for Diff View 57 | diffAddedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // Green 58 | diffRemovedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // Red 59 | diffContextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) // Gray (for unchanged lines/context) 60 | ) 61 | 62 | // Key bindings 63 | type approvalKeyMap struct { 64 | Select key.Binding 65 | Confirm key.Binding 66 | Cancel key.Binding 67 | Up key.Binding 68 | Down key.Binding 69 | PageUp key.Binding 70 | PageDown key.Binding 71 | Approve key.Binding 72 | Deny key.Binding 73 | Help key.Binding // Added Help key 74 | } 75 | 76 | func defaultApprovalKeyMap() approvalKeyMap { 77 | return approvalKeyMap{ 78 | Select: key.NewBinding( 79 | key.WithKeys("left", "right", "h", "l", "tab", "shift+tab"), 80 | key.WithHelp("←/→/tab", "select"), 81 | ), 82 | Confirm: key.NewBinding( 83 | key.WithKeys("enter"), 84 | key.WithHelp("enter", "confirm"), 85 | ), 86 | Cancel: key.NewBinding( 87 | key.WithKeys("esc", "q", "ctrl+c"), 88 | key.WithHelp("esc/q", "cancel"), 89 | ), 90 | Up: key.NewBinding( 91 | key.WithKeys("up", "k"), 92 | key.WithHelp("↑/k", "scroll up"), 93 | ), 94 | Down: key.NewBinding( 95 | key.WithKeys("down", "j"), 96 | key.WithHelp("↓/j", "scroll down"), 97 | ), 98 | PageUp: key.NewBinding( 99 | key.WithKeys("pgup"), 100 | key.WithHelp("pgup", "page up"), 101 | ), 102 | PageDown: key.NewBinding( 103 | key.WithKeys("pgdown"), 104 | key.WithHelp("pgdn", "page down"), 105 | ), 106 | Approve: key.NewBinding( 107 | key.WithKeys("y"), 108 | key.WithHelp("y", "approve"), 109 | ), 110 | Deny: key.NewBinding( 111 | key.WithKeys("n"), 112 | key.WithHelp("n", "deny"), 113 | ), 114 | Help: key.NewBinding( // Added Help key binding 115 | key.WithKeys("?"), 116 | key.WithHelp("?", "toggle help"), // Simple toggle description 117 | ), 118 | } 119 | } 120 | 121 | // ApprovalModel is a bubble tea model for approval prompts 122 | type ApprovalModel struct { 123 | Title string 124 | Description string 125 | Action string // The *raw* arguments or content being approved 126 | Approved bool // Tracks the currently selected option (true = yes) 127 | YesText string 128 | NoText string 129 | keyMap approvalKeyMap 130 | showFullHelp bool // Added state for toggling help 131 | 132 | viewport viewport.Model 133 | ready bool // Viewport readiness flag 134 | // Store terminal dimensions for Place function in View 135 | terminalWidth int 136 | terminalHeight int 137 | // Store calculated dialog dimensions 138 | dialogWidth int 139 | dialogHeight int 140 | } 141 | 142 | // NewApprovalModel creates a new approval model 143 | func NewApprovalModel(title, description, action string) ApprovalModel { 144 | vp := viewport.New(0, 0) // Initialize with zero size, will be set later 145 | vp.Style = lipgloss.NewStyle().MarginLeft(1) // Ensure content doesn't touch scrollbar 146 | 147 | return ApprovalModel{ 148 | Title: title, 149 | Description: description, 150 | Action: action, 151 | Approved: true, // Default selection to Approve 152 | YesText: "Approve", 153 | NoText: "Deny", 154 | keyMap: defaultApprovalKeyMap(), 155 | showFullHelp: false, // Start with short help 156 | viewport: vp, 157 | ready: false, 158 | } 159 | } 160 | 161 | // SetSize calculates layout dimensions based on terminal size 162 | func (m *ApprovalModel) SetSize(termWidth, termHeight int) { 163 | m.terminalWidth = termWidth 164 | m.terminalHeight = termHeight 165 | 166 | if !m.ready && termWidth > 0 && termHeight > 0 { 167 | m.ready = true // Mark as ready once we have dimensions 168 | } 169 | 170 | // --- Calculate Dialog Box Width --- 171 | desiredDialogWidth := int(float64(termWidth) * 0.8) 172 | minDialogWidth := 40 173 | maxDialogWidth := 120 174 | dialogW := desiredDialogWidth 175 | if dialogW < minDialogWidth { 176 | dialogW = minDialogWidth 177 | } 178 | if dialogW > maxDialogWidth { 179 | dialogW = maxDialogWidth 180 | } 181 | if dialogW > termWidth-2 { // Ensure fits with padding 182 | dialogW = termWidth - 2 183 | } 184 | m.dialogWidth = dialogW 185 | 186 | // --- Calculate Viewport Width --- 187 | vpHorizontalPadding := approvalDialogStyle.GetHorizontalPadding() + approvalActionStyle.GetHorizontalPadding() + m.viewport.Style.GetHorizontalMargins() 188 | vpWidth := m.dialogWidth - vpHorizontalPadding 189 | if vpWidth < 0 { 190 | vpWidth = 0 191 | } 192 | m.viewport.Width = vpWidth 193 | 194 | // --- Wrap Content for Height Calculation --- 195 | wrappedAction := lipgloss.NewStyle().Width(m.viewport.Width).Render(m.Action) 196 | m.viewport.SetContent(wrappedAction) 197 | 198 | // --- Calculate Non-Viewport Height --- 199 | titleView := m.renderTitle(vpWidth) 200 | descView := m.renderDescription(vpWidth) 201 | buttonsView := m.renderButtons() 202 | helpView := m.renderHelp(vpWidth) 203 | nonViewportHeight := lipgloss.Height(titleView) + 204 | lipgloss.Height(descView) + 205 | lipgloss.Height(buttonsView) + 206 | lipgloss.Height(helpView) + 207 | approvalDialogStyle.GetVerticalPadding() + // Dialog border/padding 208 | approvalActionStyle.GetVerticalPadding() + // Action box border/padding 209 | approvalTitleStyle.GetVerticalMargins() + // Margins between elements 210 | approvalDescriptionStyle.GetVerticalMargins() + 211 | approvalButtonStyle.GetVerticalMargins()*2 + // Button row margins 212 | approvalHelpStyle.GetVerticalMargins() 213 | 214 | // --- Calculate Viewport and Dialog Height --- 215 | // Available height within terminal for the dialog content itself 216 | maxAvailableHeight := termHeight - approvalDialogStyle.GetVerticalPadding() - 2 // Subtract dialog padding and small buffer 217 | if maxAvailableHeight < 0 { 218 | maxAvailableHeight = 0 219 | } 220 | 221 | // Calculate ideal viewport height based on available space 222 | idealVpHeight := maxAvailableHeight - nonViewportHeight 223 | 224 | // Apply constraints: min, max, and available space 225 | minViewportHeight := 3 226 | maxViewportHeight := 15 // << The requested limit 227 | 228 | finalVpHeight := idealVpHeight // Start with ideal 229 | if finalVpHeight < minViewportHeight { 230 | finalVpHeight = minViewportHeight 231 | } 232 | if finalVpHeight > maxViewportHeight { 233 | finalVpHeight = maxViewportHeight 234 | } 235 | 236 | m.viewport.Height = finalVpHeight 237 | 238 | // Calculate final dialog height based on the *constrained* viewport height 239 | m.dialogHeight = nonViewportHeight + m.viewport.Height 240 | 241 | // Ensure final dialog height still fits within the absolute max available height 242 | if m.dialogHeight > maxAvailableHeight { 243 | m.dialogHeight = maxAvailableHeight 244 | // If we capped the dialog height, we might need to shrink the viewport *again* 245 | // This edge case happens if nonViewportHeight is very large 246 | newVpHeight := m.dialogHeight - nonViewportHeight 247 | if newVpHeight < minViewportHeight { // Ensure min is still respected 248 | newVpHeight = minViewportHeight 249 | } 250 | m.viewport.Height = newVpHeight 251 | } 252 | 253 | // Ensure viewport content is set after final dimensions 254 | m.viewport.SetContent(wrappedAction) 255 | } 256 | 257 | // renderTitle renders the title, wrapped to width 258 | func (m ApprovalModel) renderTitle(maxWidth int) string { 259 | style := approvalTitleStyle.Copy().Width(maxWidth) 260 | return style.Render(m.Title) 261 | } 262 | 263 | // renderDescription renders the description, wrapped to width 264 | func (m ApprovalModel) renderDescription(maxWidth int) string { 265 | style := approvalDescriptionStyle.Copy().Width(maxWidth) 266 | return style.Render(m.Description) 267 | } 268 | 269 | // Init initializes the model 270 | func (m ApprovalModel) Init() tea.Cmd { 271 | return nil // No initial command needed 272 | } 273 | 274 | // Update handles updates to the model 275 | func (m ApprovalModel) Update(msg tea.Msg) (ApprovalModel, tea.Cmd) { 276 | var ( 277 | cmd tea.Cmd 278 | cmds []tea.Cmd 279 | ) 280 | 281 | // Ensure model is ready before processing inputs 282 | if !m.ready { 283 | if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { 284 | m.SetSize(sizeMsg.Width, sizeMsg.Height) 285 | } 286 | // Ignore other messages until ready 287 | return m, nil 288 | } 289 | 290 | switch msg := msg.(type) { 291 | case tea.WindowSizeMsg: 292 | m.SetSize(msg.Width, msg.Height) 293 | 294 | case tea.KeyMsg: 295 | // Give viewport priority for scrolling keys if content overflows 296 | contentOverflows := m.viewport.TotalLineCount() > m.viewport.Height 297 | isScrollingKey := key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.PageUp) || key.Matches(msg, m.keyMap.PageDown) 298 | 299 | if contentOverflows && isScrollingKey { 300 | m.viewport, cmd = m.viewport.Update(msg) 301 | cmds = append(cmds, cmd) 302 | } else { 303 | // Handle non-scrolling keys or if content fits 304 | switch { 305 | case key.Matches(msg, m.keyMap.Select): 306 | m.Approved = !m.Approved // Toggle selection 307 | 308 | case key.Matches(msg, m.keyMap.Confirm): 309 | cmds = append(cmds, func() tea.Msg { return ApprovalResultMsg{Approved: m.Approved} }) 310 | case key.Matches(msg, m.keyMap.Approve): 311 | m.Approved = true 312 | cmds = append(cmds, func() tea.Msg { return ApprovalResultMsg{Approved: true} }) 313 | case key.Matches(msg, m.keyMap.Deny): 314 | m.Approved = false 315 | cmds = append(cmds, func() tea.Msg { return ApprovalResultMsg{Approved: false} }) 316 | 317 | case key.Matches(msg, m.keyMap.Cancel): 318 | m.Approved = false // Treat cancel as denial for simplicity 319 | cmds = append(cmds, func() tea.Msg { return ApprovalResultMsg{Approved: false} }) 320 | 321 | case key.Matches(msg, m.keyMap.Help): 322 | m.showFullHelp = !m.showFullHelp 323 | // Recalculate layout as help height might change 324 | m.SetSize(m.terminalWidth, m.terminalHeight) 325 | } 326 | } 327 | 328 | // Pass mouse events to the viewport for potential scrolling 329 | case tea.MouseMsg: 330 | if m.viewport.TotalLineCount() > m.viewport.Height { 331 | m.viewport, cmd = m.viewport.Update(msg) 332 | cmds = append(cmds, cmd) 333 | } 334 | } 335 | 336 | // Ensure viewport updates are applied if triggered by mouse or other means 337 | // We already handled specific keys, this handles general updates 338 | // m.viewport, cmd = m.viewport.Update(msg) // This might double-process keys, avoid it unless needed 339 | // cmds = append(cmds, cmd) 340 | 341 | return m, tea.Batch(cmds...) 342 | } 343 | 344 | // renderButtons renders the Approve/Deny buttons 345 | func (m ApprovalModel) renderButtons() string { 346 | yesStyle := approvalButtonInactiveStyle 347 | noStyle := approvalButtonInactiveStyle 348 | 349 | if m.Approved { 350 | yesStyle = approvalButtonActiveStyle 351 | } else { 352 | noStyle = approvalButtonActiveStyle 353 | } 354 | 355 | yes := yesStyle.Render(m.YesText) 356 | no := noStyle.Render(m.NoText) 357 | 358 | // Join buttons side-by-side, centered within available space 359 | // Use dialogWidth for centering context if needed, but simple join is usually fine 360 | return lipgloss.JoinHorizontal(lipgloss.Center, yes, no) 361 | } 362 | 363 | // renderHelp builds and renders the help string 364 | func (m ApprovalModel) renderHelp(maxWidth int) string { 365 | // Base keys available always 366 | keys := []key.Binding{m.keyMap.Select, m.keyMap.Confirm, m.keyMap.Approve, m.keyMap.Deny, m.keyMap.Cancel, m.keyMap.Help} 367 | 368 | // Add scrolling keys if content overflows 369 | if m.viewport.TotalLineCount() > m.viewport.Height { 370 | keys = append(keys, m.keyMap.Up, m.keyMap.Down, m.keyMap.PageUp, m.keyMap.PageDown) 371 | } 372 | 373 | // Build help string manually 374 | var helpBuilder strings.Builder 375 | activeKeys := 0 // Track how many keys are actually added to the builder 376 | for _, k := range keys { 377 | // Use FullHelp if toggled, otherwise ShortHelp 378 | helpMsg := "" 379 | if m.showFullHelp { 380 | // Access help details directly from the binding 381 | helpMsg = fmt.Sprintf("%s: %s", k.Help().Key, k.Help().Desc) 382 | } else { 383 | helpMsg = k.Help().Key // Only show the key in short help 384 | } 385 | 386 | // Hide Approve/Deny keys from short help to avoid clutter 387 | // Compare primary key representation for equality check 388 | isApproveKey := k.Keys()[0] == m.keyMap.Approve.Keys()[0] // Assuming first key is representative 389 | isDenyKey := k.Keys()[0] == m.keyMap.Deny.Keys()[0] 390 | if !m.showFullHelp && (isApproveKey || isDenyKey) { 391 | continue 392 | } 393 | 394 | // Add separator if not the first item being added 395 | if activeKeys > 0 { 396 | helpBuilder.WriteString(" • ") 397 | } 398 | helpBuilder.WriteString(helpMsg) 399 | activeKeys++ 400 | } 401 | 402 | // Apply style and wrap 403 | style := approvalHelpStyle.Copy().Width(maxWidth) 404 | return style.Render(helpBuilder.String()) 405 | } 406 | 407 | // View renders the approval UI 408 | func (m ApprovalModel) View() string { 409 | if !m.ready { 410 | // Return empty string or minimal message until ready 411 | // Using Place requires dimensions, so wait until SetSize is called 412 | return "" 413 | } 414 | 415 | // Use calculated dialog width for rendering internal elements 416 | contentWidth := m.viewport.Width // Width available inside action box border/padding 417 | 418 | titleView := m.renderTitle(contentWidth) 419 | descView := m.renderDescription(contentWidth) 420 | actionView := approvalActionStyle. 421 | Width(m.viewport.Width). // Use viewport width for the action box style 422 | Height(m.viewport.Height). // Use viewport height for the action box style 423 | Render(m.viewport.View()) // Render the viewport content 424 | buttonsView := m.renderButtons() 425 | helpView := m.renderHelp(contentWidth) // Render help within content width 426 | 427 | // Combine elements vertically 428 | ui := lipgloss.JoinVertical(lipgloss.Left, 429 | titleView, 430 | descView, 431 | actionView, // Render the styled viewport 432 | buttonsView, 433 | helpView, 434 | ) 435 | 436 | // Apply dialog styling with calculated width and height 437 | // Subtract padding *before* rendering content inside 438 | dialogContentWidth := m.dialogWidth - approvalDialogStyle.GetHorizontalPadding() 439 | // Height calculation is complex due to wrapping; let Render handle it, or use MaxHeight 440 | dialogView := approvalDialogStyle. 441 | Width(dialogContentWidth). // Set width for the box itself 442 | // MaxHeight(m.dialogHeight - approvalDialogStyle.GetVerticalPadding()). // Optional: constrain height 443 | Render(ui) 444 | 445 | // Center the dialog in the terminal using stored terminal dimensions 446 | return lipgloss.Place(m.terminalWidth, m.terminalHeight, lipgloss.Center, lipgloss.Center, dialogView) 447 | } 448 | -------------------------------------------------------------------------------- /internal/ui/diff_formatter.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // FormatPatchForDisplay takes a raw patch string (potentially multi-file) 8 | // from the agent's custom format and attempts to add standard +/- diff markers 9 | // and color highlighting for better readability in the approval UI. 10 | func FormatPatchForDisplay(rawPatch string) string { 11 | lines := strings.Split(rawPatch, "\n") // Split by newline 12 | 13 | var formatted strings.Builder 14 | var inEditBlock bool = false // Track if we are inside an ADD/DEL block 15 | 16 | for _, line := range lines { 17 | // Preserve empty lines within the block, but trim others for prefix checks 18 | isEmptyLine := len(strings.TrimSpace(line)) == 0 19 | trimmedLine := "" 20 | if !isEmptyLine { 21 | trimmedLine = strings.TrimSpace(line) 22 | } 23 | 24 | // Handle block markers (Keep default style) 25 | if strings.HasPrefix(trimmedLine, "// FILE:") || strings.HasPrefix(trimmedLine, "// EDIT:") { 26 | inEditBlock = strings.HasPrefix(trimmedLine, "// EDIT:") 27 | formatted.WriteString(line + "\n") 28 | continue 29 | } 30 | if strings.HasPrefix(trimmedLine, "// END_EDIT") { 31 | inEditBlock = false 32 | formatted.WriteString(line + "\n") 33 | continue 34 | } 35 | 36 | // Process lines within an edit block 37 | if inEditBlock { 38 | if strings.HasPrefix(trimmedLine, "ADD:") { 39 | content := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "ADD:")) 40 | formatted.WriteString(diffAddedStyle.Render("+ "+content) + "\n") 41 | } else if strings.HasPrefix(trimmedLine, "DEL:") || strings.HasPrefix(trimmedLine, "DELETE:") { 42 | content := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "DEL:")) 43 | if strings.HasPrefix(trimmedLine, "DELETE:") { // Handle both DEL and DELETE 44 | content = strings.TrimSpace(strings.TrimPrefix(trimmedLine, "DELETE:")) 45 | } 46 | formatted.WriteString(diffRemovedStyle.Render("- "+content) + "\n") 47 | } else { 48 | // Render context lines within the edit block with context style 49 | // Keep original leading/trailing whitespace for context lines if possible? 50 | // For simplicity, just prefix with two spaces for now. 51 | formatted.WriteString(diffContextStyle.Render(" "+line) + "\n") 52 | } 53 | } else { 54 | // Lines outside edit blocks are treated as metadata or ignored context 55 | // Render them with default/context style? 56 | formatted.WriteString(line + "\n") // Keep original styling 57 | } 58 | } 59 | 60 | return formatted.String() 61 | } 62 | -------------------------------------------------------------------------------- /internal/ui/text-input.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/textinput" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | // CustomTextInput is a text input component that supports multiline text input 12 | type CustomTextInput struct { 13 | textInput textinput.Model 14 | value string 15 | width int 16 | height int 17 | cursorPos int 18 | prefix string 19 | placeholder string 20 | focused bool 21 | showCursor bool 22 | style lipgloss.Style 23 | prefixStyle lipgloss.Style 24 | cursorStyle lipgloss.Style 25 | blurredStyle lipgloss.Style 26 | } 27 | 28 | // NewCustomTextInput creates a new custom text input 29 | func NewCustomTextInput() CustomTextInput { 30 | ti := textinput.New() 31 | ti.Placeholder = "Type your message..." 32 | ti.Focus() 33 | ti.CharLimit = 4096 34 | ti.Width = 80 35 | 36 | return CustomTextInput{ 37 | textInput: ti, 38 | value: "", 39 | cursorPos: 0, 40 | prefix: "user", 41 | placeholder: "Send a message or press tab to select a suggestion", 42 | focused: true, 43 | showCursor: true, 44 | style: lipgloss.NewStyle(). 45 | Border(lipgloss.NormalBorder()). 46 | BorderForeground(lipgloss.Color("7")). 47 | Padding(0, 1), 48 | prefixStyle: lipgloss.NewStyle(). 49 | Foreground(lipgloss.Color("5")). 50 | Bold(true), 51 | cursorStyle: lipgloss.NewStyle(). 52 | Foreground(lipgloss.Color("7")). 53 | Underline(true), 54 | blurredStyle: lipgloss.NewStyle(). 55 | Foreground(lipgloss.Color("8")), 56 | } 57 | } 58 | 59 | // Init initializes the model 60 | func (m CustomTextInput) Init() tea.Cmd { 61 | return textinput.Blink 62 | } 63 | 64 | // Update handles messages for the model 65 | func (m CustomTextInput) Update(msg tea.Msg) (CustomTextInput, tea.Cmd) { 66 | var cmd tea.Cmd 67 | 68 | switch msg := msg.(type) { 69 | case tea.KeyMsg: 70 | switch msg.Type { 71 | case tea.KeyEnter: 72 | // Submit the value 73 | return m, nil 74 | } 75 | case tea.WindowSizeMsg: 76 | m.width = msg.Width 77 | m.height = msg.Height 78 | } 79 | 80 | m.textInput, cmd = m.textInput.Update(msg) 81 | m.value = m.textInput.Value() 82 | return m, cmd 83 | } 84 | 85 | // View renders the model 86 | func (m CustomTextInput) View() string { 87 | if !m.focused { 88 | return m.blurredStyle.Render(m.placeholder) 89 | } 90 | 91 | // Render the cursor differently with our styling 92 | cursor := "█" 93 | if !m.showCursor { 94 | cursor = " " 95 | } 96 | 97 | // Format as "user: " 98 | prefix := m.prefixStyle.Render(m.prefix) 99 | 100 | // Only show cursor if there's no content 101 | if m.value == "" { 102 | return fmt.Sprintf("%s %s", prefix, cursor) 103 | } 104 | 105 | // Show the text with cursor 106 | return fmt.Sprintf("%s %s", prefix, m.value) 107 | } 108 | 109 | // Focus focuses the model 110 | func (m *CustomTextInput) Focus() { 111 | m.focused = true 112 | m.textInput.Focus() 113 | } 114 | 115 | // Blur blurs the model 116 | func (m *CustomTextInput) Blur() { 117 | m.focused = false 118 | m.textInput.Blur() 119 | } 120 | 121 | // SetValue sets the value of the model 122 | func (m *CustomTextInput) SetValue(value string) { 123 | m.value = value 124 | m.textInput.SetValue(value) 125 | } 126 | 127 | // Value returns the current value of the model 128 | func (m CustomTextInput) Value() string { 129 | return m.value 130 | } 131 | 132 | // SetPlaceholder sets the placeholder text 133 | func (m *CustomTextInput) SetPlaceholder(placeholder string) { 134 | m.placeholder = placeholder 135 | m.textInput.Placeholder = placeholder 136 | } 137 | 138 | // SetWidth sets the width of the input field 139 | func (m *CustomTextInput) SetWidth(width int) { 140 | m.width = width 141 | m.textInput.Width = width 142 | } 143 | 144 | // SetPrefix sets the prefix text 145 | func (m *CustomTextInput) SetPrefix(prefix string) { 146 | m.prefix = prefix 147 | } 148 | -------------------------------------------------------------------------------- /sandbox/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hey, this message was AI generated.") 7 | // Sample array 8 | arr := []int{10, 7, 8, 9, 1, 5} 9 | fmt.Println("Original array:", arr) 10 | 11 | } 12 | -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensure the script directory exists 4 | mkdir -p scripts 5 | 6 | # Set the API key if provided as an argument 7 | if [ ! -z "$1" ]; then 8 | export OPENAI_API_KEY="$1" 9 | fi 10 | 11 | # Check if OPENAI_API_KEY is set 12 | if [ -z "$OPENAI_API_KEY" ]; then 13 | echo "Error: OPENAI_API_KEY environment variable is not set" 14 | echo "Please set your OpenAI API key: export OPENAI_API_KEY=your-api-key-here" 15 | echo "Or provide it as an argument: ./scripts/run.sh your-api-key-here" 16 | exit 1 17 | fi 18 | 19 | # Get the prompt if provided as the second argument 20 | PROMPT="" 21 | if [ ! -z "$2" ]; then 22 | PROMPT="$2" 23 | fi 24 | 25 | # Run the application 26 | if [ -z "$PROMPT" ]; then 27 | go run cmd/codex/main.go 28 | else 29 | go run cmd/codex/main.go "$PROMPT" -------------------------------------------------------------------------------- /tests/agent_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/epuerta/codex-go/internal/agent" 11 | "github.com/epuerta/codex-go/internal/config" 12 | "github.com/epuerta/codex-go/internal/logging" 13 | ) 14 | 15 | func TestOpenAIAgent(t *testing.T) { 16 | // Skip if no API key is provided 17 | apiKey := os.Getenv("OPENAI_API_KEY") 18 | if apiKey == "" { 19 | t.Skip("Skipping test: OPENAI_API_KEY not set") 20 | } 21 | 22 | // Create a config 23 | cfg := &config.Config{ 24 | APIKey: apiKey, 25 | Model: "gpt-3.5-turbo", 26 | APITimeout: 30, 27 | ApprovalMode: config.Suggest, 28 | } 29 | 30 | // Create a nil logger for testing 31 | logger := logging.NewNilLogger() 32 | 33 | // Create an OpenAI agent 34 | openaiAgent, err := agent.NewOpenAIAgent(cfg, logger) 35 | if err != nil { 36 | t.Fatalf("Failed to create OpenAI agent: %v", err) 37 | } 38 | 39 | // Test sending a message 40 | messages := []agent.Message{ 41 | { 42 | Role: "system", 43 | Content: "You are a helpful assistant. Respond with a short greeting.", 44 | }, 45 | { 46 | Role: "user", 47 | Content: "Hello!", 48 | }, 49 | } 50 | 51 | // Set up a context with timeout 52 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 53 | defer cancel() 54 | 55 | // Create a channel to collect response items 56 | respChan := make(chan agent.ResponseItem) 57 | var responses []agent.ResponseItem 58 | 59 | // Send the message in a goroutine 60 | go func() { 61 | defer close(respChan) 62 | // Convert our handler to accept string JSON 63 | jsonHandler := func(jsonStr string) { 64 | var item agent.ResponseItem 65 | if err := json.Unmarshal([]byte(jsonStr), &item); err != nil { 66 | t.Errorf("Error unmarshalling response item: %v", err) 67 | return 68 | } 69 | respChan <- item 70 | } 71 | _, err := openaiAgent.SendMessage(ctx, messages, jsonHandler) 72 | if err != nil { 73 | t.Errorf("Error sending message: %v", err) 74 | } 75 | }() 76 | 77 | // Collect responses from the channel 78 | for item := range respChan { 79 | responses = append(responses, item) 80 | } 81 | 82 | // Check that we got at least one response 83 | if len(responses) == 0 { 84 | t.Errorf("No responses received") 85 | } 86 | 87 | // Check for message content in the responses 88 | hasMessage := false 89 | for _, resp := range responses { 90 | if resp.Type == "message" && resp.Message != nil { 91 | hasMessage = true 92 | break 93 | } 94 | } 95 | 96 | if !hasMessage { 97 | t.Errorf("No message in responses") 98 | } 99 | } 100 | 101 | func TestAgentMock(t *testing.T) { 102 | // TODO: Implement a mock agent test that doesn't require an API key 103 | // This is a placeholder for future mock testing 104 | t.Skip("Mock agent test not implemented yet") 105 | } 106 | --------------------------------------------------------------------------------