From 28f7ec1cdaf0b6e20b7b9bd44a65d04b53adf660 Mon Sep 17 00:00:00 2001 From: Theodora Cheng Date: Thu, 15 Aug 2024 11:03:56 -0700 Subject: [PATCH] initial commit --- CODE_OF_CONDUCT.md | 74 ++++++++++ LICENSE => LICENSE.txt | 4 +- README.md | 127 +++++++++++++++++ SECURITY.md | 31 +++++ SUPPORT.md | 13 ++ cmd/chat.go | 83 +++++++++++ go.mod | 27 ++++ go.sum | 41 ++++++ main.go | 10 ++ pkg/chat/agent.go | 84 +++++++++++ pkg/chat/chat.go | 249 +++++++++++++++++++++++++++++++++ pkg/chat/chat_test.go | 69 ++++++++++ pkg/chat/colors.go | 32 +++++ pkg/chat/parser.go | 295 +++++++++++++++++++++++++++++++++++++++ pkg/chat/parser_test.go | 298 ++++++++++++++++++++++++++++++++++++++++ pkg/chat/structs.go | 62 +++++++++ pkg/chat/utils.go | 20 +++ 17 files changed, 1517 insertions(+), 2 deletions(-) create mode 100644 CODE_OF_CONDUCT.md rename LICENSE => LICENSE.txt (95%) create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 cmd/chat.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/chat/agent.go create mode 100644 pkg/chat/chat.go create mode 100644 pkg/chat/chat_test.go create mode 100644 pkg/chat/colors.go create mode 100644 pkg/chat/parser.go create mode 100644 pkg/chat/parser_test.go create mode 100644 pkg/chat/structs.go create mode 100644 pkg/chat/utils.go diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a1f82f0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file diff --git a/LICENSE b/LICENSE.txt similarity index 95% rename from LICENSE rename to LICENSE.txt index 781076f..baed8fe 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 copilot-extensions +Copyright (c) 2024 GitHub Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c9fb92 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# GH Debug CLI + +This tool allows you to chat with your assistant locally in order to create a faster feedback loop for developers developing an assistant. +Debug mode is enabled by default so that you can see clearer information around what exactly is getting parsed successfully. + +The different SSE events in the [agent protocol](https://github.com/github-technology-partners/copilot-partners/blob/main/docs/sse-events.md) that the CLI gives debug output for are: +1. [errors](https://github.com/github-technology-partners/copilot-partners/blob/c0b6be447b95d94fff6297bae820ea8cc6d36b87/docs/copilot-errors.md) +2. [references](https://github.com/github-technology-partners/copilot-partners/blob/c0b6be447b95d94fff6297bae820ea8cc6d36b87/docs/references.md) +3. [confirmations](https://github.com/github-technology-partners/copilot-partners/blob/c0b6be447b95d94fff6297bae820ea8cc6d36b87/docs/confirmations.md) + +> Note: This tool does not handle the payload verification process. To use this tool to validate your events, please temporarily disable payload verification for local testing and re-enable when completed. + +## Install the debug tool +1. Authenticate with GitHub CLI OAuth app + ```shell + gh auth login --web -h github.com + ``` +1. Install / upgrade extension + ```shell + gh extension install github-technology-partners/gh-debug-cli + ``` +1. See more info about the cli tool + ```shell + gh debug-cli -h + ``` + +## Using the debug tool +1. Run the following command `gh debug-cli -h` to see the different flags that it takes in. +``` +> gh debug-cli -h +This cli tool allows you to debug your agent by chatting with it locally. + +Usage: + [flags] + +Flags: + -h, --help help for this command + --log-level DEBUG Log level to help debug events. Supported types are DEBUG, `TRACE`, `NONE`. `DEBUG` returns general logs. `TRACE` prints the raw http response. (default "DEBUG") + --token string GitHub token for chat authentication (optional) + --url string url to chat with your agent (default "http://localhost:8080") + --username string username to display in chat (default "sparklyunicorn") +``` +2. You can alternatively set these flags as environment variables (in all caps) so you don't need to pass them in every time. The only "required" one to get this up and running is the url for your agent +``` +export URL="http://localhost:8080/agent/blackbeard" +``` +3. When you run the CLI, you will see any flags that were previously set in your environment variables as the output. +``` +> gh debug-cli +Setting url to http://localhost:8080/agents/blackbeard + +Start typing to chat with your assistant... +sparklyunicorn: +``` +4. Type something to simulate chatting with your assistant. +``` +> gh debug-cli +Setting url to http://localhost:8080/agents/blackbeard + +Start typing to chat with your assistant... +sparklyunicorn: hello +assistant: Ahoy, @monalisa! A jolly good day to ye, me heartie. How can ol' Blackbeard be of service to ye today? + +Huzzah! You successfully received a message! +╔═══════════╤════════════════════════════════════════════════════════════════╗ +║ Role │ Content ║ +╟━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ assistant │ [condensed] Ahoy, @monalisa! A jolly good day to ye, me hearti ║ +╟━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ Parsed message data ║ +╚═══════════╧════════════════════════════════════════════════════════════════╝ +sparklyunicorn: + +``` +5. To debug your SSE events, you can set up a key word that your assistant uses to send you a specific type of event. My blackbeard agent allows me to send a keyword "confirmation", and here I can see the debug output on what is parsed from the SSE event +``` +> sparklyunicorn: confirmation +assistant: Arrr, @monalisa! I be ready and waitin' for yer confirmation. Be ye ready to set sail on this treacherous journey and receive a custom limerick 'bout petals? Aye or nay, let me know yer decision, and I'll be at yer service. + +Huzzah! You successfully received a message! +╔═══════════╤════════════════════════════════════════════════════════════════╗ +║ Role │ Content ║ +╟━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ assistant │ [condensed] Arrr, @monalisa! I be ready and waitin' for yer co ║ +╟━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ Parsed message data ║ +╚═══════════╧════════════════════════════════════════════════════════════════╝ + +Huzzah! You successfully received a confirmation! +╔══════════════╤═════════════════════════════════════════════════════╗ +║ Key │ Value ║ +╟━━━━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ type │ action ║ +║ title │ Be ye sure ye want a custom limerick 'bout petals ? ║ +║ message │ Arrr, this here action be irreversible, matey! ║ +║ confirmation │ map[id:123] ║ +╟━━━━━━━━━━━━━━┼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╢ +║ Parsed confirmation data ║ +╚══════════════╧═════════════════════════════════════════════════════╝ + +Be ye sure ye want a custom limerick 'bout petals ? + Arrr, this here action be irreversible, matey! +Reply: [y/N] +``` +6. If I got a bad confirmation, it would look something like this +``` +> sparklyunicorn: bad confirmation + +Alas...The following is not a valid confirmation: + ["conf"] + +assistant: Avast, @monalisa! Me apologies if I didn't quite understand yer request. Pray tell, could ye please clarify what be wrong with the confirmation? I be here to assist ye, me matey! +``` +7. And if debug mode was set to false, then I would only see the confirmation prompt itself. +``` +gh debug-cli --log-level none +Setting url to http://localhost:8080/agents/blackbeard + +Start typing to chat with your assistant... +sparklyunicorn: confirmation +assistant: Ahoy, @monalisa! Ye be seekin' confirmation, me hearty. Are ye sure ye want a custom limerick 'bout petals? This here action be irreversible, matey! + +Be ye sure ye want a custom limerick 'bout petals ? + Arrr, this here action be irreversible, matey! +Reply: [y/N] +``` +8. Currently, the supported event types for debug mode are references, errors, and confirmations! Have fun chatting with your assistant! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4279c87 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +Thanks for helping make GitHub safe for everyone. + +# Security + +GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. + +## Reporting Security Issues + +If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please send an email to opensource-security[@]github.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + + * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy + +See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) \ No newline at end of file diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..2243438 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,13 @@ +# Support + +## How to file issues and get help + +This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. + +For help or questions about using this project, please open an issue in the repository. + +- **GH Debug CLI** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner. + +## GitHub Support Policy + +Support for this project is limited to the resources listed above. \ No newline at end of file diff --git a/cmd/chat.go b/cmd/chat.go new file mode 100644 index 0000000..e324199 --- /dev/null +++ b/cmd/chat.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/github-technology-partners/gh-debug-cli/pkg/chat" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + chatCmdURLFlag = "url" + chatCmdUsernameFlag = "username" + chatCmdLogLevelFlag = "log-level" + chatCmdTokenFlag = "token" + chatCmdPrivateKeyFlag = "private-key" + chatCmdPublicKeyFlag = "public-key" +) + +var chatCmd = &cobra.Command{ + Short: "Interact with your agent.", + Long: `This cli tool allows you to debug your agent by chatting with it locally.`, + Run: agentChat, + TraverseChildren: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + cmd.Flags().VisitAll(func(f *pflag.Flag) { + optName := strings.ToUpper(f.Name) + optName = strings.ReplaceAll(optName, "-", "_") + if val, ok := os.LookupEnv(optName); !f.Changed && ok { + fmt.Printf("Setting %s to %s\n", f.Name, val) + err2 := f.Value.Set(val) + if err2 != nil { + err = fmt.Errorf("invalid environment variable %s: %w", optName, err2) + } + } + }) + return err + }} + +func init() { + chatCmd.CompletionOptions.DisableDefaultCmd = true + + chatCmd.PersistentFlags().String(chatCmdURLFlag, "http://localhost:8080", "url to chat with your agent") + chatCmd.PersistentFlags().String(chatCmdUsernameFlag, "sparklyunicorn", "username to display in chat") + chatCmd.PersistentFlags().String(chatCmdTokenFlag, "", "GitHub token for chat authentication (optional)") + chatCmd.PersistentFlags().String(chatCmdLogLevelFlag, "DEBUG", "Log level to help debug events. Supported types are `DEBUG`, `TRACE`, `NONE`. `DEBUG` returns general logs. `TRACE` prints the raw http response.") + chatCmd.PersistentFlags().String(chatCmdPrivateKeyFlag, "", "Private key for payload verification") + chatCmd.PersistentFlags().String(chatCmdPublicKeyFlag, "", "Public key for payload verification") + +} + +func agentChat(cmd *cobra.Command, args []string) { + + url, _ := cmd.Flags().GetString(chatCmdURLFlag) + if url == "" { + fmt.Println("a url is required to chat with your agent") + } + + username, _ := cmd.Flags().GetString(chatCmdUsernameFlag) + + token, _ := cmd.Flags().GetString(chatCmdTokenFlag) + + debug, _ := cmd.Flags().GetString(chatCmdLogLevelFlag) + debug = strings.ToUpper(debug) + if debug != chat.LEVEL_NONE && debug != chat.LEVEL_DEBUG && debug != chat.LEVEL_TRACE { + fmt.Println("debug mode must be either `DEBUG`, `TRACE`, or `NONE`") + } + + err := chat.Chat(url, username, token, debug) + if err != nil { + fmt.Println(err) + } +} + +func Execute() { + err := chatCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a08bdf9 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/github-technology-partners/gh-debug-cli + +go 1.21.0 + +toolchain go1.21 + +require ( + github.com/alexeyco/simpletable v1.0.0 + github.com/google/uuid v1.6.0 + github.com/jclem/sseparser v0.5.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prataprc/goparsec v0.0.0-20211219142520-daac0e635e7e // indirect + github.com/rivo/uniseg v0.4.7 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..51067ba --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/alexeyco/simpletable v1.0.0 h1:ZQ+LvJ4bmoeHb+dclF64d0LX+7QAi7awsfCrptZrpHk= +github.com/alexeyco/simpletable v1.0.0/go.mod h1:VJWVTtGUnW7EKbMRH8cE13SigKGx/1fO2SeeOiGeBkk= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jclem/sseparser v0.5.0 h1:DcD6ZnzqnAqdUqjamc1nhGLSQRLbTXNYRF9NwdAYpEs= +github.com/jclem/sseparser v0.5.0/go.mod h1:XpIEwYl1LibAWsx7fxHD61/wBuuVmkRPZ/PBChZ97yU= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prataprc/goparsec v0.0.0-20211219142520-daac0e635e7e h1:7teoyCCMBovX+/L3/C2adcGNJI6Tsx6a2hbWQ8vWoO8= +github.com/prataprc/goparsec v0.0.0-20211219142520-daac0e635e7e/go.mod h1:YbpxZqbf10o5u96/iDpcfDQmbIOTX/iNCH/yBByTfaM= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ee2278a --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +/* +Copyright © 2024 NAME HERE +*/ +package main + +import "github.com/github-technology-partners/gh-debug-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/chat/agent.go b/pkg/chat/agent.go new file mode 100644 index 0000000..7928335 --- /dev/null +++ b/pkg/chat/agent.go @@ -0,0 +1,84 @@ +package chat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httputil" + + "github.com/google/uuid" +) + +func invokeAgent(ctx context.Context, url string, token string, history []Message, debugMode string) ([]*Message, error) { + copilotThreadID := uuid.New().String() + body := Request{ + Messages: history, + CopilotThreadID: copilotThreadID, + } + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(b)) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("Github-Public-Key-Signature", "") + req.Header.Set("Github-Public-Key-Identifier", "") + + if token != "" { + req.Header.Set("X-GitHub-Token", token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error sending request: %w", err) + } + defer resp.Body.Close() + + var buf messageBuffer + fn := func(data any) { + switch v := data.(type) { + case Completion: + buf.WriteChatMessage(v) + + case Confirmation: + buf.WriteConfirmation(v) + + case []Reference: + buf.WriteReferences(v) + + case []CopilotError: + buf.WriteErrors(v) + + default: + fmt.Printf("Invalid data type: %T\n", v) + } + } + + if shouldLog(debugMode, LEVEL_TRACE) { + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + log.Fatal(err) + } + + fmt.Print(yellow("Raw Response\n" + string(respDump) + "\n\n")) + } + + parser := NewParser(resp.Body, fn) + if err := parser.ParseAndEmit(ctx, debugMode); err != nil { + fmt.Println(err) + } + + if parser.ValidEventCount() { + return nil, fmt.Errorf("cannot have more than one event type in an invocation, found %d", parser.eventCount) + } + + return buf, nil +} diff --git a/pkg/chat/chat.go b/pkg/chat/chat.go new file mode 100644 index 0000000..770817e --- /dev/null +++ b/pkg/chat/chat.go @@ -0,0 +1,249 @@ +package chat + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/alexeyco/simpletable" +) + +func Chat(url string, username string, token string, logLevel string) error { + if url == "" { + return fmt.Errorf("agent url is required") + } + + ctx := context.Background() + var history []Message + + if _, err := fmt.Fprintf(os.Stdout, "\nStart typing to chat with your assistant...\n%s: ", magenta(username)); err != nil { + return fmt.Errorf("error writing to stdout: %w", err) + } + + // Read full message from stdin + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + userMessage := Message{ + Role: "user", + Content: scanner.Text(), + } + history = append(history, userMessage) + + msgs, err := invokeAgent(ctx, url, token, history, logLevel) + if err != nil { + return fmt.Errorf(red("error creating message: %w"), err) + } + + for _, msg := range msgs { + fmt.Fprint(os.Stdout, &Output{ + Message: msg, + LogLevel: logLevel, + }) + + chatMsg := Message{ + Role: msg.Role, + Content: msg.Content, + } + if msg.FunctionCall != nil { + chatMsg.FunctionCall = &ChatMessageFunctionCall{ + Name: msg.FunctionCall.Name, + Arguments: msg.FunctionCall.Arguments, + } + } + + history = append(history, chatMsg) + } + + if _, err := fmt.Fprintf(os.Stdout, "%s: ", magenta(username)); err != nil { + return fmt.Errorf("error writing to stdout: %w", err) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading from stdin: %w", err) + } + + return nil +} + +func (o *Output) String() string { + + m := o.Message + + var msg strings.Builder + if m.FunctionCall != nil { + if shouldLog(o.LogLevel, LEVEL_DEBUG) { + msg.WriteString(green("\nHuzzah! You successfully received a function call!\n")) + + table := simpletable.New() + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignCenter, Text: "Key"}, + {Align: simpletable.AlignCenter, Text: "Value"}, + }, + } + cells := [][]*simpletable.Cell{ + {{Text: "role"}, {Text: m.Role}}, + {{Text: "name"}, {Text: m.FunctionCall.Name}}, + {{Text: "arguments"}, {Text: m.FunctionCall.Arguments}}, + } + table.Body = &simpletable.Body{Cells: cells} + + table.Footer = &simpletable.Footer{Cells: []*simpletable.Cell{ + {Align: simpletable.AlignCenter, Span: 2, Text: "Parsed function data"}, + }} + + table.SetStyle(simpletable.StyleUnicode) + msg.WriteString(fmt.Sprintf("%s\n", green(table.String()))) + } + + } else { + if m.Role != "" && m.Content != "" { + msg.WriteString(fmt.Sprintf("%s: %s\n", cyan(m.Role), m.Content)) + + if shouldLog(o.LogLevel, LEVEL_DEBUG) { + msg.WriteString(fmt.Sprintf("\n%s\n", green("Huzzah! You successfully received a message!"))) + + table := simpletable.New() + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignCenter, Text: "Role"}, + {Align: simpletable.AlignCenter, Text: "Content"}, + }, + } + cells := [][]*simpletable.Cell{ + {{Text: m.Role}, {Text: fmt.Sprintf("[condensed] %.50s", m.Content)}}, + } + table.Body = &simpletable.Body{Cells: cells} + + table.Footer = &simpletable.Footer{Cells: []*simpletable.Cell{ + {Align: simpletable.AlignRight, Span: 2, Text: "Parsed message data"}, + }} + + table.SetStyle(simpletable.StyleUnicode) + msg.WriteString(fmt.Sprintf("%s\n", green(table.String()))) + } + } + } + + if m.Confirmation != nil { + if shouldLog(o.LogLevel, LEVEL_DEBUG) { + msg.WriteString(green("\nHuzzah! You successfully received a confirmation!\n")) + + table := simpletable.New() + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: "Key"}, + {Align: simpletable.AlignLeft, Text: "Value"}, + }, + } + + cells := [][]*simpletable.Cell{ + {{Text: "type"}, {Text: m.Confirmation.Type}}, + {{Text: "title"}, {Text: m.Confirmation.Title}}, + {{Text: "message"}, {Text: m.Confirmation.Message}}, + {{Text: "confirmation"}, {Text: fmt.Sprintf("%s", m.Confirmation.Confirmation)}}, + } + table.Body = &simpletable.Body{Cells: cells} + + table.Footer = &simpletable.Footer{Cells: []*simpletable.Cell{ + {Align: simpletable.AlignRight, Span: 2, Text: "Parsed confirmation data"}, + }} + + table.SetStyle(simpletable.StyleUnicode) + msg.WriteString(fmt.Sprintf("%s\n", green(table.String()))) + } + msg.WriteString(cyan(fmt.Sprintf("\n%s\n %s\nReply: [y/N]\n", m.Confirmation.Title, m.Confirmation.Message))) + } + + if len(m.References) > 0 { + // When debug mode is turned off, the refrerences are not explicitly displayed + if shouldLog(o.LogLevel, LEVEL_DEBUG) { + msg.WriteString(green("\nHuzzah! You successfully received some references!\n")) + + table := simpletable.New() + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: "index"}, + {Align: simpletable.AlignLeft, Text: "id"}, + {Align: simpletable.AlignLeft, Text: "type"}, + {Align: simpletable.AlignLeft, Text: "data"}, + {Align: simpletable.AlignLeft, Text: "display_icon"}, + {Align: simpletable.AlignLeft, Text: "display_name"}, + {Align: simpletable.AlignLeft, Text: "display_url"}, + }, + } + + var cells [][]*simpletable.Cell + for i, reference := range m.References { + cells = append(cells, []*simpletable.Cell{ + {Text: fmt.Sprintf("%d", i)}, + {Text: reference.ID}, + {Text: reference.Type}, + {Text: fmt.Sprintf("[condensed] %.20s", reference.Data)}, + {Text: reference.Metadata.DisplayIcon}, + {Text: reference.Metadata.DisplayName}, + {Text: reference.Metadata.DisplayURL}, + }) + + } + table.Body = &simpletable.Body{Cells: cells} + + table.Footer = &simpletable.Footer{Cells: []*simpletable.Cell{ + {Align: simpletable.AlignRight, Span: 7, Text: "Parsed references data"}, + }} + + table.SetStyle(simpletable.StyleUnicode) + msg.WriteString(fmt.Sprintf("%s\n", green(table.String()))) + } + + for i, reference := range m.References { + msg.WriteString(fmt.Sprintf("%d. %s: %s\n", i+1, reference.ID, reference.Metadata.DisplayName)) + } + } + + if len(m.Errors) > 0 { + table := simpletable.New() + + if shouldLog(o.LogLevel, LEVEL_DEBUG) { + msg.WriteString(green("\nHuzzah! You successfully received some errors!\n")) + + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: "index"}, + {Align: simpletable.AlignLeft, Text: "message"}, + {Align: simpletable.AlignLeft, Text: "type"}, + {Align: simpletable.AlignLeft, Text: "code"}, + {Align: simpletable.AlignLeft, Text: "identifier"}, + }, + } + + var cells [][]*simpletable.Cell + for i, error := range m.Errors { + cells = append(cells, []*simpletable.Cell{ + {Text: fmt.Sprintf("%d", i)}, + {Text: error.Message}, + {Text: error.Type}, + {Text: error.Code}, + {Text: error.Identifier}, + }) + } + table.Body = &simpletable.Body{Cells: cells} + + table.Footer = &simpletable.Footer{Cells: []*simpletable.Cell{ + {Align: simpletable.AlignRight, Span: 5, Text: "Parsed error data"}, + }} + + table.SetStyle(simpletable.StyleUnicode) + msg.WriteString(fmt.Sprintf("%s\n", green(table.String()))) + } + + for i, error := range m.Errors { + msg.WriteString(fmt.Sprintf("%d. %s error: %s\n", i+1, error.Type, error.Message)) + } + } + + return msg.String() +} diff --git a/pkg/chat/chat_test.go b/pkg/chat/chat_test.go new file mode 100644 index 0000000..f178dbd --- /dev/null +++ b/pkg/chat/chat_test.go @@ -0,0 +1,69 @@ +package chat + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChat(t *testing.T) { + tests := []struct { + name string + url string + username string + token string + expectedError error + }{ + { + name: "happy_path", + url: "http://localhost:8080", + username: "username", + token: "token", + expectedError: nil, + }, + { + name: "failure_agent_url_empty", + url: "", + username: "username", + token: "token", + expectedError: fmt.Errorf("agent url is required"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualError := Chat(tt.url, tt.username, tt.token, LEVEL_NONE) + assert.Equal(t, tt.expectedError, actualError) + }) + } +} + +func TestOutput_String(t *testing.T) { + tests := []struct { + name string + output *Output + expectedString string + }{ + { + name: "happy_path_function_call", + output: &Output{ + Message: &Message{ + FunctionCall: &ChatMessageFunctionCall{ + Name: "test", + Arguments: "args", + }, + }, + LogLevel: LEVEL_DEBUG, + }, + expectedString: "\x1b[32m\nHuzzah! You successfully received a function call!\n\x1b[37m\x1b[32m╔═════════════╤════════╗\n║ Key │ Value ║\n╟━━━━━━━━━━━━━┼━━━━━━━━╢\n║ role │ ║\n║ name │ test ║\n║ arguments │ args ║\n╟━━━━━━━━━━━━━┼━━━━━━━━╢\n║ Parsed function data ║\n╚═════════════╧════════╝\x1b[37m\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualString := tt.output.String() + assert.Equal(t, tt.expectedString, actualString) + }) + } +} diff --git a/pkg/chat/colors.go b/pkg/chat/colors.go new file mode 100644 index 0000000..925fc40 --- /dev/null +++ b/pkg/chat/colors.go @@ -0,0 +1,32 @@ +package chat + +import "fmt" + +const ( + ColorDefault = "\x1b[37m" + ColorRed = "\x1b[31m" + ColorGreen = "\x1b[32m" + ColorYellow = "\x1b[33m" + ColorMagenta = "\x1b[35m" + ColorCyan = "\x1b[36m" +) + +func red(s string) string { + return fmt.Sprintf("%s%s%s", ColorRed, s, ColorDefault) +} + +func green(s string) string { + return fmt.Sprintf("%s%s%s", ColorGreen, s, ColorDefault) +} + +func yellow(s string) string { + return fmt.Sprintf("%s%s%s", ColorYellow, s, ColorDefault) +} + +func magenta(s string) string { + return fmt.Sprintf("%s%s%s", ColorMagenta, s, ColorDefault) +} + +func cyan(s string) string { + return fmt.Sprintf("%s%s%s", ColorCyan, s, ColorDefault) +} diff --git a/pkg/chat/parser.go b/pkg/chat/parser.go new file mode 100644 index 0000000..2ac8874 --- /dev/null +++ b/pkg/chat/parser.go @@ -0,0 +1,295 @@ +package chat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/jclem/sseparser" +) + +const ( + sseDataField = "data" + sseEventField = "event" +) + +type dataEmitter func(data any) + +// Parser is a parser for ServerSent Events (SSE). +type Parser struct { + buf io.Reader + fn dataEmitter + eventCount int +} + +// NewParser creates a new SSEParser. +func NewParser(buf io.Reader, fn dataEmitter) *Parser { + return &Parser{ + buf: buf, + fn: fn, + eventCount: 0, + } +} + +// ParseAndEmit parses the SSE stream and emits the parsed events. +func (p *Parser) ParseAndEmit(ctx context.Context, debug string) error { + scanner := sseparser.NewStreamScanner(p.buf) + + for { + event, _, err := scanner.Next() + if err != nil { + if errors.Is(err, sseparser.ErrStreamEOF) { + return nil + } + return fmt.Errorf("failed to read from stream: %w", err) + } + + eventFields := map[string]string{} + dataFields := []string{} + for _, field := range event.Fields() { + switch field.Name { + case sseEventField: + eventFields[field.Name] = field.Value + case sseDataField: + dataFields = append(dataFields, field.Value) + default: + return fmt.Errorf(red("only 'event' and 'data' fields are supported, found: %s\n\n"), field.Name) + } + } + + switch { + case eventFields[sseEventField] == "copilot_confirmation": + p.eventCount++ + err := emitConfirmation(dataFields, p.fn) + if err != nil && shouldLog(debug, LEVEL_DEBUG) { + return fmt.Errorf(red("\nAlas...The following is not a valid copilot confirmation:\n%v\n\nErrors:\n%v\n\n"), dataFields, err) + } + + case eventFields[sseEventField] == "copilot_references": + p.eventCount++ + err := emitReferences(dataFields, p.fn) + if err != nil && shouldLog(debug, LEVEL_DEBUG) { + return fmt.Errorf(red("\nAlas...The following is not a valid copilot reference:\n%v\n\nErrors:\n%v\n\n"), dataFields, err) + } + + case eventFields[sseEventField] == "copilot_errors": + p.eventCount++ + err := emitErrors(dataFields, p.fn) + if err != nil && shouldLog(debug, LEVEL_DEBUG) { + return fmt.Errorf(red("\nAlas...The following is not a valid a copilot error:\n%v\n\nErrors:\n%v\n\n"), dataFields, err) + } + + case eventFields[sseEventField] != "": + if shouldLog(debug, LEVEL_DEBUG) { + err := fmt.Errorf("type not supported: %s", eventFields[sseEventField]) + return fmt.Errorf(red("\nAlas...The following is not a valid event:\n%v\n\nErrors:\n%v\n\n"), dataFields, err) + } + + default: + if _, ok := eventFields[sseEventField]; ok && shouldLog(debug, LEVEL_DEBUG) { + err := fmt.Errorf("event field must have a type") + return fmt.Errorf(red("\nAlas...The following is not a valid event:\n%v\n\nErrors:\n%v\n\n"), dataFields, err) + } + + err := emitDatas(dataFields, p.fn) + if err != nil && shouldLog(debug, LEVEL_DEBUG) { + return fmt.Errorf(red("\nAlas...Failed to process data fields:\n%v\n\nErrors: %v\n\n"), dataFields, err) + } + } + } +} + +func (p *Parser) ValidEventCount() bool { + return p.eventCount > 1 +} + +func emitErrors(data []string, fn dataEmitter) error { + for _, d := range data { + var errs []CopilotError + if err := json.Unmarshal([]byte(d), &errs); err != nil { + return fmt.Errorf("ensure data is an array of copilot_errors") + } + + if len(errs) == 0 { + return fmt.Errorf("no errors found") + } + + var errMsg strings.Builder + for i, err := range errs { + if err.Type == "" { + errMsg.WriteString(fmt.Sprintf("error %d is missing a type\n", i)) + } + if err.Code == "" { + errMsg.WriteString(fmt.Sprintf("error %d is missing a code\n", i)) + } + if err.Message == "" { + errMsg.WriteString(fmt.Sprintf("error %d is missing a message\n", i)) + } + if err.Identifier == "" { + errMsg.WriteString(fmt.Sprintf("error %d is missing an identifier\n", i)) + } + } + + if errMsg.Len() > 0 { + return fmt.Errorf(errMsg.String()) + } + + fn(errs) + } + + return nil +} + +func emitReferences(data []string, fn dataEmitter) error { + for _, d := range data { + var refs []Reference + if err := json.Unmarshal([]byte(d), &refs); err != nil { + return fmt.Errorf("ensure data is an array of copilot_references") + } + + if len(refs) == 0 { + return fmt.Errorf("no references found") + } + + var errMsg strings.Builder + for i, ref := range refs { + if ref.Type == "" { + errMsg.WriteString(fmt.Sprintf("ref %d is missing a type\n", i)) + } + if ref.ID == "" { + errMsg.WriteString(fmt.Sprintf("ref %d is missing an id\n", i)) + } + if ref.Metadata.DisplayName == "" { + errMsg.WriteString(fmt.Sprintf("ref %d is missing a metadata display name\n", i)) + } + } + + if errMsg.Len() > 0 { + return fmt.Errorf(errMsg.String()) + } + + fn(refs) + } + + return nil +} + +func emitConfirmation(data []string, fn dataEmitter) error { + for _, d := range data { + var confirmation Confirmation + if err := json.Unmarshal([]byte(d), &confirmation); err != nil { + return fmt.Errorf("ensure data is of type copilot_confirmation") + } + + var errMsg strings.Builder + if confirmation.Type == "" { + errMsg.WriteString("confirmation is missing a type\n") + } + if confirmation.Title == "" { + errMsg.WriteString("confirmation is missing a title\n") + } + if confirmation.Message == "" { + errMsg.WriteString("confirmation is missing a message\n") + } + + if errMsg.Len() > 0 { + return fmt.Errorf(errMsg.String()) + } + + fn(confirmation) + } + + return nil +} + +func emitDatas(datas []string, fn dataEmitter) error { + for _, data := range datas { + if data == "" || data == "[DONE]" { + continue + } + + var message Message + if err := json.Unmarshal([]byte(data), &message); err == nil { + var errMsg strings.Builder + + if message.Confirmation != nil { + errMsg.WriteString("setting confirmation in a message payload is not supported\n") + } + + if message.Errors != nil { + errMsg.WriteString("setting errors in a message payload is not supported\n") + } + + if message.References != nil { + errMsg.WriteString("setting references in a message payload is not supported\n") + } + + if errMsg.Len() > 0 { + return fmt.Errorf(errMsg.String()) + } + } + + var chatMessage Completion + if err := json.Unmarshal([]byte(data), &chatMessage); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + + fn(chatMessage) + } + + return nil +} + +type messageBuffer []*Message + +func (mb *messageBuffer) lastMessage() *Message { + if len(*mb) == 0 { + *mb = append(*mb, new(Message)) + } + + buf := *mb + return buf[len(buf)-1] +} + +func (mb *messageBuffer) WriteConfirmation(c Confirmation) { + if last := mb.lastMessage(); last != nil { + last.Confirmation = &c + } +} + +func (mb *messageBuffer) WriteReferences(r []Reference) { + if last := mb.lastMessage(); last != nil { + last.References = r + } +} + +func (mb *messageBuffer) WriteErrors(e []CopilotError) { + if last := mb.lastMessage(); last != nil { + last.Errors = e + } +} + +func (mb *messageBuffer) WriteChatMessage(m Completion) { + if len(m.Choices) > 0 { + choice := m.Choices[0] + lastmsg := mb.lastMessage() + + // ensure that the last message in the buffer has the same role as the choice + // this will help us group delta messages by role + if lastmsg.Role != "" && lastmsg.Role != choice.Delta.Role && choice.Delta.Role != "" { + lastmsg = &Message{Role: choice.Delta.Role} + *mb = append(*mb, lastmsg) + } + + // ensure the first time we see a delta message, we set the role of the last message + if choice.Delta.Role != "" { + lastmsg.Role = choice.Delta.Role + } + + lastmsg.Content += choice.Delta.Content + lastmsg.FunctionCall = choice.Delta.FunctionCall + } +} diff --git a/pkg/chat/parser_test.go b/pkg/chat/parser_test.go new file mode 100644 index 0000000..1950958 --- /dev/null +++ b/pkg/chat/parser_test.go @@ -0,0 +1,298 @@ +package chat + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseAndEmit(t *testing.T) { + testCases := []struct { + name string + stream string + expectedAny []any + expectedTypes []interface{} + expectedError error + }{ + { + name: "happy_path", + stream: `event: copilot_confirmation +data: {"type": "confirm", "title": "Test Confirmation", "message": "This is a test confirmation"} + +event: copilot_references +data: [{"type": "ref", "id": "1", "metadata": {"display_name": "Test Reference"}}] + +event: copilot_errors +data: [{"type": "error", "code": "E1", "message": "Test Error", "identifier": "1"}] + +data: {"choices":[{"delta":{"content":"ahoy there"}}]} + + `, + expectedTypes: []interface{}{ + Confirmation{}, + []Reference{}, + []CopilotError{}, + Completion{}, + }, + expectedAny: []any{ + Confirmation{ + Type: "confirm", + Title: "Test Confirmation", + Message: "This is a test confirmation", + }, + []Reference{ + { + Type: "ref", + ID: "1", + Metadata: ReferenceMetadata{ + DisplayName: "Test Reference", + }, + }, + }, + []CopilotError{ + { + Type: "error", + Code: "E1", + Message: "Test Error", + Identifier: "1", + }, + }, + Completion{ + Choices: []CompletionChoice{ + { + Delta: Message{ + Content: "ahoy there", + }, + }, + }, + }, + }, + expectedError: nil, + }, + { + name: "happy_path_arrays", + stream: `event: copilot_confirmation +data: {"type": "confirm", "title": "Test Confirmation", "message": "This is a test confirmation"} + +event: copilot_references +data: [{"type": "ref", "id": "1", "metadata": {"display_name": "Test Reference"}}, {"type": "ref", "id": "2", "metadata": {"display_name": "Test Reference"}}] + +event: copilot_errors +data: [{"type": "error", "code": "E1", "message": "Test Error", "identifier": "1"}, {"type": "error", "code": "E1", "message": "Test Error", "identifier": "2"}] + +data: {"choices":[{"delta":{"content":"ahoy there"}}]} + + `, + expectedTypes: []interface{}{ + Confirmation{}, + []Reference{}, + []CopilotError{}, + Completion{}, + }, + expectedAny: []any{ + Confirmation{ + Type: "confirm", + Title: "Test Confirmation", + Message: "This is a test confirmation", + }, + []Reference{ + { + Type: "ref", + ID: "1", + Metadata: ReferenceMetadata{ + DisplayName: "Test Reference", + }, + }, + { + Type: "ref", + ID: "2", + Metadata: ReferenceMetadata{ + DisplayName: "Test Reference", + }, + }, + }, + []CopilotError{ + { + Type: "error", + Code: "E1", + Message: "Test Error", + Identifier: "1", + }, + { + Type: "error", + Code: "E1", + Message: "Test Error", + Identifier: "2", + }, + }, + Completion{ + Choices: []CompletionChoice{ + { + Delta: Message{ + Content: "ahoy there", + }, + }, + }, + }, + }, + expectedError: nil, + }, + { + name: "failure_mismatched_event_types", + stream: `event: copilot_references +data: {"type": "confirm", "title": "Test Confirmation", "message": "This is a test confirmation"} + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...The following is not a valid copilot reference:\n[{\"type\": \"confirm\", \"title\": \"Test Confirmation\", \"message\": \"This is a test confirmation\"}]\n\nErrors:\nensure data is an array of copilot_references\n\n\x1b[37m"), + }, + { + name: "failure_invalid_event_type", + stream: `retry: copilot_references + + `, + expectedError: fmt.Errorf("\x1b[31monly 'event' and 'data' fields are supported, found: retry\n\n\x1b[37m"), + }, + { + name: "failure_missing_copilot_reference_metadata", + stream: `event: copilot_references +data: [{"type": "", "id": "", "metadata": {"display_name": ""}}] + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...The following is not a valid copilot reference:\n[[{\"type\": \"\", \"id\": \"\", \"metadata\": {\"display_name\": \"\"}}]]\n\nErrors:\nref 0 is missing a type\nref 0 is missing an id\nref 0 is missing a metadata display name\n\n\n\x1b[37m"), + }, + { + name: "failure_missing_data_field", + stream: `event: copilot_references +[{"type": "", "id": "", "metadata": {"display_name": ""}}] + + `, + expectedError: fmt.Errorf("\x1b[31monly 'event' and 'data' fields are supported, found: [{\"type\"\n\n\x1b[37m"), + }, + { + name: "failure_references_not_array", + stream: `event: copilot_references +data: {"type": "ref", "id": "1", "metadata": {"display_name": "Test Reference"}} + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...The following is not a valid copilot reference:\n[{\"type\": \"ref\", \"id\": \"1\", \"metadata\": {\"display_name\": \"Test Reference\"}}]\n\nErrors:\nensure data is an array of copilot_references\n\n\x1b[37m"), + }, + { + name: "failure_invalid_message_chunk", + stream: `data: {"copilot_confirmation": {"type":"action","title":"Turn off feature flag","message":"Are you sure you wish to turn off the feature flag?","confirmation":{"id":"id-123"}}} + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...Failed to process data fields:\n[{\"copilot_confirmation\": {\"type\":\"action\",\"title\":\"Turn off feature flag\",\"message\":\"Are you sure you wish to turn off the feature flag?\",\"confirmation\":{\"id\":\"id-123\"}}}]\n\nErrors: setting confirmation in a message payload is not supported\n\n\n\x1b[37m"), + }, + { + name: "failure_invalid_error_type", + stream: `event: error +data: {"type":"function","code":"foo","message":"A function error occurred","identifier":"fn123"} + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...The following is not a valid event:\n[{\"type\":\"function\",\"code\":\"foo\",\"message\":\"A function error occurred\",\"identifier\":\"fn123\"}]\n\nErrors:\ntype not supported: error\n\n\x1b[37m"), + }, + { + name: "failure_invalid_error_must_be_array", + stream: `event: copilot_errors +data: {"type":"function","code":"foo","message":"A function error occurred","identifier":"fn123"} + + `, + expectedError: fmt.Errorf("\x1b[31m\nAlas...The following is not a valid a copilot error:\n[{\"type\":\"function\",\"code\":\"foo\",\"message\":\"A function error occurred\",\"identifier\":\"fn123\"}]\n\nErrors:\nensure data is an array of copilot_errors\n\n\x1b[37m"), + }, + { + name: "failure_extra_double_quotes", + stream: `event: copilot_errors +data: [{"type":"reference","code":"foo","message":"A reference error occurred","identifier":"ref123"},{"type":"function","code":"foo","message":"A function error occurred","identifier":"fn123"},{"type":"agent","code":"foo","message":"An agent error occurred","identifier":"agt123"}] + +"data: [DONE]" + + `, + expectedError: fmt.Errorf("\x1b[31monly 'event' and 'data' fields are supported, found: \"data\n\n\x1b[37m"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var emittedData []any + dataEmitter := func(data any) { + emittedData = append(emittedData, data) + } + + p := NewParser(bytes.NewBufferString(tc.stream), dataEmitter) + err := p.ParseAndEmit(context.Background(), LEVEL_DEBUG) + + assert.Equal(t, tc.expectedError, err) + + if tc.expectedAny != nil { + if emittedData != nil { + assert.Equal(t, len(tc.expectedAny), len(emittedData)) + } else { + assert.Fail(t, "emittedData is nil") + } + } + + for i, expectedType := range tc.expectedTypes { + switch v := expectedType.(type) { + case Confirmation: + actualConfirmation, ok := emittedData[i].(Confirmation) + assert.True(t, ok) + + expectedConfirmation, ok := tc.expectedAny[i].(Confirmation) + assert.True(t, ok) + + assert.Equal(t, expectedConfirmation.Type, actualConfirmation.Type) + assert.Equal(t, expectedConfirmation.Title, actualConfirmation.Title) + assert.Equal(t, expectedConfirmation.Message, actualConfirmation.Message) + case []Reference: + actualReferences, ok := emittedData[i].([]Reference) + assert.True(t, ok) + + expectedReferences, ok := tc.expectedAny[i].([]Reference) + assert.True(t, ok) + + assert.Equal(t, len(actualReferences), len(expectedReferences)) + + for j := range actualReferences { + assert.Equal(t, actualReferences[j].Type, expectedReferences[j].Type) + assert.Equal(t, actualReferences[j].ID, expectedReferences[j].ID) + assert.Equal(t, actualReferences[j].Metadata.DisplayName, expectedReferences[j].Metadata.DisplayName) + } + case []CopilotError: + actualCopilotErrors, ok := emittedData[i].([]CopilotError) + assert.True(t, ok) + + expectedCopilotErrors, ok := tc.expectedAny[i].([]CopilotError) + assert.True(t, ok) + + assert.Equal(t, len(actualCopilotErrors), len(expectedCopilotErrors)) + + for j := range actualCopilotErrors { + assert.Equal(t, actualCopilotErrors[j].Type, actualCopilotErrors[j].Type) + assert.Equal(t, actualCopilotErrors[j].Code, actualCopilotErrors[j].Code) + assert.Equal(t, actualCopilotErrors[j].Message, actualCopilotErrors[j].Message) + assert.Equal(t, actualCopilotErrors[j].Identifier, actualCopilotErrors[j].Identifier) + } + case Completion: + actualChat, ok := emittedData[i].(Completion) + assert.True(t, ok) + + expectedChat, ok := tc.expectedAny[i].(Completion) + assert.True(t, ok) + + for j, actualChoice := range actualChat.Choices { + assert.Equal(t, expectedChat.Choices[j].Delta.Role, actualChoice.Delta.Role) + assert.Equal(t, expectedChat.Choices[j].Delta.Content, actualChoice.Delta.Content) + assert.Equal(t, expectedChat.Choices[j].Delta.Name, actualChoice.Delta.Name) + } + + default: + assert.Fail(t, fmt.Sprintf("unexpected type %T", v)) + } + } + }) + } + +} diff --git a/pkg/chat/structs.go b/pkg/chat/structs.go new file mode 100644 index 0000000..f525979 --- /dev/null +++ b/pkg/chat/structs.go @@ -0,0 +1,62 @@ +package chat + +type Output struct { + Message *Message `json:"message"` + LogLevel string `json:"log_level"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + Name string `json:"name,omitempty"` + FunctionCall *ChatMessageFunctionCall `json:"function_call,omitempty"` + Confirmation *Confirmation `json:"copilot_confirmation"` + References []Reference `json:"copilot_references"` + Errors []CopilotError `json:"copilot_errors"` +} + +type Completion struct { + Choices []CompletionChoice `json:"choices"` +} + +type CompletionChoice struct { + Delta Message `json:"delta"` +} + +type Request struct { + CopilotThreadID string `json:"copilot_thread_id"` + Messages []Message `json:"messages"` + Agent string `json:"agent"` +} + +type ChatMessageFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type Confirmation struct { + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Confirmation any `json:"confirmation"` +} + +type Reference struct { + Type string `json:"type"` + ID string `json:"id"` + Data any `json:"data"` + Metadata ReferenceMetadata `json:"metadata"` +} + +type ReferenceMetadata struct { + DisplayName string `json:"display_name"` + DisplayIcon string `json:"display_icon"` + DisplayURL string `json:"display_url"` +} + +type CopilotError struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` + Identifier string `json:"identifier"` +} diff --git a/pkg/chat/utils.go b/pkg/chat/utils.go new file mode 100644 index 0000000..bb81575 --- /dev/null +++ b/pkg/chat/utils.go @@ -0,0 +1,20 @@ +package chat + +const ( + LEVEL_NONE = "NONE" + LEVEL_DEBUG = "DEBUG" + LEVEL_TRACE = "TRACE" +) + +// if debugMode = trace, then it will log all the debug messages +// if debugMode = info, then it will log only the general logs +func shouldLog(debugMode string, logLevel string) bool { + switch debugMode { + case LEVEL_NONE: + return false + case LEVEL_TRACE: + return true + default: + return debugMode == logLevel + } +}