From edee917d881eebcd2fba88d3e2bb29358c3f1989 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Feb 2026 11:50:56 -0500 Subject: [PATCH] feat: add experimental agents support (#22290) feat: add AI chat system with agent tools and chat UI Introduce the chatd subsystem and Agents UI for AI-powered chat within Coder workspaces. - Add chatd package with chat loop, message compaction, prompt management, and LLM provider integration (OpenAI, Anthropic) - Add agent tools: create workspace, list/read templates, read/write/ edit files, execute commands - Add chat API endpoints with streaming, message editing, and durable reconnection - Add database schema and migrations for chats, chat messages, chat providers, and chat model configs - Add RBAC policies and dbauthz enforcement for chat resources - Add Agents UI pages with conversation timeline, queued messages list, diff viewer, and model configuration panel - Add comprehensive test coverage including coderd integration tests, chatd unit tests, and Storybook stories - Gate feature behind experiments flag --------- Co-authored-by: Cian Johnston Co-authored-by: Danielle Maywood Co-authored-by: Jeremy Ruppel Co-authored-by: Claude Sonnet 4.6 --- Makefile | 24 +- cli/gitaskpass.go | 46 +- cli/server.go | 154 +- cli/server_test.go | 151 + cli/testdata/coder_server_--help.golden | 3 + cli/testdata/server-config.yaml.golden | 3 + coderd/aitasks.go | 3 +- coderd/apidoc/docs.go | 19 + coderd/apidoc/swagger.json | 19 + coderd/authorize.go | 48 + coderd/chatd/chatd.go | 2375 +++++++++++++ coderd/chatd/chatd_test.go | 461 +++ coderd/chatd/chatloop/chatloop.go | 676 ++++ coderd/chatd/chatloop/chatloop_test.go | 289 ++ coderd/chatd/chatloop/compaction.go | 209 ++ coderd/chatd/chatloop/compaction_test.go | 132 + coderd/chatd/chatprompt/chatprompt.go | 982 ++++++ coderd/chatd/chatprompt/chatprompt_test.go | 91 + coderd/chatd/chatprovider/chatprovider.go | 1330 +++++++ .../chatd/chatprovider/chatprovider_test.go | 191 + coderd/chatd/chattest/anthropic.go | 403 +++ coderd/chatd/chattest/anthropic_test.go | 221 ++ coderd/chatd/chattest/openai.go | 458 +++ coderd/chatd/chattest/openai_test.go | 367 ++ coderd/chatd/chattool/chattool.go | 33 + coderd/chatd/chattool/createworkspace.go | 426 +++ coderd/chatd/chattool/editfiles.go | 50 + coderd/chatd/chattool/execute.go | 133 + coderd/chatd/chattool/listtemplates.go | 94 + coderd/chatd/chattool/readfile.go | 72 + coderd/chatd/chattool/readtemplate.go | 130 + coderd/chatd/chattool/writefile.go | 51 + coderd/chatd/instruction.go | 126 + coderd/chatd/instruction_test.go | 134 + coderd/chatd/prompt.go | 73 + coderd/chatd/subagent.go | 512 +++ coderd/chatd/title.go | 216 ++ coderd/chats.go | 3093 +++++++++++++++++ coderd/chats_test.go | 2067 +++++++++++ coderd/coderd.go | 64 +- coderd/database/check_constraint.go | 29 +- coderd/database/db2sdk/db2sdk.go | 345 ++ coderd/database/db2sdk/db2sdk_test.go | 131 + coderd/database/dbauthz/dbauthz.go | 425 ++- coderd/database/dbauthz/dbauthz_test.go | 374 +- coderd/database/dbmetrics/querymetrics.go | 361 ++ coderd/database/dbmock/dbmock.go | 667 ++++ coderd/database/dump.sql | 236 +- coderd/database/foreign_key_constraint.go | 15 + .../database/migrations/000422_chats.down.sql | 8 + .../database/migrations/000422_chats.up.sql | 167 + .../000422_chat_provider_model_configs.up.sql | 114 + coderd/database/modelmethods.go | 4 + coderd/database/models.go | 238 +- coderd/database/querier.go | 51 + coderd/database/querier_test.go | 41 + coderd/database/queries.sql.go | 1876 +++++++++- coderd/database/queries/chatmodelconfigs.sql | 129 + coderd/database/queries/chatproviders.sql | 75 + coderd/database/queries/chats.sql | 409 +++ coderd/database/queries/workspaces.sql | 2 +- coderd/database/unique_constraint.go | 8 + coderd/httpmw/chatparam.go | 50 + coderd/httpmw/chatparam_test.go | 159 + coderd/httpmw/requestid.go | 13 +- coderd/httpmw/requestid_test.go | 15 + coderd/pubsub/chatevent.go | 46 + coderd/pubsub/chatstreamnotify.go | 37 + coderd/rbac/object_gen.go | 11 + coderd/rbac/policy/policy.go | 10 + coderd/rbac/roles_test.go | 14 + coderd/rbac/scopes_constants_gen.go | 12 + coderd/workspaceagents.go | 20 +- coderd/workspaces.go | 23 +- codersdk/agentsdk/agentsdk.go | 14 + codersdk/agentsdk/agentsdk_test.go | 30 + codersdk/apikey_scopes_gen.go | 5 + codersdk/chats.go | 903 +++++ codersdk/chats_test.go | 53 + codersdk/deployment.go | 140 +- codersdk/rbacresources_gen.go | 2 + docs/reference/api/general.md | 1 + docs/reference/api/members.md | 40 +- docs/reference/api/schemas.md | 165 +- docs/reference/api/users.md | 10 +- docs/reference/cli/server.md | 11 + .../cli/testdata/coder_server_--help.golden | 3 + enterprise/coderd/chats.go | 172 + enterprise/coderd/chats_test.go | 355 ++ enterprise/coderd/coderd.go | 99 +- enterprise/dbcrypt/cliutil.go | 57 +- enterprise/dbcrypt/dbcrypt.go | 87 + flake.lock | 6 +- flake.nix | 32 +- go.mod | 126 +- go.sum | 1643 +-------- provisioner/terraform/serve_internal_test.go | 2 +- scripts/biome_format.sh | 33 + scripts/clidocgen/main.go | 4 + site/e2e/helpers.ts | 17 +- site/jest.config.ts | 2 + site/package.json | 3 + site/pnpm-lock.yaml | 381 ++ site/src/App.tsx | 19 +- site/src/api/api.test.ts | 46 + site/src/api/api.ts | 220 ++ site/src/api/queries/chats.ts | 188 + site/src/api/queries/workspaces.ts | 10 + site/src/api/rbacresourcesGenerated.ts | 6 + site/src/api/typesGenerated.ts | 628 ++++ site/src/components/Dialog/Dialog.tsx | 2 +- site/src/components/Markdown/Markdown.tsx | 5 +- site/src/components/ScrollArea/ScrollArea.tsx | 22 +- site/src/components/Select/Select.tsx | 2 +- .../ai-elements/conversation.stories.tsx | 75 + .../components/ai-elements/conversation.tsx | 42 + site/src/components/ai-elements/index.ts | 7 + site/src/components/ai-elements/message.tsx | 29 + .../ai-elements/model-selector.stories.tsx | 153 + .../components/ai-elements/model-selector.tsx | 164 + .../ai-elements/response.stories.tsx | 68 + site/src/components/ai-elements/response.tsx | 155 + .../ai-elements/runtimeTypeUtils.ts | 25 + site/src/components/ai-elements/shimmer.tsx | 77 + site/src/components/ai-elements/thinking.tsx | 17 + .../components/ai-elements/tool.stories.tsx | 470 +++ .../ai-elements/tool/ChatSummarizedTool.tsx | 68 + .../ai-elements/tool/CreateWorkspaceTool.tsx | 83 + .../ai-elements/tool/EditFilesTool.tsx | 107 + .../ai-elements/tool/ExecuteTool.tsx | 228 ++ .../ai-elements/tool/ListTemplatesTool.tsx | 98 + .../ai-elements/tool/ReadFileTool.tsx | 81 + .../ai-elements/tool/ReadTemplateTool.tsx | 54 + .../ai-elements/tool/SubagentTool.tsx | 184 + site/src/components/ai-elements/tool/Tool.tsx | 483 +++ .../ai-elements/tool/ToolCollapsible.tsx | 59 + .../components/ai-elements/tool/ToolIcon.tsx | 35 + .../components/ai-elements/tool/ToolLabel.tsx | 156 + .../ai-elements/tool/WriteFileTool.tsx | 84 + site/src/components/ai-elements/tool/index.ts | 1 + .../components/ai-elements/tool/utils.test.ts | 587 ++++ site/src/components/ai-elements/tool/utils.ts | 389 +++ site/src/contexts/DiffsWorkerPoolProvider.tsx | 51 + site/src/hooks/useEmbeddedMetadata.test.ts | 10 + site/src/hooks/useEmbeddedMetadata.ts | 2 + site/src/index.css | 19 + .../modules/dashboard/Navbar/NavbarView.tsx | 25 + .../AgentsPage/AgentChatInput.stories.tsx | 103 + site/src/pages/AgentsPage/AgentChatInput.tsx | 582 ++++ .../pages/AgentsPage/AgentDetail.stories.tsx | 452 +++ site/src/pages/AgentsPage/AgentDetail.tsx | 861 +++++ .../AgentDetail/ChatContext.test.tsx | 847 +++++ .../AgentsPage/AgentDetail/ChatContext.ts | 690 ++++ .../AgentDetail/ConversationTimeline.tsx | 703 ++++ .../AgentsPage/AgentDetail/TopBarPortals.tsx | 199 ++ .../AgentsPage/AgentDetail/blockUtils.test.ts | 219 ++ .../AgentsPage/AgentDetail/blockUtils.ts | 84 + .../AgentDetail/chatHelpers.test.ts | 319 ++ .../AgentsPage/AgentDetail/chatHelpers.ts | 124 + .../AgentDetail/messageParsing.test.ts | 275 ++ .../AgentsPage/AgentDetail/messageParsing.ts | 339 ++ .../AgentDetail/streamState.test.ts | 254 ++ .../AgentsPage/AgentDetail/streamState.ts | 194 ++ .../AgentDetail/streamingJson.test.ts | 83 + .../AgentsPage/AgentDetail/streamingJson.ts | 434 +++ .../src/pages/AgentsPage/AgentDetail/types.ts | 78 + .../AgentDetail/useMessageWindow.ts | 55 + .../pages/AgentsPage/AgentsPage.stories.tsx | 159 + site/src/pages/AgentsPage/AgentsPage.tsx | 785 +++++ .../AgentsPage/AgentsSidebar.stories.tsx | 240 ++ site/src/pages/AgentsPage/AgentsSidebar.tsx | 696 ++++ site/src/pages/AgentsPage/AgentsSkeletons.tsx | 82 + .../ChatModelAdminPanel.stories.tsx | 535 +++ .../ChatModelAdminPanel.tsx | 376 ++ .../ChatModelAdminPanel/ModelConfigFields.tsx | 536 +++ .../ChatModelAdminPanel/ModelForm.tsx | 604 ++++ .../ChatModelAdminPanel/ModelsSection.tsx | 230 ++ .../ChatModelAdminPanel/ProviderForm.tsx | 226 ++ .../ChatModelAdminPanel/ProviderIcon.tsx | 57 + .../ChatModelAdminPanel/ProvidersSection.tsx | 149 + .../AgentsPage/ChatModelAdminPanel/helpers.ts | 16 + .../modelConfigFormLogic.test.ts | 1041 ++++++ .../modelConfigFormLogic.ts | 741 ++++ .../ChatModelAdminPanel/modelConfigSchemas.ts | 166 + .../ConfigureAgentsDialog.stories.tsx | 111 + .../AgentsPage/ConfigureAgentsDialog.tsx | 203 ++ site/src/pages/AgentsPage/DiffRightPanel.tsx | 119 + .../AgentsPage/FilesChangedPanel.stories.tsx | 67 + .../pages/AgentsPage/FilesChangedPanel.tsx | 161 + .../AgentsPage/QueuedMessagesList.stories.tsx | 103 + .../pages/AgentsPage/QueuedMessagesList.tsx | 325 ++ site/src/pages/AgentsPage/modelOptions.ts | 167 + .../ProvisionerTagsPopover.stories.tsx | 13 +- site/src/router.tsx | 25 + site/src/testHelpers/entities.ts | 2 + site/src/testHelpers/pierreDiffsReactMock.tsx | 23 + site/src/utils/OneWayWebSocket.ts | 9 +- site/src/utils/time.test.ts | 104 + site/src/utils/time.ts | 37 + site/tailwind.config.js | 7 +- site/vite.config.mts | 3 + 201 files changed, 44828 insertions(+), 1859 deletions(-) create mode 100644 coderd/chatd/chatd.go create mode 100644 coderd/chatd/chatd_test.go create mode 100644 coderd/chatd/chatloop/chatloop.go create mode 100644 coderd/chatd/chatloop/chatloop_test.go create mode 100644 coderd/chatd/chatloop/compaction.go create mode 100644 coderd/chatd/chatloop/compaction_test.go create mode 100644 coderd/chatd/chatprompt/chatprompt.go create mode 100644 coderd/chatd/chatprompt/chatprompt_test.go create mode 100644 coderd/chatd/chatprovider/chatprovider.go create mode 100644 coderd/chatd/chatprovider/chatprovider_test.go create mode 100644 coderd/chatd/chattest/anthropic.go create mode 100644 coderd/chatd/chattest/anthropic_test.go create mode 100644 coderd/chatd/chattest/openai.go create mode 100644 coderd/chatd/chattest/openai_test.go create mode 100644 coderd/chatd/chattool/chattool.go create mode 100644 coderd/chatd/chattool/createworkspace.go create mode 100644 coderd/chatd/chattool/editfiles.go create mode 100644 coderd/chatd/chattool/execute.go create mode 100644 coderd/chatd/chattool/listtemplates.go create mode 100644 coderd/chatd/chattool/readfile.go create mode 100644 coderd/chatd/chattool/readtemplate.go create mode 100644 coderd/chatd/chattool/writefile.go create mode 100644 coderd/chatd/instruction.go create mode 100644 coderd/chatd/instruction_test.go create mode 100644 coderd/chatd/prompt.go create mode 100644 coderd/chatd/subagent.go create mode 100644 coderd/chatd/title.go create mode 100644 coderd/chats.go create mode 100644 coderd/chats_test.go create mode 100644 coderd/database/migrations/000422_chats.down.sql create mode 100644 coderd/database/migrations/000422_chats.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql create mode 100644 coderd/database/queries/chatmodelconfigs.sql create mode 100644 coderd/database/queries/chatproviders.sql create mode 100644 coderd/database/queries/chats.sql create mode 100644 coderd/httpmw/chatparam.go create mode 100644 coderd/httpmw/chatparam_test.go create mode 100644 coderd/pubsub/chatevent.go create mode 100644 coderd/pubsub/chatstreamnotify.go create mode 100644 codersdk/chats.go create mode 100644 codersdk/chats_test.go create mode 100644 enterprise/coderd/chats.go create mode 100644 enterprise/coderd/chats_test.go create mode 100755 scripts/biome_format.sh create mode 100644 site/src/api/queries/chats.ts create mode 100644 site/src/components/ai-elements/conversation.stories.tsx create mode 100644 site/src/components/ai-elements/conversation.tsx create mode 100644 site/src/components/ai-elements/index.ts create mode 100644 site/src/components/ai-elements/message.tsx create mode 100644 site/src/components/ai-elements/model-selector.stories.tsx create mode 100644 site/src/components/ai-elements/model-selector.tsx create mode 100644 site/src/components/ai-elements/response.stories.tsx create mode 100644 site/src/components/ai-elements/response.tsx create mode 100644 site/src/components/ai-elements/runtimeTypeUtils.ts create mode 100644 site/src/components/ai-elements/shimmer.tsx create mode 100644 site/src/components/ai-elements/thinking.tsx create mode 100644 site/src/components/ai-elements/tool.stories.tsx create mode 100644 site/src/components/ai-elements/tool/ChatSummarizedTool.tsx create mode 100644 site/src/components/ai-elements/tool/CreateWorkspaceTool.tsx create mode 100644 site/src/components/ai-elements/tool/EditFilesTool.tsx create mode 100644 site/src/components/ai-elements/tool/ExecuteTool.tsx create mode 100644 site/src/components/ai-elements/tool/ListTemplatesTool.tsx create mode 100644 site/src/components/ai-elements/tool/ReadFileTool.tsx create mode 100644 site/src/components/ai-elements/tool/ReadTemplateTool.tsx create mode 100644 site/src/components/ai-elements/tool/SubagentTool.tsx create mode 100644 site/src/components/ai-elements/tool/Tool.tsx create mode 100644 site/src/components/ai-elements/tool/ToolCollapsible.tsx create mode 100644 site/src/components/ai-elements/tool/ToolIcon.tsx create mode 100644 site/src/components/ai-elements/tool/ToolLabel.tsx create mode 100644 site/src/components/ai-elements/tool/WriteFileTool.tsx create mode 100644 site/src/components/ai-elements/tool/index.ts create mode 100644 site/src/components/ai-elements/tool/utils.test.ts create mode 100644 site/src/components/ai-elements/tool/utils.ts create mode 100644 site/src/contexts/DiffsWorkerPoolProvider.tsx create mode 100644 site/src/pages/AgentsPage/AgentChatInput.stories.tsx create mode 100644 site/src/pages/AgentsPage/AgentChatInput.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail.stories.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail/ChatContext.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx create mode 100644 site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/blockUtils.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/chatHelpers.test.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/chatHelpers.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/messageParsing.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/streamState.test.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/streamState.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/streamingJson.test.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/streamingJson.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/types.ts create mode 100644 site/src/pages/AgentsPage/AgentDetail/useMessageWindow.ts create mode 100644 site/src/pages/AgentsPage/AgentsPage.stories.tsx create mode 100644 site/src/pages/AgentsPage/AgentsPage.tsx create mode 100644 site/src/pages/AgentsPage/AgentsSidebar.stories.tsx create mode 100644 site/src/pages/AgentsPage/AgentsSidebar.tsx create mode 100644 site/src/pages/AgentsPage/AgentsSkeletons.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderIcon.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ProvidersSection.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/helpers.ts create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.test.ts create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.ts create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigSchemas.ts create mode 100644 site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx create mode 100644 site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx create mode 100644 site/src/pages/AgentsPage/DiffRightPanel.tsx create mode 100644 site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/FilesChangedPanel.tsx create mode 100644 site/src/pages/AgentsPage/QueuedMessagesList.stories.tsx create mode 100644 site/src/pages/AgentsPage/QueuedMessagesList.tsx create mode 100644 site/src/pages/AgentsPage/modelOptions.ts create mode 100644 site/src/testHelpers/pierreDiffsReactMock.tsx create mode 100644 site/src/utils/time.test.ts diff --git a/Makefile b/Makefile index 9539d8ccb3..ab4dc273f5 100644 --- a/Makefile +++ b/Makefile @@ -854,7 +854,7 @@ enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go') # -C sets the directory for the go run command go run -C ./scripts/apitypings main.go > $@ - (cd site/ && pnpm exec biome format --write src/api/typesGenerated.ts) + ./scripts/biome_format.sh src/api/typesGenerated.ts touch "$@" site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go @@ -863,7 +863,7 @@ site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/prot site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*) go run ./scripts/gensite/ -icons "$@" - (cd site/ && pnpm exec biome format --write src/theme/icons.json) + ./scripts/biome_format.sh src/theme/icons.json touch "$@" examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates) @@ -901,12 +901,12 @@ codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scope site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go go run scripts/typegen/main.go rbac typescript > "$@" - (cd site/ && pnpm exec biome format --write src/api/rbacresourcesGenerated.ts) + ./scripts/biome_format.sh src/api/rbacresourcesGenerated.ts touch "$@" site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go go run scripts/typegen/main.go countries > "$@" - (cd site/ && pnpm exec biome format --write src/api/countriesGenerated.ts) + ./scripts/biome_format.sh src/api/countriesGenerated.ts touch "$@" scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES) @@ -950,11 +950,11 @@ coderd/apidoc/.gen: \ touch "$@" docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md - (cd site/ && pnpm exec biome format --write ../docs/manifest.json) + ./scripts/biome_format.sh ../docs/manifest.json touch "$@" coderd/apidoc/swagger.json: site/node_modules/.installed coderd/apidoc/.gen - (cd site/ && pnpm exec biome format --write ../coderd/apidoc/swagger.json) + ./scripts/biome_format.sh ../coderd/apidoc/swagger.json touch "$@" update-golden-files: @@ -999,11 +999,19 @@ enterprise/tailnet/testdata/.gen-golden: $(wildcard enterprise/tailnet/testdata/ touch "$@" helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go) - TZ=UTC go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update + if command -v helm >/dev/null 2>&1; then + TZ=UTC go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update + else + echo "WARNING: helm not found; skipping helm/coder golden generation" >&2 + fi touch "$@" helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/testdata/*.yaml) $(wildcard helm/provisioner/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/provisioner/tests/*_test.go) - TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update + if command -v helm >/dev/null 2>&1; then + TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update + else + echo "WARNING: helm not found; skipping helm/provisioner golden generation" >&2 + fi touch "$@" coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/*_test.go) diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 8ed0ef0b0c..76f1d94553 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "net/http" + "os" + "os/exec" + "strings" "time" "golang.org/x/xerrors" @@ -16,6 +19,29 @@ import ( "github.com/coder/serpent" ) +// detectGitRef attempts to resolve the current git branch and remote +// origin URL from the given working directory. These are sent to the +// control plane so it can look up PR/diff status via the GitHub API +// without SSHing into the workspace. Failures are silently ignored +// since this is best-effort. +func detectGitRef(workingDirectory string) (branch string, remoteOrigin string) { + run := func(args ...string) string { + //nolint:gosec + cmd := exec.Command(args[0], args[1:]...) + if workingDirectory != "" { + cmd.Dir = workingDirectory + } + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) + } + branch = run("git", "rev-parse", "--abbrev-ref", "HEAD") + remoteOrigin = run("git", "config", "--get", "remote.origin.url") + return branch, remoteOrigin +} + // gitAskpass is used by the Coder agent to automatically authenticate // with Git providers based on a hostname. func gitAskpass(agentAuth *AgentAuth) *serpent.Command { @@ -38,8 +64,20 @@ func gitAskpass(agentAuth *AgentAuth) *serpent.Command { return xerrors.Errorf("create agent client: %w", err) } + workingDirectory, err := os.Getwd() + if err != nil { + workingDirectory = "" + } + + // Detect the current git branch and remote origin so + // the control plane can resolve diffs without needing + // to SSH back into the workspace. + gitBranch, gitRemoteOrigin := detectGitRef(workingDirectory) + token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ - Match: host, + Match: host, + GitBranch: gitBranch, + GitRemoteOrigin: gitRemoteOrigin, }) if err != nil { var apiError *codersdk.Error @@ -58,6 +96,12 @@ func gitAskpass(agentAuth *AgentAuth) *serpent.Command { return xerrors.Errorf("get git token: %w", err) } if token.URL != "" { + // This is to help the agent authenticate with Git. + if inv.Environ.Get("CODER_CHAT_AGENT") == "true" { + _, _ = fmt.Fprintf(inv.Stderr, `You must notify the user to authenticate with Git.\n\nThe URL is: %s\n`, token.URL) + return cliui.ErrCanceled + } + if err := openURL(inv, token.URL); err == nil { cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n%s", token.URL) } else { diff --git a/cli/server.go b/cli/server.go index 4aed11d5c2..08f835c67a 100644 --- a/cli/server.go +++ b/cli/server.go @@ -617,28 +617,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - extAuthEnv, err := ReadExternalAuthProvidersFromEnv(os.Environ()) - if err != nil { - return xerrors.Errorf("read external auth providers from env: %w", err) - } - promRegistry := prometheus.NewRegistry() oauthInstrument := promoauth.NewFactory(promRegistry) - vals.ExternalAuthConfigs.Value = append(vals.ExternalAuthConfigs.Value, extAuthEnv...) - externalAuthConfigs, err := externalauth.ConvertConfig( - oauthInstrument, - vals.ExternalAuthConfigs.Value, - vals.AccessURL.Value(), - ) - if err != nil { - return xerrors.Errorf("convert external auth config: %w", err) - } - for _, c := range externalAuthConfigs { - logger.Debug( - ctx, "loaded external auth config", - slog.F("id", c.ID), - ) - } realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { @@ -669,7 +649,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. Pubsub: nil, CacheDir: cacheDir, GoogleTokenValidator: googleTokenValidator, - ExternalAuthConfigs: externalAuthConfigs, + ExternalAuthConfigs: nil, RealIPConfig: realIPConfig, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -829,6 +809,40 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } + extAuthEnv, err := ReadExternalAuthProvidersFromEnv(os.Environ()) + if err != nil { + return xerrors.Errorf("read external auth providers from env: %w", err) + } + mergedExternalAuthProviders := append([]codersdk.ExternalAuthConfig{}, vals.ExternalAuthConfigs.Value...) + mergedExternalAuthProviders = append(mergedExternalAuthProviders, extAuthEnv...) + vals.ExternalAuthConfigs.Value = mergedExternalAuthProviders + + mergedExternalAuthProviders, err = maybeAppendDefaultGithubExternalAuthProvider( + ctx, + options.Logger, + options.Database, + vals, + mergedExternalAuthProviders, + ) + if err != nil { + return xerrors.Errorf("maybe append default github external auth provider: %w", err) + } + + options.ExternalAuthConfigs, err = externalauth.ConvertConfig( + oauthInstrument, + mergedExternalAuthProviders, + vals.AccessURL.Value(), + ) + if err != nil { + return xerrors.Errorf("convert external auth config: %w", err) + } + for _, c := range options.ExternalAuthConfigs { + logger.Debug( + ctx, "loaded external auth config", + slog.F("id", c.ID), + ) + } + // Manage push notifications. experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()) if experiments.Enabled(codersdk.ExperimentWebPush) { @@ -1926,6 +1940,79 @@ type githubOAuth2ConfigParams struct { enterpriseBaseURL string } +func isDeploymentEligibleForGithubDefaultProvider(ctx context.Context, db database.Store) (bool, error) { + // We want to enable the default provider only for new deployments, and avoid + // enabling it if a deployment was upgraded from an older version. + // nolint:gocritic // Requires system privileges + defaultEligible, err := db.GetOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx)) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, xerrors.Errorf("get github default eligible: %w", err) + } + defaultEligibleNotSet := errors.Is(err, sql.ErrNoRows) + + if defaultEligibleNotSet { + // nolint:gocritic // User count requires system privileges + userCount, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx), false) + if err != nil { + return false, xerrors.Errorf("get user count: %w", err) + } + // We check if a deployment is new by checking if it has any users. + defaultEligible = userCount == 0 + // nolint:gocritic // Requires system privileges + if err := db.UpsertOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx), defaultEligible); err != nil { + return false, xerrors.Errorf("upsert github default eligible: %w", err) + } + } + + return defaultEligible, nil +} + +func maybeAppendDefaultGithubExternalAuthProvider( + ctx context.Context, + logger slog.Logger, + db database.Store, + vals *codersdk.DeploymentValues, + mergedExplicitProviders []codersdk.ExternalAuthConfig, +) ([]codersdk.ExternalAuthConfig, error) { + if !vals.ExternalAuthGithubDefaultProviderEnable.Value() { + logger.Info(ctx, "default github external auth provider suppressed", + slog.F("reason", "disabled by configuration"), + slog.F("flag", "external-auth-github-default-provider-enable"), + ) + return mergedExplicitProviders, nil + } + + if len(mergedExplicitProviders) > 0 { + logger.Info(ctx, "default github external auth provider suppressed", + slog.F("reason", "explicit external auth providers configured"), + slog.F("provider_count", len(mergedExplicitProviders)), + ) + return mergedExplicitProviders, nil + } + + defaultEligible, err := isDeploymentEligibleForGithubDefaultProvider(ctx, db) + if err != nil { + return nil, err + } + if !defaultEligible { + logger.Info(ctx, "default github external auth provider suppressed", + slog.F("reason", "deployment is not eligible"), + ) + return mergedExplicitProviders, nil + } + + logger.Info(ctx, "injecting default github external auth provider", + slog.F("type", codersdk.EnhancedExternalAuthProviderGitHub.String()), + slog.F("client_id", GithubOAuth2DefaultProviderClientID), + slog.F("device_flow", GithubOAuth2DefaultProviderDeviceFlow), + ) + return append(mergedExplicitProviders, codersdk.ExternalAuthConfig{ + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + ClientID: GithubOAuth2DefaultProviderClientID, + DeviceFlow: GithubOAuth2DefaultProviderDeviceFlow, + }), nil +} + func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *codersdk.DeploymentValues) (*githubOAuth2ConfigParams, error) { params := githubOAuth2ConfigParams{ accessURL: vals.AccessURL.Value(), @@ -1950,28 +2037,9 @@ func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *c return nil, nil //nolint:nilnil } - // Check if the deployment is eligible for the default GitHub OAuth2 provider. - // We want to enable it only for new deployments, and avoid enabling it - // if a deployment was upgraded from an older version. - // nolint:gocritic // Requires system privileges - defaultEligible, err := db.GetOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx)) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, xerrors.Errorf("get github default eligible: %w", err) - } - defaultEligibleNotSet := errors.Is(err, sql.ErrNoRows) - - if defaultEligibleNotSet { - // nolint:gocritic // User count requires system privileges - userCount, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx), false) - if err != nil { - return nil, xerrors.Errorf("get user count: %w", err) - } - // We check if a deployment is new by checking if it has any users. - defaultEligible = userCount == 0 - // nolint:gocritic // Requires system privileges - if err := db.UpsertOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx), defaultEligible); err != nil { - return nil, xerrors.Errorf("upsert github default eligible: %w", err) - } + defaultEligible, err := isDeploymentEligibleForGithubDefaultProvider(ctx, db) + if err != nil { + return nil, err } if !defaultEligible { diff --git a/cli/server_test.go b/cli/server_test.go index da82fdb7ac..b0b493570c 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -53,6 +53,7 @@ import ( "github.com/coder/coder/v2/coderd/database/migrations" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/pty/ptytest" @@ -302,6 +303,7 @@ func TestServer(t *testing.T) { "open install.sh: file does not exist", "telemetry disabled, unable to notify of security issues", "installed terraform version newer than expected", + "report generator", } countLines := func(fullOutput string) int { @@ -1805,6 +1807,155 @@ func TestServer(t *testing.T) { }) } +//nolint:tparallel,paralleltest // This test sets environment variables. +func TestServer_ExternalAuthGitHubDefaultProvider(t *testing.T) { + type testCase struct { + name string + args []string + env map[string]string + createUserPreStart bool + expectedProviders []string + } + + run := func(t *testing.T, tc testCase) { + ctx := testutil.Context(t, testutil.WaitLong) + + unsetPrefixedEnv := func(prefix string) { + t.Helper() + for _, envVar := range os.Environ() { + envKey, _, found := strings.Cut(envVar, "=") + if !found || !strings.HasPrefix(envKey, prefix) { + continue + } + value, had := os.LookupEnv(envKey) + require.True(t, had) + require.NoError(t, os.Unsetenv(envKey)) + keyCopy := envKey + valueCopy := value + t.Cleanup(func() { + // This is for setting/unsetting a number of prefixed env vars. + // t.Setenv doesn't cover this use case. + // nolint:usetesting + _ = os.Setenv(keyCopy, valueCopy) + }) + } + } + unsetPrefixedEnv("CODER_EXTERNAL_AUTH_") + unsetPrefixedEnv("CODER_GITAUTH_") + + dbURL, err := dbtestutil.Open(t) + require.NoError(t, err) + db, _ := dbtestutil.NewDB(t, dbtestutil.WithURL(dbURL)) + + const ( + existingUserEmail = "existing-user@coder.com" + existingUserUsername = "existing-user" + existingUserPassword = "SomeSecurePassword!" + ) + if tc.createUserPreStart { + hashedPassword, err := userpassword.Hash(existingUserPassword) + require.NoError(t, err) + _ = dbgen.User(t, db, database.User{ + Email: existingUserEmail, + Username: existingUserUsername, + HashedPassword: []byte(hashedPassword), + }) + } + + args := []string{ + "server", + "--postgres-url", dbURL, + "--http-address", ":0", + "--access-url", "https://example.com", + } + args = append(args, tc.args...) + + inv, cfg := clitest.New(t, args...) + for envKey, value := range tc.env { + t.Setenv(envKey, value) + } + clitest.Start(t, inv) + + accessURL := waitAccessURL(t, cfg) + client := codersdk.New(accessURL) + + if tc.createUserPreStart { + loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: existingUserEmail, + Password: existingUserPassword, + }) + require.NoError(t, err) + client.SetSessionToken(loginResp.SessionToken) + } else { + _ = coderdtest.CreateFirstUser(t, client) + } + + externalAuthResp, err := client.ListExternalAuths(ctx) + require.NoError(t, err) + + gotProviders := map[string]codersdk.ExternalAuthLinkProvider{} + for _, provider := range externalAuthResp.Providers { + gotProviders[provider.ID] = provider + } + require.Len(t, gotProviders, len(tc.expectedProviders)) + + for _, providerID := range tc.expectedProviders { + provider, ok := gotProviders[providerID] + require.Truef(t, ok, "expected provider %q to be configured", providerID) + if providerID == codersdk.EnhancedExternalAuthProviderGitHub.String() { + require.Equal(t, codersdk.EnhancedExternalAuthProviderGitHub.String(), provider.Type) + require.True(t, provider.Device) + } + } + } + + for _, tc := range []testCase{ + { + name: "NewDeployment_NoExplicitProviders_InjectsDefaultGithub", + expectedProviders: []string{codersdk.EnhancedExternalAuthProviderGitHub.String()}, + }, + { + name: "ExistingDeployment_DoesNotInjectDefaultGithub", + createUserPreStart: true, + expectedProviders: nil, + }, + { + name: "DefaultProviderDisabled_DoesNotInjectDefaultGithub", + args: []string{ + "--external-auth-github-default-provider-enable=false", + }, + expectedProviders: nil, + }, + { + name: "ExplicitProviderViaConfig_DoesNotInjectDefaultGithub", + args: []string{ + `--external-auth-providers=[{"type":"gitlab","client_id":"config-client-id"}]`, + }, + expectedProviders: []string{codersdk.EnhancedExternalAuthProviderGitLab.String()}, + }, + { + name: "ExplicitProviderViaEnv_DoesNotInjectDefaultGithub", + env: map[string]string{ + "CODER_EXTERNAL_AUTH_0_TYPE": codersdk.EnhancedExternalAuthProviderGitLab.String(), + "CODER_EXTERNAL_AUTH_0_CLIENT_ID": "env-client-id", + }, + expectedProviders: []string{codersdk.EnhancedExternalAuthProviderGitLab.String()}, + }, + { + name: "ExplicitProviderViaLegacyEnv_DoesNotInjectDefaultGithub", + env: map[string]string{ + "CODER_GITAUTH_0_TYPE": codersdk.EnhancedExternalAuthProviderGitLab.String(), + "CODER_GITAUTH_0_CLIENT_ID": "legacy-env-client-id", + }, + expectedProviders: []string{codersdk.EnhancedExternalAuthProviderGitLab.String()}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + run(t, tc) + }) + } +} + //nolint:tparallel,paralleltest // This test sets environment variables. func TestServer_Logging_NoParallel(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index bd78da21e6..a23783913d 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -62,6 +62,9 @@ OPTIONS: Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments. + --external-auth-github-default-provider-enable bool, $CODER_EXTERNAL_AUTH_GITHUB_DEFAULT_PROVIDER_ENABLE (default: true) + Enable the default GitHub external auth provider managed by Coder. + --postgres-auth password|awsiamrds, $CODER_PG_AUTH (default: password) Type of auth to use when connecting to postgres. For AWS RDS, using IAM authentication (awsiamrds) is recommended. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 2477c5bcaf..fb801b6d7d 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -564,6 +564,9 @@ supportLinks: [] # External Authentication providers. # (default: , type: struct[[]codersdk.ExternalAuthConfig]) externalAuthProviders: [] +# Enable the default GitHub external auth provider managed by Coder. +# (default: true, type: bool) +externalAuthGithubDefaultProviderEnable: true # Hostname of HTTPS server that runs https://github.com/coder/wgtunnel. By # default, this will pick the best available wgtunnel server hosted by Coder. e.g. # "tunnel.example.com". diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 4d24f86ed7..abf2ef5a98 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -192,7 +192,8 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { }) defer commitAuditWS() - workspace, err := createWorkspace(ctx, aReqWS, apiKey.UserID, api, owner, createReq, r, &createWorkspaceOptions{ + workspace, err := createWorkspace(ctx, aReqWS, apiKey.UserID, api, owner, createReq, &createWorkspaceOptions{ + remoteAddr: r.RemoteAddr, // Before creating the workspace, ensure that this task can be created. preCreateInTX: func(ctx context.Context, tx database.Store) error { // Create task record in the database before creating the workspace so that diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5f4f4a9b7d..0aceb9c411 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12783,6 +12783,11 @@ const docTemplate = `{ "boundary_usage:delete", "boundary_usage:read", "boundary_usage:update", + "chat:*", + "chat:create", + "chat:delete", + "chat:read", + "chat:update", "coder:all", "coder:apikeys.manage_self", "coder:application_connect", @@ -12987,6 +12992,11 @@ const docTemplate = `{ "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", "APIKeyScopeBoundaryUsageUpdate", + "APIKeyScopeChatAll", + "APIKeyScopeChatCreate", + "APIKeyScopeChatDelete", + "APIKeyScopeChatRead", + "APIKeyScopeChatUpdate", "APIKeyScopeCoderAll", "APIKeyScopeCoderApikeysManageSelf", "APIKeyScopeCoderApplicationConnect", @@ -14848,6 +14858,9 @@ const docTemplate = `{ "external_auth": { "$ref": "#/definitions/serpent.Struct-array_codersdk_ExternalAuthConfig" }, + "external_auth_github_default_provider_enable": { + "type": "boolean" + }, "external_token_encryption_keys": { "type": "array", "items": { @@ -15132,9 +15145,11 @@ const docTemplate = `{ "workspace-usage", "web-push", "oauth2", + "agents", "mcp-server-http" ], "x-enum-comments": { + "ExperimentAgents": "Enables agent-powered chat functionality.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", @@ -15150,6 +15165,7 @@ const docTemplate = `{ "Enables the new workspace usage tracking.", "Enables web push notifications through the browser.", "Enables OAuth2 provider functionality.", + "Enables agent-powered chat functionality.", "Enables the MCP HTTP server functionality." ], "x-enum-varnames": [ @@ -15159,6 +15175,7 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", + "ExperimentAgents", "ExperimentMCPServerHTTP" ] }, @@ -18099,6 +18116,7 @@ const docTemplate = `{ "assign_role", "audit_log", "boundary_usage", + "chat", "connection_log", "crypto_key", "debug_info", @@ -18144,6 +18162,7 @@ const docTemplate = `{ "ResourceAssignRole", "ResourceAuditLog", "ResourceBoundaryUsage", + "ResourceChat", "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c6e0ad952c..a61da85d04 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11387,6 +11387,11 @@ "boundary_usage:delete", "boundary_usage:read", "boundary_usage:update", + "chat:*", + "chat:create", + "chat:delete", + "chat:read", + "chat:update", "coder:all", "coder:apikeys.manage_self", "coder:application_connect", @@ -11591,6 +11596,11 @@ "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", "APIKeyScopeBoundaryUsageUpdate", + "APIKeyScopeChatAll", + "APIKeyScopeChatCreate", + "APIKeyScopeChatDelete", + "APIKeyScopeChatRead", + "APIKeyScopeChatUpdate", "APIKeyScopeCoderAll", "APIKeyScopeCoderApikeysManageSelf", "APIKeyScopeCoderApplicationConnect", @@ -13378,6 +13388,9 @@ "external_auth": { "$ref": "#/definitions/serpent.Struct-array_codersdk_ExternalAuthConfig" }, + "external_auth_github_default_provider_enable": { + "type": "boolean" + }, "external_token_encryption_keys": { "type": "array", "items": { @@ -13655,9 +13668,11 @@ "workspace-usage", "web-push", "oauth2", + "agents", "mcp-server-http" ], "x-enum-comments": { + "ExperimentAgents": "Enables agent-powered chat functionality.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", @@ -13673,6 +13688,7 @@ "Enables the new workspace usage tracking.", "Enables web push notifications through the browser.", "Enables OAuth2 provider functionality.", + "Enables agent-powered chat functionality.", "Enables the MCP HTTP server functionality." ], "x-enum-varnames": [ @@ -13682,6 +13698,7 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", + "ExperimentAgents", "ExperimentMCPServerHTTP" ] }, @@ -16507,6 +16524,7 @@ "assign_role", "audit_log", "boundary_usage", + "chat", "connection_log", "crypto_key", "debug_info", @@ -16552,6 +16570,7 @@ "ResourceAssignRole", "ResourceAuditLog", "ResourceBoundaryUsage", + "ResourceChat", "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", diff --git a/coderd/authorize.go b/coderd/authorize.go index 563b772d5f..10d6c519a7 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "fmt" "net/http" @@ -8,6 +9,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" @@ -91,6 +93,36 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object return true } +// AuthorizeContext checks whether the RBAC subject on the context +// is authorized to perform the given action. The subject must have +// been set via dbauthz.As or the ExtractAPIKey middleware. Returns +// false if the subject is missing or unauthorized. +func (h *HTTPAuthorizer) AuthorizeContext(ctx context.Context, action policy.Action, object rbac.Objecter) bool { + roles, ok := dbauthz.ActorFromContext(ctx) + if !ok { + h.Logger.Error(ctx, "no authorization actor in context") + return false + } + err := h.Authorizer.Authorize(ctx, roles, action, object.RBACObject()) + if err != nil { + internalError := new(rbac.UnauthorizedError) + logger := h.Logger + if xerrors.As(err, internalError) { + logger = h.Logger.With(slog.F("internal_error", internalError.Internal())) + } + logger.Warn(ctx, "requester is not authorized to access the object", + slog.F("roles", roles.SafeRoleNames()), + slog.F("actor_id", roles.ID), + slog.F("actor_name", roles), + slog.F("scope", roles.SafeScopeName()), + slog.F("action", action), + slog.F("object", object), + ) + return false + } + return true +} + // AuthorizeSQLFilter returns an authorization filter that can used in a // SQL 'WHERE' clause. If the filter is used, the resulting rows returned // from postgres are already authorized, and the caller does not need to @@ -106,6 +138,22 @@ func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Actio return prepared, nil } +// AuthorizeSQLFilterContext is like AuthorizeSQLFilter but reads the +// RBAC subject from the context directly rather than from an +// *http.Request. The subject must have been set via dbauthz.As. +func (h *HTTPAuthorizer) AuthorizeSQLFilterContext(ctx context.Context, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) { + roles, ok := dbauthz.ActorFromContext(ctx) + if !ok { + return nil, xerrors.New("no authorization actor in context") + } + prepared, err := h.Authorizer.Prepare(ctx, roles, action, objectType) + if err != nil { + return nil, xerrors.Errorf("prepare filter: %w", err) + } + + return prepared, nil +} + // checkAuthorization returns if the current API key can use the given // permissions, factoring in the current user's roles and the API key scopes. // diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go new file mode 100644 index 0000000000..c14744bea1 --- /dev/null +++ b/coderd/chatd/chatd.go @@ -0,0 +1,2375 @@ +package chatd + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/chatd/chatloop" + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/chatd/chattool" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/pubsub" + coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +const ( + // DefaultPendingChatAcquireInterval is the default time between attempts to + // acquire pending chats. + DefaultPendingChatAcquireInterval = time.Second + // DefaultInFlightChatStaleAfter is the default age after which a running + // chat is considered stale and should be recovered. + DefaultInFlightChatStaleAfter = 5 * time.Minute + + homeInstructionLookupTimeout = 5 * time.Second + instructionCacheTTL = 5 * time.Minute + chatHeartbeatInterval = 30 * time.Second + maxChatSteps = 1200 + + defaultSubagentInstruction = "You are running as a delegated sub-agent chat. Complete the delegated task and provide clear, concise assistant responses for the parent agent." +) + +// Server handles background processing of pending chats. +type Server struct { + cancel context.CancelFunc + closed chan struct{} + inflight sync.WaitGroup + + db database.Store + workerID uuid.UUID + logger slog.Logger + + remotePartsProvider RemotePartsProvider + + agentConnFn AgentConnFunc + createWorkspaceFn chattool.CreateWorkspaceFn + pubsub pubsub.Pubsub + providerAPIKeys chatprovider.ProviderAPIKeys + + // streamMu guards chatStreams which tracks in-flight chat + // stream state for broadcasting ephemeral events. + streamMu sync.Mutex + chatStreams map[uuid.UUID]*chatStreamState + + // instructionCache caches home instruction file contents by + // workspace agent ID so we don't re-dial on every chat turn. + instructionCacheMu sync.Mutex + instructionCache map[uuid.UUID]cachedInstruction + + // Configuration + pendingChatAcquireInterval time.Duration + inFlightChatStaleAfter time.Duration +} + +type cachedInstruction struct { + instruction string + fetchedAt time.Time +} + +// AgentConnFunc provides access to workspace agent connections. +type AgentConnFunc func(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) + +// ReplicaAddressResolver maps a replica ID to its relay address. +type ReplicaAddressResolver func(context.Context, uuid.UUID) (string, bool) + +// RemotePartsProvider returns a snapshot and live stream of message_part +// events from the replica that is running the chat. Called when the chat +// is actively running on a different replica. Nil in AGPL single-replica +// deployments. +type RemotePartsProvider func( + ctx context.Context, + chatID uuid.UUID, + workerID uuid.UUID, + requestHeader http.Header, +) ( + snapshot []codersdk.ChatStreamEvent, + parts <-chan codersdk.ChatStreamEvent, + cancel func(), + err error, +) + +type chatStreamState struct { + buffer []codersdk.ChatStreamEvent + buffering bool + subscribers map[uuid.UUID]chan codersdk.ChatStreamEvent +} + +// MaxQueueSize is the maximum number of queued user messages per chat. +const MaxQueueSize = 20 + +var ( + // ErrMessageQueueFull indicates the per-chat queue limit was reached. + ErrMessageQueueFull = xerrors.New("chat message queue is full") + // ErrEditedMessageNotFound indicates the edited message does not exist + // in the target chat. + ErrEditedMessageNotFound = xerrors.New("edited message not found") + // ErrEditedMessageNotUser indicates a non-user message edit attempt. + ErrEditedMessageNotUser = xerrors.New("only user messages can be edited") +) + +// CreateOptions controls chat creation in the shared chat mutation path. +type CreateOptions struct { + OwnerID uuid.UUID + WorkspaceID uuid.NullUUID + WorkspaceAgentID uuid.NullUUID + ParentChatID uuid.NullUUID + RootChatID uuid.NullUUID + Title string + ModelConfigID uuid.UUID + SystemPrompt string + InitialUserContent []fantasy.Content +} + +// SendMessageBusyBehavior controls what happens when a chat is already active. +type SendMessageBusyBehavior string + +const ( + // SendMessageBusyBehaviorQueue queues user messages while the chat is busy. + SendMessageBusyBehaviorQueue SendMessageBusyBehavior = "queue" + // SendMessageBusyBehaviorInterrupt inserts the message immediately and + // transitions the chat to pending, which interrupts the active run. + SendMessageBusyBehaviorInterrupt SendMessageBusyBehavior = "interrupt" +) + +// SendMessageOptions controls user message insertion with busy-state behavior. +type SendMessageOptions struct { + ChatID uuid.UUID + Content []fantasy.Content + ModelConfigID *uuid.UUID + BusyBehavior SendMessageBusyBehavior +} + +// SendMessageResult contains the outcome of user message processing. +type SendMessageResult struct { + Queued bool + QueuedMessage *database.ChatQueuedMessage + Message database.ChatMessage + Chat database.Chat +} + +// EditMessageOptions controls in-place user message edits. +type EditMessageOptions struct { + ChatID uuid.UUID + EditedMessageID int64 + Content []fantasy.Content +} + +// EditMessageResult contains the updated user message and chat status. +type EditMessageResult struct { + Message database.ChatMessage + Chat database.Chat +} + +// PromoteQueuedOptions controls queued-message promotion. +type PromoteQueuedOptions struct { + ChatID uuid.UUID + QueuedMessageID int64 + ModelConfigID *uuid.UUID +} + +// PromoteQueuedResult contains post-promotion message metadata. +type PromoteQueuedResult struct { + PromotedMessage database.ChatMessage +} + +// CreateChat creates a chat, inserts optional system prompt and initial user +// message, and moves the chat into pending status. +func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.Chat, error) { + if opts.OwnerID == uuid.Nil { + return database.Chat{}, xerrors.New("owner_id is required") + } + if strings.TrimSpace(opts.Title) == "" { + return database.Chat{}, xerrors.New("title is required") + } + if len(opts.InitialUserContent) == 0 { + return database.Chat{}, xerrors.New("initial user content is required") + } + + var chat database.Chat + txErr := p.db.InTx(func(tx database.Store) error { + insertedChat, err := tx.InsertChat(ctx, database.InsertChatParams{ + OwnerID: opts.OwnerID, + WorkspaceID: opts.WorkspaceID, + WorkspaceAgentID: opts.WorkspaceAgentID, + ParentChatID: opts.ParentChatID, + RootChatID: opts.RootChatID, + LastModelConfigID: opts.ModelConfigID, + Title: opts.Title, + }) + if err != nil { + return xerrors.Errorf("insert chat: %w", err) + } + + systemPrompt := strings.TrimSpace(opts.SystemPrompt) + if systemPrompt != "" { + systemContent, err := json.Marshal(systemPrompt) + if err != nil { + return xerrors.Errorf("marshal system prompt: %w", err) + } + _, err = tx.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: insertedChat.ID, + ModelConfigID: uuid.NullUUID{ + UUID: opts.ModelConfigID, + Valid: true, + }, + Role: "system", + Content: pqtype.NullRawMessage{ + RawMessage: systemContent, + Valid: len(systemContent) > 0, + }, + Visibility: database.ChatMessageVisibilityModel, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + if err != nil { + return xerrors.Errorf("insert system message: %w", err) + } + } + + userContent, err := chatprompt.MarshalContent(opts.InitialUserContent) + if err != nil { + return xerrors.Errorf("marshal initial user content: %w", err) + } + _, err = insertChatMessageWithStore(ctx, tx, database.InsertChatMessageParams{ + ChatID: insertedChat.ID, + ModelConfigID: uuid.NullUUID{ + UUID: opts.ModelConfigID, + Valid: true, + }, + Role: "user", + Content: userContent, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + if err != nil { + return xerrors.Errorf("insert initial user message: %w", err) + } + + chat, err = setChatPendingWithStore(ctx, tx, insertedChat.ID) + if err != nil { + return xerrors.Errorf("set chat pending: %w", err) + } + + if !chat.RootChatID.Valid && !chat.ParentChatID.Valid { + chat.RootChatID = uuid.NullUUID{UUID: chat.ID, Valid: true} + } + return nil + }, nil) + if txErr != nil { + return database.Chat{}, txErr + } + + p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindCreated) + return chat, nil +} + +// SendMessage inserts a user message and optionally queues it while the chat +// is busy, then publishes stream + pubsub updates. +func (p *Server) SendMessage( + ctx context.Context, + opts SendMessageOptions, +) (SendMessageResult, error) { + if opts.ChatID == uuid.Nil { + return SendMessageResult{}, xerrors.New("chat_id is required") + } + if len(opts.Content) == 0 { + return SendMessageResult{}, xerrors.New("content is required") + } + + busyBehavior := opts.BusyBehavior + if busyBehavior == "" { + busyBehavior = SendMessageBusyBehaviorQueue + } + switch busyBehavior { + case SendMessageBusyBehaviorQueue, SendMessageBusyBehaviorInterrupt: + default: + return SendMessageResult{}, xerrors.Errorf("invalid busy behavior %q", opts.BusyBehavior) + } + + content, err := chatprompt.MarshalContent(opts.Content) + if err != nil { + return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err) + } + + var ( + result SendMessageResult + queuedMessagesSDK []codersdk.ChatQueuedMessage + ) + + txErr := p.db.InTx(func(tx database.Store) error { + lockedChat, err := tx.GetChatByIDForUpdate(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("lock chat: %w", err) + } + modelConfigID := lockedChat.LastModelConfigID + if opts.ModelConfigID != nil { + modelConfigID = *opts.ModelConfigID + } + + if busyBehavior == SendMessageBusyBehaviorQueue && + shouldQueueUserMessage(lockedChat.Status) { + existingQueued, err := tx.GetChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("get queued messages: %w", err) + } + if len(existingQueued) >= MaxQueueSize { + return ErrMessageQueueFull + } + + queued, err := tx.InsertChatQueuedMessage(ctx, database.InsertChatQueuedMessageParams{ + ChatID: opts.ChatID, + Content: content.RawMessage, + }) + if err != nil { + return xerrors.Errorf("insert queued message: %w", err) + } + + queuedMessages, err := tx.GetChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("get queued messages: %w", err) + } + + result.Queued = true + result.QueuedMessage = &queued + result.Chat = lockedChat + queuedMessagesSDK = db2sdk.ChatQueuedMessages(queuedMessages) + return nil + } + + message, updatedChat, err := insertUserMessageAndSetPending( + ctx, + tx, + lockedChat, + modelConfigID, + content, + ) + if err != nil { + return err + } + result.Message = message + result.Chat = updatedChat + + return nil + }, nil) + if txErr != nil { + return SendMessageResult{}, txErr + } + + if result.Queued { + p.publishEvent(opts.ChatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + ChatID: opts.ChatID, + QueuedMessages: queuedMessagesSDK, + }) + p.publishChatStreamNotify(opts.ChatID, coderdpubsub.ChatStreamNotifyMessage{ + QueueUpdate: true, + }) + return result, nil + } + + p.publishMessage(opts.ChatID, result.Message) + p.publishStatus(opts.ChatID, result.Chat.Status, result.Chat.WorkerID) + p.publishChatPubsubEvent(result.Chat, coderdpubsub.ChatEventKindStatusChange) + return result, nil +} + +// EditMessage updates a user message in-place, truncates all following messages, +// clears queued messages, and moves the chat into pending status. +func (p *Server) EditMessage( + ctx context.Context, + opts EditMessageOptions, +) (EditMessageResult, error) { + if opts.ChatID == uuid.Nil { + return EditMessageResult{}, xerrors.New("chat_id is required") + } + if opts.EditedMessageID <= 0 { + return EditMessageResult{}, xerrors.New("edited_message_id is required") + } + if len(opts.Content) == 0 { + return EditMessageResult{}, xerrors.New("content is required") + } + + content, err := chatprompt.MarshalContent(opts.Content) + if err != nil { + return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err) + } + + var result EditMessageResult + txErr := p.db.InTx(func(tx database.Store) error { + _, err := tx.GetChatByIDForUpdate(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("lock chat: %w", err) + } + + existing, err := tx.GetChatMessageByID(ctx, opts.EditedMessageID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrEditedMessageNotFound + } + return xerrors.Errorf("get edited message: %w", err) + } + if existing.ChatID != opts.ChatID { + return ErrEditedMessageNotFound + } + if existing.Role != "user" { + return ErrEditedMessageNotUser + } + + updatedMessage, err := tx.UpdateChatMessageByID(ctx, database.UpdateChatMessageByIDParams{ + ModelConfigID: uuid.NullUUID{}, + Content: content, + ID: opts.EditedMessageID, + }) + if err != nil { + return xerrors.Errorf("update chat message: %w", err) + } + + err = tx.DeleteChatMessagesAfterID(ctx, database.DeleteChatMessagesAfterIDParams{ + ChatID: opts.ChatID, + AfterID: opts.EditedMessageID, + }) + if err != nil { + return xerrors.Errorf("delete later chat messages: %w", err) + } + + err = tx.DeleteAllChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("delete queued messages: %w", err) + } + + updatedChat, err := tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: opts.ChatID, + Status: database.ChatStatusPending, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if err != nil { + return xerrors.Errorf("set chat pending: %w", err) + } + + result.Message = updatedMessage + result.Chat = updatedChat + return nil + }, nil) + if txErr != nil { + return EditMessageResult{}, txErr + } + + p.publishMessage(opts.ChatID, result.Message) + p.publishEvent(opts.ChatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + QueuedMessages: []codersdk.ChatQueuedMessage{}, + }) + p.publishChatStreamNotify(opts.ChatID, coderdpubsub.ChatStreamNotifyMessage{ + QueueUpdate: true, + }) + p.publishStatus(opts.ChatID, result.Chat.Status, result.Chat.WorkerID) + p.publishChatPubsubEvent(result.Chat, coderdpubsub.ChatEventKindStatusChange) + + return result, nil +} + +// DeleteChat removes a chat and all descendants, then broadcasts a deleted event. +func (p *Server) DeleteChat(ctx context.Context, chatID uuid.UUID) error { + if chatID == uuid.Nil { + return xerrors.New("chat_id is required") + } + + chat, err := p.db.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("get chat: %w", err) + } + + err = p.db.InTx(func(tx database.Store) error { + // Collect descendants breadth-first, then delete from leaves upward. + descendantIDs := make([]uuid.UUID, 0) + queue := []uuid.UUID{chatID} + for len(queue) > 0 { + parentID := queue[0] + queue = queue[1:] + + children, err := tx.ListChildChatsByParentID(ctx, parentID) + if err != nil { + return xerrors.Errorf("list children of chat %s: %w", parentID, err) + } + for _, child := range children { + descendantIDs = append(descendantIDs, child.ID) + queue = append(queue, child.ID) + } + } + + for i := len(descendantIDs) - 1; i >= 0; i-- { + if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil { + return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err) + } + } + + if err := tx.DeleteChatByID(ctx, chatID); err != nil { + return xerrors.Errorf("delete chat: %w", err) + } + + return nil + }, nil) + if err != nil { + return err + } + + p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindDeleted) + return nil +} + +// DeleteQueued removes a queued user message and publishes the queue update. +func (p *Server) DeleteQueued( + ctx context.Context, + chatID uuid.UUID, + queuedMessageID int64, +) error { + if chatID == uuid.Nil { + return xerrors.New("chat_id is required") + } + + err := p.db.DeleteChatQueuedMessage(ctx, database.DeleteChatQueuedMessageParams{ + ID: queuedMessageID, + ChatID: chatID, + }) + if err != nil { + return xerrors.Errorf("delete queued message: %w", err) + } + + queuedMessages, err := p.db.GetChatQueuedMessages(ctx, chatID) + if err != nil { + p.logger.Warn(ctx, "failed to load queued messages after delete", + slog.F("chat_id", chatID), + slog.F("queued_message_id", queuedMessageID), + slog.Error(err), + ) + return nil + } + + p.publishEvent(chatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + QueuedMessages: db2sdk.ChatQueuedMessages(queuedMessages), + }) + p.publishChatStreamNotify(chatID, coderdpubsub.ChatStreamNotifyMessage{ + QueueUpdate: true, + }) + return nil +} + +// PromoteQueued promotes a queued message into chat history and marks the chat pending. +func (p *Server) PromoteQueued( + ctx context.Context, + opts PromoteQueuedOptions, +) (PromoteQueuedResult, error) { + if opts.ChatID == uuid.Nil { + return PromoteQueuedResult{}, xerrors.New("chat_id is required") + } + + var ( + result PromoteQueuedResult + promoted database.ChatMessage + updatedChat database.Chat + remainingQueue []database.ChatQueuedMessage + ) + + txErr := p.db.InTx(func(tx database.Store) error { + lockedChat, err := tx.GetChatByIDForUpdate(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("lock chat: %w", err) + } + modelConfigID := lockedChat.LastModelConfigID + if opts.ModelConfigID != nil { + modelConfigID = *opts.ModelConfigID + } + + queuedMessages, err := tx.GetChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("get queued messages: %w", err) + } + + var ( + targetContent json.RawMessage + found bool + ) + for _, qm := range queuedMessages { + if qm.ID == opts.QueuedMessageID { + targetContent = qm.Content + found = true + break + } + } + if !found { + return xerrors.New("queued message not found") + } + + err = tx.DeleteChatQueuedMessage(ctx, database.DeleteChatQueuedMessageParams{ + ID: opts.QueuedMessageID, + ChatID: opts.ChatID, + }) + if err != nil { + return xerrors.Errorf("delete queued message: %w", err) + } + + promoted, updatedChat, err = insertUserMessageAndSetPending( + ctx, + tx, + lockedChat, + modelConfigID, + pqtype.NullRawMessage{ + RawMessage: targetContent, + Valid: len(targetContent) > 0, + }, + ) + if err != nil { + return err + } + + remainingQueue, err = tx.GetChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("get remaining queue: %w", err) + } + result.PromotedMessage = promoted + + return nil + }, nil) + if txErr != nil { + return PromoteQueuedResult{}, txErr + } + + p.publishEvent(opts.ChatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + QueuedMessages: db2sdk.ChatQueuedMessages(remainingQueue), + }) + p.publishChatStreamNotify(opts.ChatID, coderdpubsub.ChatStreamNotifyMessage{ + QueueUpdate: true, + }) + p.publishMessage(opts.ChatID, promoted) + p.publishStatus(opts.ChatID, updatedChat.Status, updatedChat.WorkerID) + + return result, nil +} + +// InterruptChat interrupts execution, sets waiting status, and broadcasts status updates. +func (p *Server) InterruptChat( + ctx context.Context, + chat database.Chat, +) database.Chat { + if chat.ID == uuid.Nil { + return chat + } + + updatedChat, err := p.setChatWaiting(ctx, chat.ID) + if err != nil { + p.logger.Error(ctx, "failed to mark chat as waiting", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return chat + } + return updatedChat +} + +// RefreshStatus loads the latest chat status and publishes it to stream subscribers. +func (p *Server) RefreshStatus(ctx context.Context, chatID uuid.UUID) error { + if chatID == uuid.Nil { + return xerrors.New("chat_id is required") + } + + chat, err := p.db.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("get chat: %w", err) + } + + p.publishStatus(chat.ID, chat.Status, chat.WorkerID) + return nil +} + +func setChatPendingWithStore( + ctx context.Context, + store database.Store, + chatID uuid.UUID, +) (database.Chat, error) { + chat, err := store.GetChatByID(ctx, chatID) + if err != nil { + return database.Chat{}, xerrors.Errorf("get chat: %w", err) + } + if chat.Status == database.ChatStatusPending { + return chat, nil + } + + updatedChat, err := store.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusPending, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if err != nil { + return database.Chat{}, xerrors.Errorf("set chat pending: %w", err) + } + return updatedChat, nil +} + +func (p *Server) setChatWaiting(ctx context.Context, chatID uuid.UUID) (database.Chat, error) { + updatedChat, err := p.db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chatID, + Status: database.ChatStatusWaiting, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if err != nil { + return database.Chat{}, err + } + p.publishStatus(chatID, updatedChat.Status, updatedChat.WorkerID) + p.publishChatPubsubEvent(updatedChat, coderdpubsub.ChatEventKindStatusChange) + return updatedChat, nil +} + +func insertChatMessageWithStore( + ctx context.Context, + store database.Store, + params database.InsertChatMessageParams, +) (database.ChatMessage, error) { + message, err := store.InsertChatMessage(ctx, params) + if err != nil { + return database.ChatMessage{}, xerrors.Errorf("insert chat message: %w", err) + } + return message, nil +} + +func insertUserMessageAndSetPending( + ctx context.Context, + store database.Store, + lockedChat database.Chat, + modelConfigID uuid.UUID, + content pqtype.NullRawMessage, +) (database.ChatMessage, database.Chat, error) { + message, err := insertChatMessageWithStore(ctx, store, database.InsertChatMessageParams{ + ChatID: lockedChat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: "user", + Content: content, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + if err != nil { + return database.ChatMessage{}, database.Chat{}, err + } + + if lockedChat.Status == database.ChatStatusPending { + return message, lockedChat, nil + } + + updatedChat, err := store.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: lockedChat.ID, + Status: database.ChatStatusPending, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if err != nil { + return database.ChatMessage{}, database.Chat{}, xerrors.Errorf("set chat pending: %w", err) + } + return message, updatedChat, nil +} + +// shouldQueueUserMessage reports whether a user message should be +// queued while a chat is active. +func shouldQueueUserMessage(status database.ChatStatus) bool { + switch status { + case database.ChatStatusRunning, database.ChatStatusPending: + return true + default: + return false + } +} + +// Config configures a chat processor. +type Config struct { + Logger slog.Logger + Database database.Store + ReplicaID uuid.UUID + RemotePartsProvider RemotePartsProvider + PendingChatAcquireInterval time.Duration + InFlightChatStaleAfter time.Duration + AgentConn AgentConnFunc + CreateWorkspace chattool.CreateWorkspaceFn + Pubsub pubsub.Pubsub + ProviderAPIKeys chatprovider.ProviderAPIKeys +} + +// New creates a new chat processor. The processor polls for pending +// chats and processes them. It is the caller's responsibility to call Close +// on the returned instance. +func New(cfg Config) *Server { + ctx, cancel := context.WithCancel(context.Background()) + + pendingChatAcquireInterval := cfg.PendingChatAcquireInterval + if pendingChatAcquireInterval == 0 { + pendingChatAcquireInterval = DefaultPendingChatAcquireInterval + } + + inFlightChatStaleAfter := cfg.InFlightChatStaleAfter + if inFlightChatStaleAfter == 0 { + inFlightChatStaleAfter = DefaultInFlightChatStaleAfter + } + + workerID := cfg.ReplicaID + if workerID == uuid.Nil { + workerID = uuid.New() + } + + p := &Server{ + cancel: cancel, + closed: make(chan struct{}), + db: cfg.Database, + workerID: workerID, + logger: cfg.Logger.Named("chat-processor"), + remotePartsProvider: cfg.RemotePartsProvider, + agentConnFn: cfg.AgentConn, + createWorkspaceFn: cfg.CreateWorkspace, + pubsub: cfg.Pubsub, + providerAPIKeys: cfg.ProviderAPIKeys, + chatStreams: make(map[uuid.UUID]*chatStreamState), + instructionCache: make(map[uuid.UUID]cachedInstruction), + pendingChatAcquireInterval: pendingChatAcquireInterval, + inFlightChatStaleAfter: inFlightChatStaleAfter, + } + + //nolint:gocritic // The chat processor is a system-level service. + ctx = dbauthz.AsSystemRestricted(ctx) + go p.start(ctx) + + return p +} + +func (p *Server) start(ctx context.Context) { + defer close(p.closed) + + // First, recover any stale chats from crashed workers. + p.recoverStaleChats(ctx) + + ticker := time.NewTicker(p.pendingChatAcquireInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.processOnce(ctx) + } + } +} + +func (p *Server) processOnce(ctx context.Context) { + // Try to acquire a pending chat. + chat, err := p.db.AcquireChat(ctx, database.AcquireChatParams{ + StartedAt: time.Now(), + WorkerID: p.workerID, + }) + if err != nil { + if !xerrors.Is(err, sql.ErrNoRows) { + p.logger.Error(ctx, "failed to acquire chat", slog.Error(err)) + } + // No pending chats or error. + return + } + + // Process the chat (don't block the main loop). + p.inflight.Add(1) + go func() { + defer p.inflight.Done() + p.processChat(ctx, chat) + }() +} + +func (p *Server) publishToStream(chatID uuid.UUID, event codersdk.ChatStreamEvent) { + p.streamMu.Lock() + state := p.streamStateLocked(chatID) + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + if !state.buffering { + p.streamMu.Unlock() + return + } + state.buffer = append(state.buffer, event) + } + subscribers := make([]chan codersdk.ChatStreamEvent, 0, len(state.subscribers)) + for _, ch := range state.subscribers { + subscribers = append(subscribers, ch) + } + p.streamMu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- event: + default: + p.logger.Warn(context.Background(), "dropping chat stream event", + slog.F("chat_id", chatID), slog.F("type", event.Type)) + } + } +} + +func (p *Server) subscribeToStream(chatID uuid.UUID) ( + []codersdk.ChatStreamEvent, + <-chan codersdk.ChatStreamEvent, + func(), +) { + p.streamMu.Lock() + state := p.streamStateLocked(chatID) + snapshot := append([]codersdk.ChatStreamEvent(nil), state.buffer...) + id := uuid.New() + ch := make(chan codersdk.ChatStreamEvent, 128) + state.subscribers[id] = ch + p.streamMu.Unlock() + + cancel := func() { + p.streamMu.Lock() + state, ok := p.chatStreams[chatID] + if ok { + if subscriber, exists := state.subscribers[id]; exists { + delete(state.subscribers, id) + close(subscriber) + } + p.cleanupStreamIfIdleLocked(chatID, state) + } + p.streamMu.Unlock() + } + + return snapshot, ch, cancel +} + +// cleanupStreamIfIdleLocked removes the chat entry when there +// are no subscribers and the stream is not buffering. The +// caller must hold p.streamMu. +func (p *Server) cleanupStreamIfIdleLocked(chatID uuid.UUID, state *chatStreamState) { + if !state.buffering && len(state.subscribers) == 0 { + delete(p.chatStreams, chatID) + } +} + +func (p *Server) streamStateLocked(chatID uuid.UUID) *chatStreamState { + state, ok := p.chatStreams[chatID] + if !ok { + state = &chatStreamState{subscribers: make(map[uuid.UUID]chan codersdk.ChatStreamEvent)} + p.chatStreams[chatID] = state + } + return state +} + +func (p *Server) Subscribe( + ctx context.Context, + chatID uuid.UUID, + requestHeader http.Header, +) ( + []codersdk.ChatStreamEvent, + <-chan codersdk.ChatStreamEvent, + func(), + bool, +) { + if p == nil { + return nil, nil, nil, false + } + if ctx == nil { + ctx = context.Background() + } + + // Subscribe to local stream for message_parts (ephemeral). + localSnapshot, localParts, localCancel := p.subscribeToStream(chatID) + + // Build initial snapshot synchronously + initialSnapshot := make([]codersdk.ChatStreamEvent, 0) + // Add local message_parts to snapshot + for _, event := range localSnapshot { + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + initialSnapshot = append(initialSnapshot, event) + } + } + + // Load initial messages from DB + //nolint:gocritic // System context needed to read chat messages for stream. + messages, err := p.db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), chatID) + if err == nil { + for _, msg := range messages { + sdkMsg := db2sdk.ChatMessage(msg) + initialSnapshot = append(initialSnapshot, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessage, + ChatID: chatID, + Message: &sdkMsg, + }) + } + } + + // Load initial queue + //nolint:gocritic // System context needed to read queued messages for stream. + queued, err := p.db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chatID) + if err == nil && len(queued) > 0 { + initialSnapshot = append(initialSnapshot, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + ChatID: chatID, + QueuedMessages: db2sdk.ChatQueuedMessages(queued), + }) + } + + // Get initial chat state to determine if we need a relay + //nolint:gocritic // System context needed to read chat state for relay. + chat, err := p.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chatID) + var relayCancel func() + var relayParts <-chan codersdk.ChatStreamEvent + if err == nil && chat.Status == database.ChatStatusRunning && chat.WorkerID.Valid && chat.WorkerID.UUID != p.workerID && p.remotePartsProvider != nil { + // Open relay for initial snapshot + snapshot, parts, cancel, err := p.remotePartsProvider(ctx, chatID, chat.WorkerID.UUID, requestHeader) + if err == nil { + relayCancel = cancel + relayParts = parts + // Add relay message_parts to snapshot + for _, event := range snapshot { + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + initialSnapshot = append(initialSnapshot, event) + } + } + } + } + + // Track the last message ID we've seen for DB queries + var lastMessageID int64 + if len(messages) > 0 { + lastMessageID = messages[len(messages)-1].ID + } + + // Merge all event sources + mergedCtx, mergedCancel := context.WithCancel(ctx) + mergedEvents := make(chan codersdk.ChatStreamEvent, 128) + var allCancels []func() + allCancels = append(allCancels, localCancel) + if relayCancel != nil { + allCancels = append(allCancels, relayCancel) + } + + // Helper to close relay + closeRelay := func() { + if relayCancel != nil { + relayCancel() + relayCancel = nil + } + relayParts = nil + } + + // Helper to open relay to a worker + openRelay := func(workerID uuid.UUID) { + if p.remotePartsProvider == nil { + return + } + closeRelay() + snapshot, parts, cancel, err := p.remotePartsProvider(mergedCtx, chatID, workerID, requestHeader) + if err != nil { + p.logger.Warn(mergedCtx, "failed to open relay for message parts", + slog.F("chat_id", chatID), + slog.F("worker_id", workerID), + slog.Error(err), + ) + return + } + relayParts = parts + relayCancel = cancel + // Send relay snapshot message_parts + for _, event := range snapshot { + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- event: + } + } + } + } + + //nolint:nestif + if p.pubsub != nil { + notifications := make(chan coderdpubsub.ChatStreamNotifyMessage, 10) + errCh := make(chan error, 1) + + listener := func(_ context.Context, message []byte, err error) { + if err != nil { + select { + case <-mergedCtx.Done(): + case errCh <- err: + } + return + } + var notify coderdpubsub.ChatStreamNotifyMessage + if unmarshalErr := json.Unmarshal(message, ¬ify); unmarshalErr != nil { + select { + case <-mergedCtx.Done(): + case errCh <- xerrors.Errorf("unmarshal chat stream notify: %w", unmarshalErr): + } + return + } + select { + case <-mergedCtx.Done(): + case notifications <- notify: + } + } + + // Subscribe to pubsub for durable events + if pubsubCancel, err := p.pubsub.SubscribeWithErr( + coderdpubsub.ChatStreamNotifyChannel(chatID), + listener, + ); err == nil { + allCancels = append(allCancels, pubsubCancel) + } else { + p.logger.Warn(mergedCtx, "failed to subscribe to chat stream notifications", + slog.F("chat_id", chatID), + slog.Error(err), + ) + } + + // Handle pubsub notifications in a goroutine + go func() { + defer close(mergedEvents) + defer closeRelay() + + for { + relayPartsCh := relayParts + select { + case <-mergedCtx.Done(): + return + case err := <-errCh: + p.logger.Error(mergedCtx, "chat stream pubsub error", + slog.F("chat_id", chatID), + slog.Error(err), + ) + mergedEvents <- codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeError, + ChatID: chatID, + Error: &codersdk.ChatStreamError{ + Message: err.Error(), + }, + } + return + case notify := <-notifications: + // Handle different notification types + if notify.AfterMessageID > 0 { + // Read new messages from DB + //nolint:gocritic // System context needed to read chat messages for stream. + messages, err := p.db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(mergedCtx), chatID) + if err == nil { + for _, msg := range messages { + if msg.ID > lastMessageID { + sdkMsg := db2sdk.ChatMessage(msg) + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessage, + ChatID: chatID, + Message: &sdkMsg, + }: + } + lastMessageID = msg.ID + } + } + } + } + if notify.Status != "" { + status := database.ChatStatus(notify.Status) + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: chatID, + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatus(status)}, + }: + } + // Manage relay lifecycle based on status + if status == database.ChatStatusRunning && notify.WorkerID != "" { + workerID, err := uuid.Parse(notify.WorkerID) + if err == nil && workerID != p.workerID { + openRelay(workerID) + } else if workerID == p.workerID { + closeRelay() + } + } else { + closeRelay() + } + } + if notify.Error != "" { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeError, + ChatID: chatID, + Error: &codersdk.ChatStreamError{ + Message: notify.Error, + }, + }: + } + } + if notify.QueueUpdate { + //nolint:gocritic // System context needed to read queued messages for stream. + queued, err := p.db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(mergedCtx), chatID) + if err == nil { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + ChatID: chatID, + QueuedMessages: db2sdk.ChatQueuedMessages(queued), + }: + } + } + } + case event, ok := <-localParts: + if !ok { + // Local parts channel closed, but continue with pubsub + continue + } + // Only forward message_part events from local (durable events come via pubsub) + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- event: + } + } + case event, ok := <-relayPartsCh: + if !ok { + relayParts = nil + continue + } + // Only forward message_part events from relay (durable events come via pubsub) + if event.Type == codersdk.ChatStreamEventTypeMessagePart { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- event: + } + } + } + } + }() + } else { + // No pubsub, just merge local parts + go func() { + defer close(mergedEvents) + for _, event := range localSnapshot { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- event: + } + } + for event := range localParts { + select { + case <-mergedCtx.Done(): + return + case mergedEvents <- event: + } + } + }() + } + cancel := func() { + mergedCancel() + for _, cancelFn := range allCancels { + if cancelFn != nil { + cancelFn() + } + } + } + + return initialSnapshot, mergedEvents, cancel, true +} + +func (p *Server) publishEvent(chatID uuid.UUID, event codersdk.ChatStreamEvent) { + if event.ChatID == uuid.Nil { + event.ChatID = chatID + } + p.publishToStream(chatID, event) +} + +func (p *Server) publishStatus(chatID uuid.UUID, status database.ChatStatus, workerID uuid.NullUUID) { + p.publishEvent(chatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatus(status)}, + }) + notify := coderdpubsub.ChatStreamNotifyMessage{ + Status: string(status), + } + if workerID.Valid { + notify.WorkerID = workerID.UUID.String() + } + p.publishChatStreamNotify(chatID, notify) +} + +// publishChatStreamNotify broadcasts a per-chat stream notification via +// PostgreSQL pubsub so that all replicas can read updates from the database. +func (p *Server) publishChatStreamNotify(chatID uuid.UUID, notify coderdpubsub.ChatStreamNotifyMessage) { + if p.pubsub == nil { + return + } + payload, err := json.Marshal(notify) + if err != nil { + p.logger.Error(context.Background(), "failed to marshal chat stream notify", + slog.F("chat_id", chatID), + slog.Error(err), + ) + return + } + if err := p.pubsub.Publish(coderdpubsub.ChatStreamNotifyChannel(chatID), payload); err != nil { + p.logger.Error(context.Background(), "failed to publish chat stream notify", + slog.F("chat_id", chatID), + slog.Error(err), + ) + } +} + +// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL +// pubsub so that all replicas can push updates to watching clients. +func (p *Server) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.ChatEventKind) { + if p.pubsub == nil { + return + } + sdkChat := codersdk.Chat{ + ID: chat.ID, + OwnerID: chat.OwnerID, + Title: chat.Title, + Status: codersdk.ChatStatus(chat.Status), + CreatedAt: chat.CreatedAt, + UpdatedAt: chat.UpdatedAt, + } + if chat.ParentChatID.Valid { + parentChatID := chat.ParentChatID.UUID + sdkChat.ParentChatID = &parentChatID + } + if chat.RootChatID.Valid { + rootChatID := chat.RootChatID.UUID + sdkChat.RootChatID = &rootChatID + } else if !chat.ParentChatID.Valid { + rootChatID := chat.ID + sdkChat.RootChatID = &rootChatID + } + if chat.WorkspaceID.Valid { + sdkChat.WorkspaceID = &chat.WorkspaceID.UUID + } + if chat.WorkspaceAgentID.Valid { + sdkChat.WorkspaceAgentID = &chat.WorkspaceAgentID.UUID + } + + event := coderdpubsub.ChatEvent{ + Kind: kind, + Chat: sdkChat, + } + payload, err := json.Marshal(event) + if err != nil { + p.logger.Error(context.Background(), "failed to marshal chat pubsub event", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return + } + if err := p.pubsub.Publish(coderdpubsub.ChatEventChannel(chat.OwnerID), payload); err != nil { + p.logger.Error(context.Background(), "failed to publish chat pubsub event", + slog.F("chat_id", chat.ID), + slog.F("kind", kind), + slog.Error(err), + ) + } +} + +func (p *Server) publishError(chatID uuid.UUID, message string) { + message = strings.TrimSpace(message) + if message == "" { + return + } + p.publishEvent(chatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeError, + Error: &codersdk.ChatStreamError{Message: message}, + }) + p.publishChatStreamNotify(chatID, coderdpubsub.ChatStreamNotifyMessage{ + Error: message, + }) +} + +func processingFailureReason(err error) (string, bool) { + if err == nil { + return "", false + } + + reason := strings.TrimSpace(err.Error()) + if reason == "" { + return "", false + } + return reason, true +} + +func panicFailureReason(recovered any) string { + var reason string + switch typed := recovered.(type) { + case string: + reason = strings.TrimSpace(typed) + case error: + reason = strings.TrimSpace(typed.Error()) + default: + reason = strings.TrimSpace(fmt.Sprint(typed)) + } + + if reason == "" || reason == "" { + return "chat processing panicked" + } + return "chat processing panicked: " + reason +} + +func (p *Server) publishMessage(chatID uuid.UUID, message database.ChatMessage) { + sdkMessage := db2sdk.ChatMessage(message) + p.publishEvent(chatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessage, + Message: &sdkMessage, + }) + p.publishChatStreamNotify(chatID, coderdpubsub.ChatStreamNotifyMessage{ + AfterMessageID: message.ID - 1, + }) +} + +func (p *Server) publishMessagePart(chatID uuid.UUID, role string, part codersdk.ChatMessagePart) { + if part.Type == "" { + return + } + p.publishEvent(chatID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessagePart, + MessagePart: &codersdk.ChatStreamMessagePart{ + Role: role, + Part: part, + }, + }) +} + +func shouldCancelChatFromControlNotification( + notify coderdpubsub.ChatStreamNotifyMessage, + workerID uuid.UUID, +) bool { + status := database.ChatStatus(strings.TrimSpace(notify.Status)) + switch status { + case database.ChatStatusWaiting, database.ChatStatusPending, database.ChatStatusError: + return true + case database.ChatStatusRunning: + worker := strings.TrimSpace(notify.WorkerID) + if worker == "" { + return false + } + notifyWorkerID, err := uuid.Parse(worker) + if err != nil { + return false + } + return notifyWorkerID != workerID + default: + return false + } +} + +func (p *Server) subscribeChatControl( + ctx context.Context, + chatID uuid.UUID, + cancel context.CancelCauseFunc, + logger slog.Logger, +) func() { + if p.pubsub == nil { + return nil + } + + listener := func(_ context.Context, message []byte, err error) { + if err != nil { + logger.Warn(ctx, "chat control pubsub error", slog.Error(err)) + return + } + + var notify coderdpubsub.ChatStreamNotifyMessage + if unmarshalErr := json.Unmarshal(message, ¬ify); unmarshalErr != nil { + logger.Warn(ctx, "failed to unmarshal chat control notify", slog.Error(unmarshalErr)) + return + } + + if shouldCancelChatFromControlNotification(notify, p.workerID) { + cancel(chatloop.ErrInterrupted) + } + } + + controlCancel, err := p.pubsub.SubscribeWithErr( + coderdpubsub.ChatStreamNotifyChannel(chatID), + listener, + ) + if err != nil { + logger.Warn(ctx, "failed to subscribe to chat control notifications", slog.Error(err)) + return nil + } + return controlCancel +} + +func (p *Server) processChat(ctx context.Context, chat database.Chat) { + logger := p.logger.With(slog.F("chat_id", chat.ID)) + logger.Info(ctx, "processing chat request") + + chatCtx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + controlCancel := p.subscribeChatControl(chatCtx, chat.ID, cancel, logger) + defer func() { + if controlCancel != nil { + controlCancel() + } + }() + + // Periodically update the heartbeat so other replicas know this + // worker is still alive. The goroutine stops when chatCtx is + // canceled (either by completion or interruption). + go func() { + ticker := time.NewTicker(chatHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-chatCtx.Done(): + return + case <-ticker.C: + rows, err := p.db.UpdateChatHeartbeat(chatCtx, database.UpdateChatHeartbeatParams{ + ID: chat.ID, + WorkerID: p.workerID, + }) + if err != nil { + logger.Warn(chatCtx, "failed to update chat heartbeat", slog.Error(err)) + continue + } + if rows == 0 { + cancel(chatloop.ErrInterrupted) + return + } + } + } + }() + + p.publishStatus(chat.ID, database.ChatStatusRunning, uuid.NullUUID{ + UUID: p.workerID, + Valid: true, + }) + + // Determine the final status to set when we're done. + status := database.ChatStatusWaiting + remainingQueuedMessages := []database.ChatQueuedMessage{} + shouldPublishQueueUpdate := false + + defer func() { + // Handle panics gracefully. + if r := recover(); r != nil { + logger.Error(ctx, "panic during chat processing", slog.F("panic", r)) + p.publishError(chat.ID, panicFailureReason(r)) + status = database.ChatStatusError + } + + // Check for queued messages and auto-promote the next one. + // This must be done atomically with the status update to avoid + // races with the promote endpoint (which also sets status to + // pending). We use a transaction with FOR UPDATE to ensure we + // don't overwrite a status change made by another caller. + err := p.db.InTx(func(tx database.Store) error { + // Re-read the chat status under lock β€” another caller + // (e.g. promote) may have already set it to pending. + latestChat, lockErr := tx.GetChatByIDForUpdate(ctx, chat.ID) + if lockErr != nil { + return xerrors.Errorf("lock chat for release: %w", lockErr) + } + + // If someone else already set the chat to pending (e.g. + // the promote endpoint), don't overwrite it β€” just clear + // the worker and let the processor pick it back up. + if latestChat.Status == database.ChatStatusPending && status == database.ChatStatusWaiting { + status = database.ChatStatusPending + } else if status == database.ChatStatusWaiting { + // Try to auto-promote the next queued message. + nextQueued, popErr := tx.PopNextQueuedMessage(ctx, chat.ID) + if popErr == nil { + msg, insertErr := tx.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: latestChat.LastModelConfigID, Valid: true}, + Role: "user", + Content: pqtype.NullRawMessage{ + RawMessage: nextQueued.Content, + Valid: len(nextQueued.Content) > 0, + }, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + if insertErr != nil { + logger.Error(ctx, "failed to promote queued message", + slog.F("queued_message_id", nextQueued.ID), slog.Error(insertErr)) + } else { + status = database.ChatStatusPending + + sdkMsg := db2sdk.ChatMessage(msg) + p.publishEvent(chat.ID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeMessage, + Message: &sdkMsg, + }) + + remaining, qErr := tx.GetChatQueuedMessages(ctx, chat.ID) + if qErr == nil { + remainingQueuedMessages = remaining + shouldPublishQueueUpdate = true + } + } + } + } + + _, updateErr := tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: status, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + return updateErr + }, nil) + if err != nil { + logger.Error(ctx, "failed to release chat", slog.Error(err)) + } + if err == nil && shouldPublishQueueUpdate { + p.publishEvent(chat.ID, codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeQueueUpdate, + QueuedMessages: db2sdk.ChatQueuedMessages(remainingQueuedMessages), + }) + p.publishChatStreamNotify(chat.ID, coderdpubsub.ChatStreamNotifyMessage{ + QueueUpdate: true, + }) + } + + p.publishStatus(chat.ID, status, uuid.NullUUID{}) + chat.Status = status + p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindStatusChange) + }() + + if err := p.runChat(chatCtx, chat, logger); err != nil { + if errors.Is(err, chatloop.ErrInterrupted) || errors.Is(context.Cause(chatCtx), chatloop.ErrInterrupted) { + logger.Info(ctx, "chat interrupted") + status = database.ChatStatusWaiting + return + } + logger.Error(ctx, "failed to process chat", slog.Error(err)) + if reason, ok := processingFailureReason(err); ok { + p.publishError(chat.ID, reason) + } + status = database.ChatStatusError + return + } +} + +func (p *Server) runChat( + ctx context.Context, + chat database.Chat, + logger slog.Logger, +) error { + model, modelConfig, err := p.resolveChatModel(ctx, chat) + if err != nil { + return err + } + + var callConfig codersdk.ChatModelCallConfig + if len(modelConfig.Options) > 0 { + if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { + return xerrors.Errorf("parse model call config: %w", err) + } + } + + messages, err := p.db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + if err != nil { + return xerrors.Errorf("get chat messages: %w", err) + } + p.maybeGenerateChatTitle(ctx, chat, messages, model, logger) + + prompt, err := chatprompt.ConvertMessages(messages) + if err != nil { + return xerrors.Errorf("build chat prompt: %w", err) + } + if chat.ParentChatID.Valid { + prompt = chatprompt.InsertSystem(prompt, defaultSubagentInstruction) + } + + // Start buffering stream events for this chat so that new + // subscribers receive a snapshot of in-flight message parts. + p.streamMu.Lock() + startState := p.streamStateLocked(chat.ID) + startState.buffer = nil + startState.buffering = true + p.streamMu.Unlock() + defer func() { + p.streamMu.Lock() + if stopState, ok := p.chatStreams[chat.ID]; ok { + stopState.buffer = nil + stopState.buffering = false + p.cleanupStreamIfIdleLocked(chat.ID, stopState) + } + p.streamMu.Unlock() + }() + + currentChat := chat + loadChatSnapshot := func( + loadCtx context.Context, + chatID uuid.UUID, + ) (database.Chat, error) { + //nolint:gocritic // System context required to load chat snapshots for the stream. + return p.db.GetChatByID(dbauthz.AsSystemRestricted(loadCtx), chatID) + } + var ( + chatStateMu sync.Mutex + workspaceMu sync.Mutex + conn workspacesdk.AgentConn + releaseConn func() + ) + closeConn := func() { + if releaseConn != nil { + releaseConn() + releaseConn = nil + } + } + defer closeConn() + + getWorkspaceConn := func(ctx context.Context) (workspacesdk.AgentConn, error) { + chatStateMu.Lock() + if conn != nil { + currentConn := conn + chatStateMu.Unlock() + return currentConn, nil + } + chatSnapshot := currentChat + chatStateMu.Unlock() + + if p.agentConnFn == nil { + return nil, xerrors.New("workspace agent connector is not configured") + } + + if !chatSnapshot.WorkspaceAgentID.Valid { + refreshedChat, refreshErr := refreshChatWorkspaceSnapshot( + ctx, + chatSnapshot, + loadChatSnapshot, + ) + if refreshErr != nil { + return nil, refreshErr + } + if refreshedChat.WorkspaceAgentID.Valid { + chatStateMu.Lock() + currentChat = refreshedChat + chatSnapshot = refreshedChat + chatStateMu.Unlock() + } + } + + if !chatSnapshot.WorkspaceAgentID.Valid { + return nil, xerrors.New("chat has no workspace agent") + } + + agentConn, agentRelease, err := p.agentConnFn(ctx, chatSnapshot.WorkspaceAgentID.UUID) + if err != nil { + return nil, xerrors.Errorf("connect to workspace agent: %w", err) + } + + chatStateMu.Lock() + if conn == nil { + conn = agentConn + releaseConn = agentRelease + chatStateMu.Unlock() + return agentConn, nil + } + currentConn := conn + chatStateMu.Unlock() + + agentRelease() + return currentConn, nil + } + + prompt = p.appendHomeInstructionToPrompt( + ctx, + chat, + prompt, + getWorkspaceConn, + ) + + // Use the model config's context_limit as a fallback when the LLM + // provider doesn't include context_limit in its response metadata + // (which is the common case). + modelConfigContextLimit := modelConfig.ContextLimit + + persistStep := func(persistCtx context.Context, step chatloop.PersistedStep) error { + // Split the step content into assistant blocks and tool + // result blocks so they can be stored as separate messages + // with the appropriate roles. + var assistantBlocks []fantasy.Content + var toolResults []fantasy.ToolResultContent + for _, block := range step.Content { + if tr, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok { + toolResults = append(toolResults, tr) + continue + } + if trPtr, ok := fantasy.AsContentType[*fantasy.ToolResultContent](block); ok && trPtr != nil { + toolResults = append(toolResults, *trPtr) + continue + } + assistantBlocks = append(assistantBlocks, block) + } + + if len(assistantBlocks) > 0 { + assistantContent, err := chatprompt.MarshalContent(assistantBlocks) + if err != nil { + return err + } + + hasUsage := step.Usage != (fantasy.Usage{}) + assistantMessage, err := p.db.InsertChatMessage(persistCtx, database.InsertChatMessageParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true}, + Role: string(fantasy.MessageRoleAssistant), + Content: assistantContent, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: usageNullInt64(step.Usage.InputTokens, hasUsage), + OutputTokens: usageNullInt64(step.Usage.OutputTokens, hasUsage), + TotalTokens: usageNullInt64(step.Usage.TotalTokens, hasUsage), + ReasoningTokens: usageNullInt64( + step.Usage.ReasoningTokens, + hasUsage, + ), + CacheCreationTokens: usageNullInt64( + step.Usage.CacheCreationTokens, + hasUsage, + ), + CacheReadTokens: usageNullInt64(step.Usage.CacheReadTokens, hasUsage), + ContextLimit: step.ContextLimit, + Compressed: sql.NullBool{}, + }) + if err != nil { + return xerrors.Errorf("insert assistant message: %w", err) + } + p.publishMessage(chat.ID, assistantMessage) + } + + for _, tr := range toolResults { + resultContent, err := chatprompt.MarshalToolResultContent(tr) + if err != nil { + return err + } + + toolMessage, err := p.db.InsertChatMessage(persistCtx, database.InsertChatMessageParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true}, + Role: string(fantasy.MessageRoleTool), + Content: resultContent, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + if err != nil { + return xerrors.Errorf("insert tool result: %w", err) + } + + p.publishMessage(chat.ID, toolMessage) + } + return nil + } + + streamCall := fantasy.AgentStreamCall{ + MaxOutputTokens: callConfig.MaxOutputTokens, + Temperature: callConfig.Temperature, + TopP: callConfig.TopP, + TopK: callConfig.TopK, + PresencePenalty: callConfig.PresencePenalty, + FrequencyPenalty: callConfig.FrequencyPenalty, + ProviderOptions: chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions), + } + + if streamCall.MaxOutputTokens == nil { + maxOutputTokens := int64(32_000) + streamCall.MaxOutputTokens = &maxOutputTokens + } + + compactionOptions := &chatloop.CompactionOptions{ + ThresholdPercent: modelConfig.CompressionThreshold, + ContextLimit: modelConfig.ContextLimit, + Persist: func( + persistCtx context.Context, + result chatloop.CompactionResult, + ) error { + if err := p.persistChatContextSummary( + persistCtx, + chat.ID, + modelConfig.ID, + result, + ); err != nil { + return xerrors.Errorf("persist context summary: %w", err) + } + logger.Info(persistCtx, "chat context summarized", + slog.F("chat_id", chat.ID), + slog.F("threshold_percent", result.ThresholdPercent), + slog.F("usage_percent", result.UsagePercent), + slog.F("context_tokens", result.ContextTokens), + slog.F("context_limit", result.ContextLimit), + ) + return nil + }, + OnError: func(err error) { + logger.Warn(ctx, "failed to compact chat context", slog.Error(err)) + }, + } + + // Here are all the tools we have for the chat. + tools := []fantasy.AgentTool{ + chattool.ListTemplates(chattool.ListTemplatesOptions{ + DB: p.db, + OwnerID: chat.OwnerID, + }), + chattool.ReadTemplate(chattool.ReadTemplateOptions{ + DB: p.db, + OwnerID: chat.OwnerID, + }), + chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{ + DB: p.db, + OwnerID: chat.OwnerID, + ChatID: chat.ID, + CreateFn: p.createWorkspaceFn, + AgentConnFn: chattool.AgentConnFunc(p.agentConnFn), + WorkspaceMu: &workspaceMu, + }), + chattool.ReadFile(chattool.ReadFileOptions{ + GetWorkspaceConn: getWorkspaceConn, + }), + chattool.WriteFile(chattool.WriteFileOptions{ + GetWorkspaceConn: getWorkspaceConn, + }), + chattool.EditFiles(chattool.EditFilesOptions{ + GetWorkspaceConn: getWorkspaceConn, + }), + chattool.Execute(chattool.ExecuteOptions{ + GetWorkspaceConn: getWorkspaceConn, + }), + } + tools = append(tools, p.subagentTools(func() database.Chat { + return chat + })...) + + _, err = chatloop.Run(ctx, chatloop.RunOptions{ + Model: model, + Messages: prompt, + Tools: tools, + StreamCall: streamCall, + MaxSteps: maxChatSteps, + + ContextLimitFallback: modelConfigContextLimit, + + PersistStep: persistStep, + PublishMessagePart: func( + role fantasy.MessageRole, + part codersdk.ChatMessagePart, + ) { + p.publishMessagePart(chat.ID, string(role), part) + }, + Compaction: compactionOptions, + + OnInterruptedPersistError: func(err error) { + p.logger.Warn(ctx, "failed to persist interrupted chat step", slog.Error(err)) + }, + }) + return err +} + +// persistChatContextSummary persists a chat context summary to the database. +// This is invoked via the chat loop's compaction callback. +func (p *Server) persistChatContextSummary( + ctx context.Context, + chatID uuid.UUID, + modelConfigID uuid.UUID, + result chatloop.CompactionResult, +) error { + if strings.TrimSpace(result.SystemSummary) == "" || + strings.TrimSpace(result.SummaryReport) == "" { + return nil + } + + systemContent, err := json.Marshal(result.SystemSummary) + if err != nil { + return xerrors.Errorf("encode system summary: %w", err) + } + + _, err = p.db.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: chatID, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: string(fantasy.MessageRoleSystem), + Content: pqtype.NullRawMessage{ + RawMessage: systemContent, + Valid: len(systemContent) > 0, + }, + Visibility: database.ChatMessageVisibilityModel, + Compressed: sql.NullBool{Bool: true, Valid: true}, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + }) + if err != nil { + return xerrors.Errorf("insert hidden summary message: %w", err) + } + + toolCallID := "chat_summarized_" + uuid.NewString() + args, err := json.Marshal(map[string]any{ + "source": "automatic", + "threshold_percent": result.ThresholdPercent, + }) + if err != nil { + return xerrors.Errorf("encode summary tool args: %w", err) + } + + assistantContent, err := chatprompt.MarshalContent([]fantasy.Content{ + fantasy.ToolCallContent{ + ToolCallID: toolCallID, + ToolName: "chat_summarized", + Input: string(args), + }, + }) + if err != nil { + return xerrors.Errorf("encode summary tool call: %w", err) + } + + assistantMessage, err := p.db.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: chatID, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: string(fantasy.MessageRoleAssistant), + Content: assistantContent, + Visibility: database.ChatMessageVisibilityUser, + Compressed: sql.NullBool{ + Bool: true, + Valid: true, + }, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + }) + if err != nil { + return xerrors.Errorf("insert summary tool call message: %w", err) + } + + summaryResult, marshalErr := json.Marshal(map[string]any{ + "summary": result.SummaryReport, + "source": "automatic", + "threshold_percent": result.ThresholdPercent, + "usage_percent": result.UsagePercent, + "context_tokens": result.ContextTokens, + "context_limit_tokens": result.ContextLimit, + }) + if marshalErr != nil { + return xerrors.Errorf("encode summary result payload: %w", marshalErr) + } + toolResult, err := chatprompt.MarshalToolResult( + toolCallID, + "chat_summarized", + summaryResult, + false, + ) + if err != nil { + return xerrors.Errorf("encode summary tool result: %w", err) + } + + toolMessage, err := p.db.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: chatID, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: string(fantasy.MessageRoleTool), + Content: toolResult, + Visibility: database.ChatMessageVisibilityBoth, + Compressed: sql.NullBool{ + Bool: true, + Valid: true, + }, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + }) + if err != nil { + return xerrors.Errorf("insert summary tool result message: %w", err) + } + + p.publishMessage(chatID, assistantMessage) + p.publishMessage(chatID, toolMessage) + return nil +} + +func (p *Server) resolveChatModel( + ctx context.Context, + chat database.Chat, +) (fantasy.LanguageModel, database.ChatModelConfig, error) { + dbConfig, err := p.resolveModelConfig(ctx, chat) + if err != nil { + return nil, database.ChatModelConfig{}, xerrors.Errorf( + "resolve model config: %w", err, + ) + } + + providers, err := p.db.GetEnabledChatProviders(ctx) + if err != nil { + return nil, database.ChatModelConfig{}, xerrors.Errorf( + "get enabled chat providers: %w", err, + ) + } + dbProviders := make( + []chatprovider.ConfiguredProvider, 0, len(providers), + ) + for _, provider := range providers { + dbProviders = append(dbProviders, chatprovider.ConfiguredProvider{ + Provider: provider.Provider, + APIKey: provider.APIKey, + BaseURL: provider.BaseUrl, + }) + } + keys := chatprovider.MergeProviderAPIKeys( + p.providerAPIKeys, dbProviders, + ) + + model, err := chatprovider.ModelFromConfig( + dbConfig.Provider, dbConfig.Model, keys, + ) + if err != nil { + return nil, database.ChatModelConfig{}, xerrors.Errorf( + "create model: %w", err, + ) + } + return model, dbConfig, nil +} + +// resolveModelConfig looks up the chat's model config by its +// LastModelConfigID. If the referenced config no longer exists +// (e.g. it was deleted), it falls back to the default model +// config. Returns an error when no usable config is available. +func (p *Server) resolveModelConfig( + ctx context.Context, + chat database.Chat, +) (database.ChatModelConfig, error) { + if chat.LastModelConfigID != uuid.Nil { + modelConfig, err := p.db.GetChatModelConfigByID( + ctx, chat.LastModelConfigID, + ) + if err == nil { + return modelConfig, nil + } + if !xerrors.Is(err, sql.ErrNoRows) { + return database.ChatModelConfig{}, xerrors.Errorf( + "get chat model config %s: %w", + chat.LastModelConfigID, err, + ) + } + // Model config was deleted, fall through to default. + } + + defaultConfig, err := p.db.GetDefaultChatModelConfig(ctx) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return database.ChatModelConfig{}, xerrors.New( + "no default chat model config is available", + ) + } + return database.ChatModelConfig{}, xerrors.Errorf( + "get default chat model config: %w", err, + ) + } + return defaultConfig, nil +} + +//nolint:revive // Boolean controls SQL NULL validity. +func usageNullInt64(value int64, valid bool) sql.NullInt64 { + if !valid { + return sql.NullInt64{} + } + return sql.NullInt64{ + Int64: value, + Valid: valid, + } +} + +func refreshChatWorkspaceSnapshot( + ctx context.Context, + chat database.Chat, + loadChat func(context.Context, uuid.UUID) (database.Chat, error), +) (database.Chat, error) { + if chat.WorkspaceAgentID.Valid || loadChat == nil { + return chat, nil + } + + refreshedChat, err := loadChat(ctx, chat.ID) + if err != nil { + return chat, xerrors.Errorf("reload chat workspace state: %w", err) + } + + return refreshedChat, nil +} + +func (p *Server) appendHomeInstructionToPrompt( + ctx context.Context, + chat database.Chat, + prompt []fantasy.Message, + getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), +) []fantasy.Message { + if !chat.WorkspaceAgentID.Valid || getWorkspaceConn == nil { + return prompt + } + + instruction := p.resolveHomeInstruction(ctx, chat, getWorkspaceConn) + if instruction == "" { + return prompt + } + + return chatprompt.InsertSystem(prompt, instruction) +} + +// resolveHomeInstruction returns cached home instructions for the +// workspace agent, fetching them on cache miss or expiry. +func (p *Server) resolveHomeInstruction( + ctx context.Context, + chat database.Chat, + getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), +) string { + agentID := chat.WorkspaceAgentID.UUID + + p.instructionCacheMu.Lock() + cached, ok := p.instructionCache[agentID] + p.instructionCacheMu.Unlock() + + if ok && time.Since(cached.fetchedAt) < instructionCacheTTL { + return cached.instruction + } + + instructionCtx, cancel := context.WithTimeout(ctx, homeInstructionLookupTimeout) + defer cancel() + + conn, err := getWorkspaceConn(instructionCtx) + if err != nil { + p.logger.Debug(ctx, "failed to resolve workspace connection for home instruction file", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return cached.instruction + } + + content, sourcePath, truncated, err := readHomeInstructionFile(instructionCtx, conn) + if err != nil { + p.logger.Debug(ctx, "failed to load home instruction file", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return cached.instruction + } + + instruction := formatHomeInstruction(content, sourcePath, truncated) + + p.instructionCacheMu.Lock() + p.instructionCache[agentID] = cachedInstruction{ + instruction: instruction, + fetchedAt: time.Now(), + } + p.instructionCacheMu.Unlock() + + return instruction +} + +func (p *Server) recoverStaleChats(ctx context.Context) { + staleAfter := time.Now().Add(-p.inFlightChatStaleAfter) + staleChats, err := p.db.GetStaleChats(ctx, staleAfter) + if err != nil { + p.logger.Error(ctx, "failed to get stale chats", slog.Error(err)) + return + } + + for _, chat := range staleChats { + p.logger.Info(ctx, "recovering stale chat", slog.F("chat_id", chat.ID)) + + // Reset to pending so any replica can pick it up. + _, err := p.db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusPending, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if err != nil { + p.logger.Error(ctx, "failed to recover stale chat", + slog.F("chat_id", chat.ID), slog.Error(err)) + } + } + + if len(staleChats) > 0 { + p.logger.Info(ctx, "recovered stale chats", slog.F("count", len(staleChats))) + } +} + +// Close stops the processor and waits for it to finish. +func (p *Server) Close() error { + p.cancel() + <-p.closed + p.inflight.Wait() + return nil +} diff --git a/coderd/chatd/chatd_test.go b/coderd/chatd/chatd_test.go new file mode 100644 index 0000000000..8eb17db184 --- /dev/null +++ b/coderd/chatd/chatd_test.go @@ -0,0 +1,461 @@ +package chatd_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/chatd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replicaA := newTestServer(t, db, ps, uuid.New()) + replicaB := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replicaA.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "interrupt-me", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + runningWorker := uuid.New() + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: runningWorker, Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + _, events, cancel, ok := replicaB.Subscribe(ctx, chat.ID, nil) + require.True(t, ok) + t.Cleanup(cancel) + + updated := replicaA.InterruptChat(ctx, chat) + require.Equal(t, database.ChatStatusWaiting, updated.Status) + require.False(t, updated.WorkerID.Valid) + + require.Eventually(t, func() bool { + select { + case event := <-events: + if event.Type != codersdk.ChatStreamEventTypeStatus || event.Status == nil { + return false + } + return event.Status.Status == codersdk.ChatStatusWaiting + default: + return false + } + }, testutil.WaitMedium, testutil.IntervalFast) +} + +func TestInterruptChatClearsWorkerInDatabase(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "db-transition", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + updated := replica.InterruptChat(ctx, chat) + require.Equal(t, database.ChatStatusWaiting, updated.Status) + require.False(t, updated.WorkerID.Valid) + + fromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, fromDB.Status) + require.False(t, fromDB.WorkerID.Valid) +} + +func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "heartbeat-ownership", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + workerID := uuid.New() + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + rows, err := db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{ + ID: chat.ID, + WorkerID: uuid.New(), + }) + require.NoError(t, err) + require.Equal(t, int64(0), rows) + + rows, err = db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{ + ID: chat.ID, + WorkerID: workerID, + }) + require.NoError(t, err) + require.Equal(t, int64(1), rows) +} + +func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "queue-when-busy", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + workerID := uuid.New() + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []fantasy.Content{fantasy.TextContent{Text: "queued"}}, + BusyBehavior: chatd.SendMessageBusyBehaviorQueue, + }) + require.NoError(t, err) + require.True(t, result.Queued) + require.NotNil(t, result.QueuedMessage) + require.Equal(t, database.ChatStatusRunning, result.Chat.Status) + require.Equal(t, workerID, result.Chat.WorkerID.UUID) + require.True(t, result.Chat.WorkerID.Valid) + + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, queued, 1) + + messages, err := db.GetChatMessagesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, messages, 1) +} + +func TestSendMessageInterruptBehaviorSendsImmediatelyWhenBusy(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "interrupt-when-busy", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []fantasy.Content{fantasy.TextContent{Text: "interrupt"}}, + BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt, + }) + require.NoError(t, err) + require.False(t, result.Queued) + require.Equal(t, database.ChatStatusPending, result.Chat.Status) + require.False(t, result.Chat.WorkerID.Valid) + + fromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusPending, fromDB.Status) + require.False(t, fromDB.WorkerID.Valid) + + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, queued, 0) + + messages, err := db.GetChatMessagesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, messages, 2) + require.Equal(t, messages[len(messages)-1].ID, result.Message.ID) +} + +func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "edit-message", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "original"}}, + }) + require.NoError(t, err) + + initialMessages, err := db.GetChatMessagesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, initialMessages, 1) + editedMessageID := initialMessages[0].ID + + _, err = replica.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []fantasy.Content{fantasy.TextContent{Text: "follow-up"}}, + BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt, + }) + require.NoError(t, err) + _, err = replica.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []fantasy.Content{fantasy.TextContent{Text: "another"}}, + BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt, + }) + require.NoError(t, err) + + _, err = db.InsertChatQueuedMessage(ctx, database.InsertChatQueuedMessageParams{ + ChatID: chat.ID, + Content: json.RawMessage(`"queued"`), + }) + require.NoError(t, err) + + chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + editResult, err := replica.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + EditedMessageID: editedMessageID, + Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}}, + }) + require.NoError(t, err) + require.Equal(t, editedMessageID, editResult.Message.ID) + require.Equal(t, database.ChatStatusPending, editResult.Chat.Status) + require.False(t, editResult.Chat.WorkerID.Valid) + + editedSDK := db2sdk.ChatMessage(editResult.Message) + require.Len(t, editedSDK.Content, 1) + require.Equal(t, "edited", editedSDK.Content[0].Text) + + messages, err := db.GetChatMessagesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Equal(t, editedMessageID, messages[0].ID) + onlyMessage := db2sdk.ChatMessage(messages[0]) + require.Len(t, onlyMessage.Content, 1) + require.Equal(t, "edited", onlyMessage.Content[0].Text) + + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, queued, 0) + + chatFromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusPending, chatFromDB.Status) + require.False(t, chatFromDB.WorkerID.Valid) +} + +func TestEditMessageRejectsMissingMessage(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "missing-edited-message", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + _, err = replica.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + EditedMessageID: 999999, + Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}}, + }) + require.Error(t, err) + require.True(t, errors.Is(err, chatd.ErrEditedMessageNotFound)) +} + +func TestEditMessageRejectsNonUserMessage(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "non-user-edited-message", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + assistantMessage, err := db.InsertChatMessage(ctx, database.InsertChatMessageParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: "assistant", + Content: pqtype.NullRawMessage{ + RawMessage: json.RawMessage(`"assistant"`), + Valid: true, + }, + Visibility: database.ChatMessageVisibilityBoth, + InputTokens: sql.NullInt64{}, + OutputTokens: sql.NullInt64{}, + TotalTokens: sql.NullInt64{}, + ReasoningTokens: sql.NullInt64{}, + CacheCreationTokens: sql.NullInt64{}, + CacheReadTokens: sql.NullInt64{}, + ContextLimit: sql.NullInt64{}, + Compressed: sql.NullBool{}, + }) + require.NoError(t, err) + + _, err = replica.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + EditedMessageID: assistantMessage.ID, + Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}}, + }) + require.Error(t, err) + require.True(t, errors.Is(err, chatd.ErrEditedMessageNotUser)) +} + +func newTestServer( + t *testing.T, + db database.Store, + ps dbpubsub.Pubsub, + replicaID uuid.UUID, +) *chatd.Server { + t.Helper() + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: replicaID, + Pubsub: ps, + PendingChatAcquireInterval: testutil.WaitSuperLong, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + return server +} + +func seedChatDependencies( + ctx context.Context, + t *testing.T, + db database.Store, +) (database.User, database.ChatModelConfig) { + t.Helper() + + user := dbgen.User(t, db, database.User{}) + _, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + BaseUrl: "", + ApiKeyKeyID: sql.NullString{}, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + }) + require.NoError(t, err) + model, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "gpt-4o-mini", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 70, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + return user, model +} diff --git a/coderd/chatd/chatloop/chatloop.go b/coderd/chatd/chatloop/chatloop.go new file mode 100644 index 0000000000..7466e16326 --- /dev/null +++ b/coderd/chatd/chatloop/chatloop.go @@ -0,0 +1,676 @@ +package chatloop + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strconv" + "strings" + "sync" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/codersdk" +) + +const ( + interruptedToolResultErrorMessage = "tool call was interrupted before it produced a result" +) + +var ErrInterrupted = xerrors.New("chat interrupted") + +// PersistedStep contains the full content of a completed or +// interrupted agent step. Content includes both assistant blocks +// (text, reasoning, tool calls) and tool result blocks, mirroring +// what fantasy provides in StepResult.Content. The persistence +// layer is responsible for splitting these into separate database +// messages by role. +type PersistedStep struct { + Content []fantasy.Content + Usage fantasy.Usage + ContextLimit sql.NullInt64 +} + +// RunOptions configures a single streaming chat loop run. +type RunOptions struct { + Model fantasy.LanguageModel + Messages []fantasy.Message + Tools []fantasy.AgentTool + StreamCall fantasy.AgentStreamCall + MaxSteps int + + ActiveTools []string + ContextLimitFallback int64 + + PersistStep func(context.Context, PersistedStep) error + PublishMessagePart func( + role fantasy.MessageRole, + part codersdk.ChatMessagePart, + ) + Compaction *CompactionOptions + + OnInterruptedPersistError func(error) +} + +// Run executes the chat step-stream loop and delegates persistence/publishing to callbacks. +func Run(ctx context.Context, opts RunOptions) (*fantasy.AgentResult, error) { + if opts.Model == nil { + return nil, xerrors.New("chat model is required") + } + if opts.PersistStep == nil { + return nil, xerrors.New("persist step callback is required") + } + if opts.MaxSteps <= 0 { + opts.MaxSteps = 1 + } + + publishMessagePart := func(role fantasy.MessageRole, part codersdk.ChatMessagePart) { + if opts.PublishMessagePart == nil { + return + } + opts.PublishMessagePart(role, part) + } + + var ( + stepStateMu sync.Mutex + streamToolNames map[string]string + streamReasoningTitles map[string]string + streamReasoningText map[string]string + // stepToolResultContents tracks tool results received during + // streaming. These are needed for the interrupted-step path + // where OnStepFinish never fires. + stepToolResultContents []fantasy.ToolResultContent + stepAssistantDraft []fantasy.Content + stepToolCallIndexByID map[string]int + ) + + resetStepState := func() { + stepStateMu.Lock() + streamToolNames = make(map[string]string) + streamReasoningTitles = make(map[string]string) + streamReasoningText = make(map[string]string) + stepToolResultContents = nil + stepAssistantDraft = nil + stepToolCallIndexByID = make(map[string]int) + stepStateMu.Unlock() + } + + setReasoningTitleFromText := func(id string, text string) { + if id == "" || strings.TrimSpace(text) == "" { + return + } + + stepStateMu.Lock() + defer stepStateMu.Unlock() + + if streamReasoningTitles[id] != "" { + return + } + + streamReasoningText[id] += text + if !strings.ContainsAny(streamReasoningText[id], "\r\n") { + return + } + title := chatprompt.ReasoningTitleFromFirstLine(streamReasoningText[id]) + if title == "" { + return + } + + streamReasoningTitles[id] = title + } + + appendDraftText := func(text string) { + if text == "" { + return + } + + stepStateMu.Lock() + defer stepStateMu.Unlock() + + if len(stepAssistantDraft) > 0 { + lastIndex := len(stepAssistantDraft) - 1 + switch last := stepAssistantDraft[lastIndex].(type) { + case fantasy.TextContent: + last.Text += text + stepAssistantDraft[lastIndex] = last + return + case *fantasy.TextContent: + last.Text += text + stepAssistantDraft[lastIndex] = fantasy.TextContent{Text: last.Text} + return + } + } + stepAssistantDraft = append(stepAssistantDraft, fantasy.TextContent{Text: text}) + } + + appendDraftReasoning := func(text string) { + if text == "" { + return + } + + stepStateMu.Lock() + defer stepStateMu.Unlock() + + if len(stepAssistantDraft) > 0 { + lastIndex := len(stepAssistantDraft) - 1 + switch last := stepAssistantDraft[lastIndex].(type) { + case fantasy.ReasoningContent: + last.Text += text + stepAssistantDraft[lastIndex] = last + return + case *fantasy.ReasoningContent: + last.Text += text + stepAssistantDraft[lastIndex] = fantasy.ReasoningContent{Text: last.Text} + return + } + } + stepAssistantDraft = append(stepAssistantDraft, fantasy.ReasoningContent{Text: text}) + } + + upsertDraftToolCall := func(toolCallID, toolName, input string, appendInput bool) { + if toolCallID == "" { + return + } + + stepStateMu.Lock() + defer stepStateMu.Unlock() + + if strings.TrimSpace(toolName) != "" { + streamToolNames[toolCallID] = toolName + } + + index, exists := stepToolCallIndexByID[toolCallID] + if !exists { + stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft) + stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{ + ToolCallID: toolCallID, + ToolName: toolName, + Input: input, + }) + return + } + + if index < 0 || index >= len(stepAssistantDraft) { + stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft) + stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{ + ToolCallID: toolCallID, + ToolName: toolName, + Input: input, + }) + return + } + + existingCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](stepAssistantDraft[index]) + if !ok { + if ptrCall, ptrOK := fantasy.AsContentType[*fantasy.ToolCallContent](stepAssistantDraft[index]); ptrOK && ptrCall != nil { + existingCall = *ptrCall + ok = true + } + } + if !ok { + stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft) + stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{ + ToolCallID: toolCallID, + ToolName: toolName, + Input: input, + }) + return + } + + if strings.TrimSpace(toolName) != "" { + existingCall.ToolName = toolName + } + if appendInput { + existingCall.Input += input + } else if input != "" || existingCall.Input == "" { + existingCall.Input = input + } + stepAssistantDraft[index] = existingCall + } + + appendDraftSource := func(source fantasy.SourceContent) { + stepStateMu.Lock() + stepAssistantDraft = append(stepAssistantDraft, source) + stepStateMu.Unlock() + } + + persistInterruptedStep := func() error { + stepStateMu.Lock() + draft := append([]fantasy.Content(nil), stepAssistantDraft...) + toolResults := append([]fantasy.ToolResultContent(nil), stepToolResultContents...) + toolNameByCallID := make(map[string]string, len(streamToolNames)) + for id, name := range streamToolNames { + toolNameByCallID[id] = name + } + stepStateMu.Unlock() + + if len(draft) == 0 && len(toolResults) == 0 { + return nil + } + + // Track which tool calls already have results. + answeredToolCalls := make(map[string]struct{}, len(toolResults)) + for _, tr := range toolResults { + if tr.ToolCallID != "" { + answeredToolCalls[tr.ToolCallID] = struct{}{} + } + } + + // Build the combined content: draft + received tool results + // + synthetic interrupted results for unanswered tool calls. + content := make([]fantasy.Content, 0, len(draft)+len(toolResults)) + content = append(content, draft...) + for _, tr := range toolResults { + content = append(content, tr) + } + + for _, block := range draft { + toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block) + if !ok { + if ptrCall, ptrOK := fantasy.AsContentType[*fantasy.ToolCallContent](block); ptrOK && ptrCall != nil { + toolCall = *ptrCall + ok = true + } + } + if !ok || toolCall.ToolCallID == "" { + continue + } + if _, exists := answeredToolCalls[toolCall.ToolCallID]; exists { + continue + } + + toolName := strings.TrimSpace(toolCall.ToolName) + if toolName == "" { + toolName = strings.TrimSpace(toolNameByCallID[toolCall.ToolCallID]) + } + + content = append(content, fantasy.ToolResultContent{ + ToolCallID: toolCall.ToolCallID, + ToolName: toolName, + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(interruptedToolResultErrorMessage), + }, + }) + answeredToolCalls[toolCall.ToolCallID] = struct{}{} + } + + persistCtx := context.WithoutCancel(ctx) + return opts.PersistStep(persistCtx, PersistedStep{ + Content: content, + }) + } + + resetStepState() + + agent := fantasy.NewAgent( + opts.Model, + fantasy.WithTools(opts.Tools...), + fantasy.WithStopConditions(fantasy.StepCountIs(opts.MaxSteps)), + ) + applyAnthropicCaching := shouldApplyAnthropicPromptCaching(opts.Model) + // Fantasy's AgentStreamCall currently requires a non-empty Prompt and always + // appends it as a user message. chatd already supplies the full history in + // Messages, so we pass and then strip a sentinel user message in PrepareStep. + sentinelPrompt := "__chatd_agent_prompt_sentinel_" + uuid.NewString() + + streamCall := opts.StreamCall + streamCall.Prompt = sentinelPrompt + streamCall.Messages = opts.Messages + streamCall.PrepareStep = func( + stepCtx context.Context, + options fantasy.PrepareStepFunctionOptions, + ) (context.Context, fantasy.PrepareStepResult, error) { + return stepCtx, prepareStepResult( + options.Messages, + sentinelPrompt, + opts.ActiveTools, + applyAnthropicCaching, + ), nil + } + streamCall.OnStepStart = func(_ int) error { + resetStepState() + return nil + } + streamCall.OnTextDelta = func(_ string, text string) error { + appendDraftText(text) + publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: text, + }) + return nil + } + streamCall.OnReasoningDelta = func(id string, text string) error { + appendDraftReasoning(text) + setReasoningTitleFromText(id, text) + stepStateMu.Lock() + title := streamReasoningTitles[id] + stepStateMu.Unlock() + publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: text, + Title: title, + }) + return nil + } + streamCall.OnReasoningEnd = func(id string, _ fantasy.ReasoningContent) error { + stepStateMu.Lock() + if streamReasoningTitles[id] == "" { + // At the end of reasoning we have the full text, so we can + // safely evaluate first-line title format even if no newline + // ever arrived in deltas. + streamReasoningTitles[id] = chatprompt.ReasoningTitleFromFirstLine( + streamReasoningText[id], + ) + } + title := streamReasoningTitles[id] + stepStateMu.Unlock() + if title != "" { + // Publish a title-only reasoning part so clients can update the + // reasoning header when metadata arrives at the end of streaming. + publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Title: title, + }) + } + return nil + } + streamCall.OnToolInputStart = func(id, toolName string) error { + upsertDraftToolCall(id, toolName, "", false) + return nil + } + streamCall.OnToolInputDelta = func(id, delta string) error { + stepStateMu.Lock() + toolName := streamToolNames[id] + stepStateMu.Unlock() + upsertDraftToolCall(id, toolName, delta, true) + publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: id, + ToolName: toolName, + ArgsDelta: delta, + }) + return nil + } + streamCall.OnToolCall = func(toolCall fantasy.ToolCallContent) error { + upsertDraftToolCall(toolCall.ToolCallID, toolCall.ToolName, toolCall.Input, false) + publishMessagePart( + fantasy.MessageRoleAssistant, + chatprompt.PartFromContent(toolCall), + ) + return nil + } + streamCall.OnSource = func(source fantasy.SourceContent) error { + appendDraftSource(source) + publishMessagePart( + fantasy.MessageRoleAssistant, + chatprompt.PartFromContent(source), + ) + return nil + } + streamCall.OnToolResult = func(result fantasy.ToolResultContent) error { + publishMessagePart( + fantasy.MessageRoleTool, + chatprompt.PartFromContent(result), + ) + + stepStateMu.Lock() + if result.ToolCallID != "" && strings.TrimSpace(result.ToolName) != "" { + streamToolNames[result.ToolCallID] = result.ToolName + } + stepToolResultContents = append(stepToolResultContents, result) + stepStateMu.Unlock() + + return nil + } + streamCall.OnStepFinish = func(stepResult fantasy.StepResult) error { + contextLimit := extractContextLimit(stepResult.ProviderMetadata) + if !contextLimit.Valid && opts.ContextLimitFallback > 0 { + contextLimit = sql.NullInt64{ + Int64: opts.ContextLimitFallback, + Valid: true, + } + } + + return opts.PersistStep(ctx, PersistedStep{ + Content: stepResult.Content, + Usage: stepResult.Usage, + ContextLimit: contextLimit, + }) + } + + result, err := agent.Stream(ctx, streamCall) + if err != nil { + if errors.Is(err, context.Canceled) && + errors.Is(context.Cause(ctx), ErrInterrupted) { + if persistErr := persistInterruptedStep(); persistErr != nil { + if opts.OnInterruptedPersistError != nil { + opts.OnInterruptedPersistError(persistErr) + } + } + return nil, ErrInterrupted + } + return nil, xerrors.Errorf("stream response: %w", err) + } + if opts.Compaction != nil { + if err := maybeCompact(ctx, opts, result); err != nil { + if opts.Compaction.OnError != nil { + opts.Compaction.OnError(err) + } + } + } + + return result, nil +} + +//nolint:revive // Boolean controls Anthropic-specific caching behavior. +func prepareStepResult( + messages []fantasy.Message, + sentinel string, + activeTools []string, + anthropicCaching bool, +) fantasy.PrepareStepResult { + filtered := make([]fantasy.Message, 0, len(messages)) + removed := false + for _, message := range messages { + if !removed && + message.Role == fantasy.MessageRoleUser && + len(message.Content) == 1 { + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0]) + if ok && textPart.Text == sentinel { + removed = true + continue + } + } + filtered = append(filtered, message) + } + + result := fantasy.PrepareStepResult{ + Messages: filtered, + } + if anthropicCaching { + result.Messages = addAnthropicPromptCaching(result.Messages) + } + if len(activeTools) > 0 { + result.ActiveTools = append([]string(nil), activeTools...) + } + return result +} + +func shouldApplyAnthropicPromptCaching(model fantasy.LanguageModel) bool { + if model == nil { + return false + } + return model.Provider() == fantasyanthropic.Name +} + +func addAnthropicPromptCaching(messages []fantasy.Message) []fantasy.Message { + for i := range messages { + messages[i].ProviderOptions = nil + } + + providerOption := fantasy.ProviderOptions{ + fantasyanthropic.Name: &fantasyanthropic.ProviderCacheControlOptions{ + CacheControl: fantasyanthropic.CacheControl{Type: "ephemeral"}, + }, + } + + lastSystemRoleIdx := -1 + systemMessageUpdated := false + for i, msg := range messages { + if msg.Role == fantasy.MessageRoleSystem { + lastSystemRoleIdx = i + } else if !systemMessageUpdated && lastSystemRoleIdx >= 0 { + messages[lastSystemRoleIdx].ProviderOptions = providerOption + systemMessageUpdated = true + } + if i > len(messages)-3 { + messages[i].ProviderOptions = providerOption + } + } + + return messages +} + +func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 { + if len(metadata) == 0 { + return sql.NullInt64{} + } + + encoded, err := json.Marshal(metadata) + if err != nil || len(encoded) == 0 { + return sql.NullInt64{} + } + + var payload any + if err := json.Unmarshal(encoded, &payload); err != nil { + return sql.NullInt64{} + } + + limit, ok := findContextLimitValue(payload) + if !ok { + return sql.NullInt64{} + } + + return sql.NullInt64{ + Int64: limit, + Valid: true, + } +} + +func findContextLimitValue(value any) (int64, bool) { + var ( + limit int64 + found bool + ) + + collectContextLimitValues(value, func(candidate int64) { + if !found || candidate > limit { + limit = candidate + found = true + } + }) + + return limit, found +} + +func collectContextLimitValues(value any, onValue func(int64)) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if isContextLimitKey(key) { + if numeric, ok := numericContextLimitValue(child); ok { + onValue(numeric) + } + } + collectContextLimitValues(child, onValue) + } + case []any: + for _, child := range typed { + collectContextLimitValues(child, onValue) + } + } +} + +func isContextLimitKey(key string) bool { + normalized := normalizeMetadataKey(key) + if normalized == "" { + return false + } + + switch normalized { + case + "contextlimit", + "contextwindow", + "contextlength", + "maxcontext", + "maxcontexttokens", + "maxinputtokens", + "maxinputtoken", + "inputtokenlimit": + return true + } + + return strings.Contains(normalized, "context") && + (strings.Contains(normalized, "limit") || + strings.Contains(normalized, "window") || + strings.Contains(normalized, "length") || + strings.HasPrefix(normalized, "max")) +} + +func normalizeMetadataKey(key string) string { + var b strings.Builder + b.Grow(len(key)) + + for _, r := range key { + switch { + case r >= 'a' && r <= 'z': + _, _ = b.WriteRune(r) + case r >= 'A' && r <= 'Z': + _, _ = b.WriteRune(r + ('a' - 'A')) + case r >= '0' && r <= '9': + _, _ = b.WriteRune(r) + } + } + + return b.String() +} + +func numericContextLimitValue(value any) (int64, bool) { + switch typed := value.(type) { + case int64: + return positiveInt64(typed) + case int32: + return positiveInt64(int64(typed)) + case int: + return positiveInt64(int64(typed)) + case float64: + casted := int64(typed) + if typed > 0 && float64(casted) == typed { + return casted, true + } + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err == nil { + return positiveInt64(parsed) + } + case json.Number: + parsed, err := typed.Int64() + if err == nil { + return positiveInt64(parsed) + } + } + + return 0, false +} + +func positiveInt64(value int64) (int64, bool) { + if value <= 0 { + return 0, false + } + return value, true +} diff --git a/coderd/chatd/chatloop/chatloop_test.go b/coderd/chatd/chatloop/chatloop_test.go new file mode 100644 index 0000000000..c6ae4309e1 --- /dev/null +++ b/coderd/chatd/chatloop/chatloop_test.go @@ -0,0 +1,289 @@ +package chatloop //nolint:testpackage // Uses internal symbols. + +import ( + "context" + "iter" + "strings" + "testing" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +const activeToolName = "read_file" + +func TestRun_ActiveToolsPrepareBehavior(t *testing.T) { + t.Parallel() + + var capturedCall fantasy.Call + model := &loopTestModel{ + provider: fantasyanthropic.Name, + streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + capturedCall = call + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"}, + {Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"}, + {Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}, + }), nil + }, + } + + persistStepCalls := 0 + var persistedStep PersistedStep + + _, err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleSystem, "sys-1"), + textMessage(fantasy.MessageRoleSystem, "sys-2"), + textMessage(fantasy.MessageRoleUser, "hello"), + textMessage(fantasy.MessageRoleAssistant, "working"), + textMessage(fantasy.MessageRoleUser, "continue"), + }, + Tools: []fantasy.AgentTool{ + newNoopTool(activeToolName), + newNoopTool("write_file"), + }, + MaxSteps: 3, + ActiveTools: []string{activeToolName}, + ContextLimitFallback: 4096, + PersistStep: func(_ context.Context, step PersistedStep) error { + persistStepCalls++ + persistedStep = step + return nil + }, + }) + require.NoError(t, err) + + require.Equal(t, 1, persistStepCalls) + require.True(t, persistedStep.ContextLimit.Valid) + require.Equal(t, int64(4096), persistedStep.ContextLimit.Int64) + + require.NotEmpty(t, capturedCall.Prompt) + require.False(t, containsPromptSentinel(capturedCall.Prompt)) + require.Len(t, capturedCall.Tools, 1) + require.Equal(t, activeToolName, capturedCall.Tools[0].GetName()) + + require.Len(t, capturedCall.Prompt, 5) + require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[0])) + require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[1])) + require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[2])) + require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[3])) + require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[4])) +} + +func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) { + t.Parallel() + + started := make(chan struct{}) + model := &loopTestModel{ + provider: "fake", + streamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + parts := []fantasy.StreamPart{ + { + Type: fantasy.StreamPartTypeToolInputStart, + ID: "interrupt-tool-1", + ToolCallName: "read_file", + }, + { + Type: fantasy.StreamPartTypeToolInputDelta, + ID: "interrupt-tool-1", + ToolCallName: "read_file", + Delta: `{"path":"main.go"`, + }, + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "partial assistant output"}, + } + for _, part := range parts { + if !yield(part) { + return + } + } + + select { + case <-started: + default: + close(started) + } + + <-ctx.Done() + _ = yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: ctx.Err(), + }) + }), nil + }, + } + + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + + go func() { + <-started + cancel(ErrInterrupted) + }() + + persistedAssistantCtxErr := xerrors.New("unset") + var persistedContent []fantasy.Content + + _, err := Run(ctx, RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "hello"), + }, + Tools: []fantasy.AgentTool{ + newNoopTool("read_file"), + }, + MaxSteps: 3, + PersistStep: func(persistCtx context.Context, step PersistedStep) error { + persistedAssistantCtxErr = persistCtx.Err() + persistedContent = append([]fantasy.Content(nil), step.Content...) + return nil + }, + }) + require.ErrorIs(t, err, ErrInterrupted) + require.NoError(t, persistedAssistantCtxErr) + + require.NotEmpty(t, persistedContent) + var ( + foundText bool + foundToolCall bool + foundToolResult bool + ) + for _, block := range persistedContent { + if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok { + if strings.Contains(text.Text, "partial assistant output") { + foundText = true + } + continue + } + if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok { + if toolCall.ToolCallID == "interrupt-tool-1" && + toolCall.ToolName == "read_file" && + strings.Contains(toolCall.Input, `"path":"main.go"`) { + foundToolCall = true + } + continue + } + if toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok { + if toolResult.ToolCallID == "interrupt-tool-1" && + toolResult.ToolName == "read_file" { + _, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError) + require.True(t, isErr, "interrupted tool result should be an error") + foundToolResult = true + } + } + } + require.True(t, foundText) + require.True(t, foundToolCall) + require.True(t, foundToolResult) +} + +type loopTestModel struct { + provider string + model string + generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error) + streamFn func(context.Context, fantasy.Call) (fantasy.StreamResponse, error) +} + +func (m *loopTestModel) Provider() string { + if m.provider != "" { + return m.provider + } + return "fake" +} + +func (m *loopTestModel) Model() string { + if m.model != "" { + return m.model + } + return "fake" +} + +func (m *loopTestModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + if m.generateFn != nil { + return m.generateFn(ctx, call) + } + return &fantasy.Response{}, nil +} + +func (m *loopTestModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + if m.streamFn != nil { + return m.streamFn(ctx, call) + } + return streamFromParts([]fantasy.StreamPart{{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: fantasy.FinishReasonStop, + }}), nil +} + +func (*loopTestModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + return nil, xerrors.New("not implemented") +} + +func (*loopTestModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) { + return nil, xerrors.New("not implemented") +} + +func streamFromParts(parts []fantasy.StreamPart) fantasy.StreamResponse { + return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + for _, part := range parts { + if !yield(part) { + return + } + } + }) +} + +func newNoopTool(name string) fantasy.AgentTool { + return fantasy.NewAgentTool( + name, + "test noop tool", + func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) { + return fantasy.ToolResponse{}, nil + }, + ) +} + +func textMessage(role fantasy.MessageRole, text string) fantasy.Message { + return fantasy.Message{ + Role: role, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: text}, + }, + } +} + +func containsPromptSentinel(prompt []fantasy.Message) bool { + for _, message := range prompt { + if message.Role != fantasy.MessageRoleUser || len(message.Content) != 1 { + continue + } + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0]) + if !ok { + continue + } + if strings.HasPrefix(textPart.Text, "__chatd_agent_prompt_sentinel_") { + return true + } + } + return false +} + +func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool { + if len(message.ProviderOptions) == 0 { + return false + } + + options, ok := message.ProviderOptions[fantasyanthropic.Name] + if !ok { + return false + } + + cacheOptions, ok := options.(*fantasyanthropic.ProviderCacheControlOptions) + return ok && cacheOptions.CacheControl.Type == "ephemeral" +} diff --git a/coderd/chatd/chatloop/compaction.go b/coderd/chatd/chatloop/compaction.go new file mode 100644 index 0000000000..28ccbc2abc --- /dev/null +++ b/coderd/chatd/chatloop/compaction.go @@ -0,0 +1,209 @@ +package chatloop + +import ( + "context" + "strings" + "time" + + "charm.land/fantasy" + "golang.org/x/xerrors" +) + +const ( + defaultCompactionThresholdPercent = int32(70) + minCompactionThresholdPercent = int32(0) + maxCompactionThresholdPercent = int32(100) + + defaultCompactionSummaryPrompt = "Summarize the current chat so a " + + "new assistant can continue seamlessly. Include the user's goals, " + + "decisions made, concrete technical details (files, commands, APIs), " + + "errors encountered and fixes, and open questions. Be dense and factual. " + + "Omit pleasantries and next-step suggestions." + defaultCompactionSystemSummaryPrefix = "Summary of earlier chat context:" + defaultCompactionTimeout = 90 * time.Second +) + +type CompactionOptions struct { + ThresholdPercent int32 + ContextLimit int64 + SummaryPrompt string + SystemSummaryPrefix string + Timeout time.Duration + Persist func(context.Context, CompactionResult) error + OnError func(error) +} + +type CompactionResult struct { + SystemSummary string + SummaryReport string + ThresholdPercent int32 + UsagePercent float64 + ContextTokens int64 + ContextLimit int64 +} + +func maybeCompact( + ctx context.Context, + runOpts RunOptions, + runResult *fantasy.AgentResult, +) error { + if runResult == nil || runOpts.Compaction == nil { + return nil + } + + config := *runOpts.Compaction + if config.Persist == nil { + return xerrors.New("compaction persist callback is required") + } + if strings.TrimSpace(config.SummaryPrompt) == "" { + config.SummaryPrompt = defaultCompactionSummaryPrompt + } + if strings.TrimSpace(config.SystemSummaryPrefix) == "" { + config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix + } + if config.Timeout <= 0 { + config.Timeout = defaultCompactionTimeout + } + if config.ThresholdPercent < minCompactionThresholdPercent || + config.ThresholdPercent > maxCompactionThresholdPercent { + config.ThresholdPercent = defaultCompactionThresholdPercent + } + + if config.ThresholdPercent >= maxCompactionThresholdPercent { + return nil + } + if runOpts.MaxSteps > 0 && len(runResult.Steps) >= runOpts.MaxSteps { + lastStep := runResult.Steps[len(runResult.Steps)-1] + if lastStep.FinishReason == fantasy.FinishReasonToolCalls && + len(lastStep.Content.ToolCalls()) > 0 { + return nil + } + } + + contextTokens := int64(0) + contextLimitFromMetadata := int64(0) + for i := len(runResult.Steps) - 1; i >= 0; i-- { + usage := runResult.Steps[i].Usage + total := int64(0) + hasContextTokens := false + + if usage.InputTokens > 0 { + total += usage.InputTokens + hasContextTokens = true + } + if usage.CacheReadTokens > 0 { + total += usage.CacheReadTokens + hasContextTokens = true + } + if usage.CacheCreationTokens > 0 { + total += usage.CacheCreationTokens + hasContextTokens = true + } + if !hasContextTokens && usage.TotalTokens > 0 { + total = usage.TotalTokens + hasContextTokens = true + } + if !hasContextTokens || total <= 0 { + continue + } + + contextTokens = total + metadataLimit := extractContextLimit(runResult.Steps[i].ProviderMetadata) + if metadataLimit.Valid && metadataLimit.Int64 > 0 { + contextLimitFromMetadata = metadataLimit.Int64 + } + break + } + if contextTokens <= 0 { + return nil + } + + contextLimit := contextLimitFromMetadata + if contextLimit <= 0 && config.ContextLimit > 0 { + contextLimit = config.ContextLimit + } + if contextLimit <= 0 && runOpts.ContextLimitFallback > 0 { + contextLimit = runOpts.ContextLimitFallback + } + if contextLimit <= 0 { + return nil + } + + usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100 + if usagePercent < float64(config.ThresholdPercent) { + return nil + } + + summary, err := generateCompactionSummary( + ctx, + runOpts.Model, + runOpts.Messages, + runResult.Steps, + config, + ) + if err != nil { + return err + } + if summary == "" { + return nil + } + + systemSummary := strings.TrimSpace( + config.SystemSummaryPrefix + "\n\n" + summary, + ) + + return config.Persist(ctx, CompactionResult{ + SystemSummary: systemSummary, + SummaryReport: summary, + ThresholdPercent: config.ThresholdPercent, + UsagePercent: usagePercent, + ContextTokens: contextTokens, + ContextLimit: contextLimit, + }) +} + +func generateCompactionSummary( + ctx context.Context, + model fantasy.LanguageModel, + messages []fantasy.Message, + steps []fantasy.StepResult, + options CompactionOptions, +) (string, error) { + summaryPrompt := make([]fantasy.Message, 0, len(messages)+len(steps)+1) + summaryPrompt = append(summaryPrompt, messages...) + for _, step := range steps { + summaryPrompt = append(summaryPrompt, step.Messages...) + } + summaryPrompt = append(summaryPrompt, fantasy.Message{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: options.SummaryPrompt}, + }, + }) + toolChoice := fantasy.ToolChoiceNone + + summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout) + defer cancel() + + response, err := model.Generate(summaryCtx, fantasy.Call{ + Prompt: summaryPrompt, + ToolChoice: &toolChoice, + }) + if err != nil { + return "", xerrors.Errorf("generate summary text: %w", err) + } + + parts := make([]string, 0, len(response.Content)) + for _, block := range response.Content { + textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block) + if !ok { + continue + } + text := strings.TrimSpace(textBlock.Text) + if text == "" { + continue + } + parts = append(parts, text) + } + return strings.TrimSpace(strings.Join(parts, " ")), nil +} diff --git a/coderd/chatd/chatloop/compaction_test.go b/coderd/chatd/chatloop/compaction_test.go new file mode 100644 index 0000000000..152c543d72 --- /dev/null +++ b/coderd/chatd/chatloop/compaction_test.go @@ -0,0 +1,132 @@ +package chatloop //nolint:testpackage // Uses internal symbols. + +import ( + "context" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func TestRun_Compaction(t *testing.T) { + t.Parallel() + + t.Run("PersistsWhenThresholdReached", func(t *testing.T) { + t.Parallel() + + persistCompactionCalls := 0 + var persistedCompaction CompactionResult + const summaryText = "summary text for compaction" + + model := &loopTestModel{ + provider: "fake", + streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"}, + {Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"}, + { + Type: fantasy.StreamPartTypeFinish, + FinishReason: fantasy.FinishReasonStop, + Usage: fantasy.Usage{ + InputTokens: 80, + TotalTokens: 85, + }, + }, + }), nil + }, + generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) { + require.NotEmpty(t, call.Prompt) + lastPrompt := call.Prompt[len(call.Prompt)-1] + require.Equal(t, fantasy.MessageRoleUser, lastPrompt.Role) + require.Len(t, lastPrompt.Content, 1) + + instruction, ok := fantasy.AsMessagePart[fantasy.TextPart](lastPrompt.Content[0]) + require.True(t, ok) + require.Equal(t, "summarize now", instruction.Text) + + return &fantasy.Response{ + Content: []fantasy.Content{ + fantasy.TextContent{Text: summaryText}, + }, + }, nil + }, + } + + _, err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "hello"), + }, + MaxSteps: 1, + PersistStep: func(_ context.Context, _ PersistedStep) error { + return nil + }, + ContextLimitFallback: 100, + Compaction: &CompactionOptions{ + ThresholdPercent: 70, + SummaryPrompt: "summarize now", + Persist: func(_ context.Context, result CompactionResult) error { + persistCompactionCalls++ + persistedCompaction = result + return nil + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, persistCompactionCalls) + require.Contains(t, persistedCompaction.SystemSummary, summaryText) + require.Equal(t, summaryText, persistedCompaction.SummaryReport) + require.Equal(t, int64(80), persistedCompaction.ContextTokens) + require.Equal(t, int64(100), persistedCompaction.ContextLimit) + require.InDelta(t, 80.0, persistedCompaction.UsagePercent, 0.0001) + }) + + t.Run("ErrorsAreReported", func(t *testing.T) { + t.Parallel() + + model := &loopTestModel{ + provider: "fake", + streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + return streamFromParts([]fantasy.StreamPart{ + { + Type: fantasy.StreamPartTypeFinish, + FinishReason: fantasy.FinishReasonStop, + Usage: fantasy.Usage{ + InputTokens: 80, + }, + }, + }), nil + }, + generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) { + return nil, xerrors.New("generate failed") + }, + } + + compactionErr := xerrors.New("unset") + _, err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "hello"), + }, + MaxSteps: 1, + PersistStep: func(_ context.Context, _ PersistedStep) error { + return nil + }, + ContextLimitFallback: 100, + Compaction: &CompactionOptions{ + ThresholdPercent: 70, + Persist: func(_ context.Context, _ CompactionResult) error { + return nil + }, + OnError: func(err error) { + compactionErr = err + }, + }, + }) + require.NoError(t, err) + require.Error(t, compactionErr) + require.ErrorContains(t, compactionErr, "generate summary text") + }) +} diff --git a/coderd/chatd/chatprompt/chatprompt.go b/coderd/chatd/chatprompt/chatprompt.go new file mode 100644 index 0000000000..ff6ad99368 --- /dev/null +++ b/coderd/chatd/chatprompt/chatprompt.go @@ -0,0 +1,982 @@ +package chatprompt + +import ( + "encoding/json" + "regexp" + "strings" + + "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +var toolCallIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +func ConvertMessages( + messages []database.ChatMessage, +) ([]fantasy.Message, error) { + prompt := make([]fantasy.Message, 0, len(messages)) + toolNameByCallID := make(map[string]string) + for _, message := range messages { + visibility := message.Visibility + if visibility == "" { + visibility = database.ChatMessageVisibilityBoth + } + if visibility != database.ChatMessageVisibilityModel && + visibility != database.ChatMessageVisibilityBoth { + continue + } + + switch message.Role { + case string(fantasy.MessageRoleSystem): + content, err := parseSystemContent(message.Content) + if err != nil { + return nil, err + } + if strings.TrimSpace(content) == "" { + continue + } + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: content}, + }, + }) + case string(fantasy.MessageRoleUser): + content, err := ParseContent(string(fantasy.MessageRoleUser), message.Content) + if err != nil { + return nil, err + } + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleUser, + Content: ToMessageParts(content), + }) + case string(fantasy.MessageRoleAssistant): + content, err := ParseContent(string(fantasy.MessageRoleAssistant), message.Content) + if err != nil { + return nil, err + } + parts := normalizeAssistantToolCallInputs(ToMessageParts(content)) + for _, toolCall := range ExtractToolCalls(parts) { + if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" { + continue + } + toolNameByCallID[sanitizeToolCallID(toolCall.ToolCallID)] = toolCall.ToolName + } + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: parts, + }) + case string(fantasy.MessageRoleTool): + rows, err := parseToolResultRows(message.Content) + if err != nil { + return nil, err + } + parts := make([]fantasy.MessagePart, 0, len(rows)) + for _, row := range rows { + if row.ToolCallID != "" && row.ToolName != "" { + toolNameByCallID[sanitizeToolCallID(row.ToolCallID)] = row.ToolName + } + parts = append(parts, row.toToolResultPart()) + } + prompt = append(prompt, fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: parts, + }) + default: + return nil, xerrors.Errorf("unsupported chat message role %q", message.Role) + } + } + prompt = injectMissingToolResults(prompt) + prompt = injectMissingToolUses( + prompt, + toolNameByCallID, + ) + return prompt, nil +} + +// PrependSystem prepends a system message unless an existing system +// message already mentions create_workspace guidance. +func PrependSystem(prompt []fantasy.Message, instruction string) []fantasy.Message { + instruction = strings.TrimSpace(instruction) + if instruction == "" { + return prompt + } + for _, message := range prompt { + if message.Role != fantasy.MessageRoleSystem { + continue + } + for _, part := range message.Content { + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](part) + if !ok { + continue + } + if strings.Contains(strings.ToLower(textPart.Text), "create_workspace") { + return prompt + } + } + } + + out := make([]fantasy.Message, 0, len(prompt)+1) + out = append(out, fantasy.Message{ + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: instruction}, + }, + }) + out = append(out, prompt...) + return out +} + +// InsertSystem inserts a system message after the existing system +// block and before the first non-system message. +func InsertSystem(prompt []fantasy.Message, instruction string) []fantasy.Message { + instruction = strings.TrimSpace(instruction) + if instruction == "" { + return prompt + } + + systemMessage := fantasy.Message{ + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: instruction}, + }, + } + + out := make([]fantasy.Message, 0, len(prompt)+1) + inserted := false + for _, message := range prompt { + if !inserted && message.Role != fantasy.MessageRoleSystem { + out = append(out, systemMessage) + inserted = true + } + out = append(out, message) + } + if !inserted { + out = append(out, systemMessage) + } + return out +} + +// AppendUser appends an instruction as a user message at the end of +// the prompt. +func AppendUser(prompt []fantasy.Message, instruction string) []fantasy.Message { + instruction = strings.TrimSpace(instruction) + if instruction == "" { + return prompt + } + out := make([]fantasy.Message, 0, len(prompt)+1) + out = append(out, prompt...) + out = append(out, fantasy.Message{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: instruction}, + }, + }) + return out +} + +// ParseContent decodes persisted chat message content blocks. +func ParseContent(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return nil, nil + } + + var text string + if err := json.Unmarshal(raw.RawMessage, &text); err == nil { + return []fantasy.Content{fantasy.TextContent{Text: text}}, nil + } + + var rawBlocks []json.RawMessage + if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil { + return nil, xerrors.Errorf("parse %s content: %w", role, err) + } + + content := make([]fantasy.Content, 0, len(rawBlocks)) + for i, rawBlock := range rawBlocks { + block, err := fantasy.UnmarshalContent(rawBlock) + if err != nil { + return nil, xerrors.Errorf("parse %s content block %d: %w", role, i, err) + } + content = append(content, block) + } + return content, nil +} + +// toolResultRaw is an untyped representation of a persisted tool +// result row. We intentionally avoid a strict Go struct so that +// historical shapes are never rejected. +type toolResultRaw struct { + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Result json.RawMessage `json:"result"` + IsError bool `json:"is_error,omitempty"` +} + +// parseToolResultRows decodes persisted tool result rows. +func parseToolResultRows(raw pqtype.NullRawMessage) ([]toolResultRaw, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return nil, nil + } + + var rows []toolResultRaw + if err := json.Unmarshal(raw.RawMessage, &rows); err != nil { + return nil, xerrors.Errorf("parse tool content: %w", err) + } + return rows, nil +} + +func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart { + toolCallID := sanitizeToolCallID(r.ToolCallID) + resultText := string(r.Result) + if resultText == "" || resultText == "null" { + resultText = "{}" + } + + if r.IsError { + message := strings.TrimSpace(resultText) + if extracted := extractErrorString(r.Result); extracted != "" { + message = extracted + } + return fantasy.ToolResultPart{ + ToolCallID: toolCallID, + Output: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(message), + }, + } + } + + return fantasy.ToolResultPart{ + ToolCallID: toolCallID, + Output: fantasy.ToolResultOutputContentText{ + Text: resultText, + }, + } +} + +// extractErrorString pulls the "error" field from a JSON object if +// present, returning it as a string. Returns "" if the field is +// missing or the input is not an object. +func extractErrorString(raw json.RawMessage) string { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return "" + } + errField, ok := fields["error"] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(errField, &s); err != nil { + return "" + } + return strings.TrimSpace(s) +} + +// ToMessageParts converts fantasy content blocks into message parts. +func ToMessageParts(content []fantasy.Content) []fantasy.MessagePart { + parts := make([]fantasy.MessagePart, 0, len(content)) + for _, block := range content { + switch value := block.(type) { + case fantasy.TextContent: + parts = append(parts, fantasy.TextPart{ + Text: value.Text, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case *fantasy.TextContent: + parts = append(parts, fantasy.TextPart{ + Text: value.Text, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case fantasy.ReasoningContent: + parts = append(parts, fantasy.ReasoningPart{ + Text: value.Text, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case *fantasy.ReasoningContent: + parts = append(parts, fantasy.ReasoningPart{ + Text: value.Text, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case fantasy.ToolCallContent: + parts = append(parts, fantasy.ToolCallPart{ + ToolCallID: sanitizeToolCallID(value.ToolCallID), + ToolName: value.ToolName, + Input: value.Input, + ProviderExecuted: value.ProviderExecuted, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case *fantasy.ToolCallContent: + parts = append(parts, fantasy.ToolCallPart{ + ToolCallID: sanitizeToolCallID(value.ToolCallID), + ToolName: value.ToolName, + Input: value.Input, + ProviderExecuted: value.ProviderExecuted, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case fantasy.FileContent: + parts = append(parts, fantasy.FilePart{ + Data: value.Data, + MediaType: value.MediaType, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case *fantasy.FileContent: + parts = append(parts, fantasy.FilePart{ + Data: value.Data, + MediaType: value.MediaType, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case fantasy.ToolResultContent: + parts = append(parts, fantasy.ToolResultPart{ + ToolCallID: sanitizeToolCallID(value.ToolCallID), + Output: value.Result, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + case *fantasy.ToolResultContent: + parts = append(parts, fantasy.ToolResultPart{ + ToolCallID: sanitizeToolCallID(value.ToolCallID), + Output: value.Result, + ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata), + }) + } + } + return parts +} + +func normalizeAssistantToolCallInputs( + parts []fantasy.MessagePart, +) []fantasy.MessagePart { + normalized := make([]fantasy.MessagePart, 0, len(parts)) + for _, part := range parts { + toolCall, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part) + if !ok { + normalized = append(normalized, part) + continue + } + + toolCall.Input = normalizeToolCallInput(toolCall.Input) + normalized = append(normalized, toolCall) + } + return normalized +} + +// normalizeToolCallInput guarantees tool call input is a JSON object string. +// Anthropic drops assistant tool calls with malformed input, which can leave +// following tool results orphaned. +func normalizeToolCallInput(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "{}" + } + + var object map[string]any + if err := json.Unmarshal([]byte(input), &object); err != nil || object == nil { + return "{}" + } + + return input +} + +// ExtractToolCalls returns all tool call parts as content blocks. +func ExtractToolCalls(parts []fantasy.MessagePart) []fantasy.ToolCallContent { + toolCalls := make([]fantasy.ToolCallContent, 0, len(parts)) + for _, part := range parts { + toolCall, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part) + if !ok { + continue + } + toolCalls = append(toolCalls, fantasy.ToolCallContent{ + ToolCallID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + Input: toolCall.Input, + ProviderExecuted: toolCall.ProviderExecuted, + }) + } + return toolCalls +} + +// MarshalContent encodes message content blocks for persistence. +func MarshalContent(blocks []fantasy.Content) (pqtype.NullRawMessage, error) { + if len(blocks) == 0 { + return pqtype.NullRawMessage{}, nil + } + + encodedBlocks := make([]json.RawMessage, 0, len(blocks)) + for i, block := range blocks { + encoded, err := marshalContentBlock(block) + if err != nil { + return pqtype.NullRawMessage{}, xerrors.Errorf( + "encode content block %d: %w", + i, + err, + ) + } + encodedBlocks = append(encodedBlocks, encoded) + } + + data, err := json.Marshal(encodedBlocks) + if err != nil { + return pqtype.NullRawMessage{}, xerrors.Errorf("encode content blocks: %w", err) + } + return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil +} + +// MarshalToolResult encodes a single tool result for persistence as +// an opaque JSON blob. The stored shape is +// [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}]. +func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) (pqtype.NullRawMessage, error) { + row := toolResultRaw{ + ToolCallID: toolCallID, + ToolName: toolName, + Result: result, + IsError: isError, + } + data, err := json.Marshal([]toolResultRaw{row}) + if err != nil { + return pqtype.NullRawMessage{}, xerrors.Errorf("encode tool result: %w", err) + } + return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil +} + +// MarshalToolResultContent encodes a fantasy tool result content +// block for persistence. It extracts the raw fields and delegates +// to MarshalToolResult. +func MarshalToolResultContent(content fantasy.ToolResultContent) (pqtype.NullRawMessage, error) { + var result json.RawMessage + var isError bool + + switch output := content.Result.(type) { + case fantasy.ToolResultOutputContentError: + isError = true + if output.Error != nil { + result, _ = json.Marshal(map[string]any{"error": output.Error.Error()}) + } else { + result = []byte(`{"error":""}`) + } + case fantasy.ToolResultOutputContentText: + result = json.RawMessage(output.Text) + if !json.Valid(result) { + result, _ = json.Marshal(map[string]any{"output": output.Text}) + } + case fantasy.ToolResultOutputContentMedia: + result, _ = json.Marshal(map[string]any{ + "data": output.Data, + "mime_type": output.MediaType, + "text": output.Text, + }) + default: + result = []byte(`{}`) + } + + return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError) +} + +// PartFromContent converts fantasy content into a SDK chat message part. +func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart { + switch value := block.(type) { + case fantasy.TextContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: value.Text, + } + case *fantasy.TextContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: value.Text, + } + case fantasy.ReasoningContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: value.Text, + Title: reasoningSummaryTitle(value.ProviderMetadata), + } + case *fantasy.ReasoningContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: value.Text, + Title: reasoningSummaryTitle(value.ProviderMetadata), + } + case fantasy.ToolCallContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + } + case *fantasy.ToolCallContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + } + case fantasy.SourceContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSource, + SourceID: value.ID, + URL: value.URL, + Title: value.Title, + } + case *fantasy.SourceContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSource, + SourceID: value.ID, + URL: value.URL, + Title: value.Title, + } + case fantasy.FileContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeFile, + MediaType: value.MediaType, + Data: value.Data, + } + case *fantasy.FileContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeFile, + MediaType: value.MediaType, + Data: value.Data, + } + case fantasy.ToolResultContent: + return toolResultContentToPart(value) + case *fantasy.ToolResultContent: + return toolResultContentToPart(*value) + default: + return codersdk.ChatMessagePart{} + } +} + +// ToolResultToPart converts a tool call ID, raw result, and error +// flag into a ChatMessagePart. This is the minimal conversion used +// both during streaming and when reading from the database. +func ToolResultToPart(toolCallID, toolName string, result json.RawMessage, isError bool) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolResult, + ToolCallID: toolCallID, + ToolName: toolName, + Result: result, + IsError: isError, + } +} + +// toolResultContentToPart converts a fantasy ToolResultContent +// directly into a ChatMessagePart without an intermediate struct. +func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMessagePart { + var result json.RawMessage + var isError bool + + switch output := content.Result.(type) { + case fantasy.ToolResultOutputContentError: + isError = true + if output.Error != nil { + result, _ = json.Marshal(map[string]any{"error": output.Error.Error()}) + } else { + result = []byte(`{"error":""}`) + } + case fantasy.ToolResultOutputContentText: + result = json.RawMessage(output.Text) + // Ensure valid JSON; wrap in an object if not. + if !json.Valid(result) { + result, _ = json.Marshal(map[string]any{"output": output.Text}) + } + case fantasy.ToolResultOutputContentMedia: + result, _ = json.Marshal(map[string]any{ + "data": output.Data, + "mime_type": output.MediaType, + "text": output.Text, + }) + default: + result = []byte(`{}`) + } + + return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError) +} + +// ReasoningTitleFromFirstLine extracts a compact markdown title. +func ReasoningTitleFromFirstLine(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + + firstLine := text + if idx := strings.IndexAny(firstLine, "\r\n"); idx >= 0 { + firstLine = firstLine[:idx] + } + firstLine = strings.TrimSpace(firstLine) + if firstLine == "" || !strings.HasPrefix(firstLine, "**") { + return "" + } + + rest := firstLine[2:] + end := strings.Index(rest, "**") + if end < 0 { + return "" + } + + title := strings.TrimSpace(rest[:end]) + if title == "" { + return "" + } + + // Require the first line to be exactly "**title**" (ignoring + // surrounding whitespace) so providers without this format don't + // accidentally emit a title. + if strings.TrimSpace(rest[end+2:]) != "" { + return "" + } + + return compactReasoningSummaryTitle(title) +} + +func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message { + result := make([]fantasy.Message, 0, len(prompt)) + for i := 0; i < len(prompt); i++ { + msg := prompt[i] + result = append(result, msg) + + if msg.Role != fantasy.MessageRoleAssistant { + continue + } + toolCalls := ExtractToolCalls(msg.Content) + if len(toolCalls) == 0 { + continue + } + + // Collect the tool call IDs that have results in the + // following tool message(s). + answered := make(map[string]struct{}) + j := i + 1 + for ; j < len(prompt); j++ { + if prompt[j].Role != fantasy.MessageRoleTool { + break + } + for _, part := range prompt[j].Content { + tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) + if !ok { + continue + } + answered[tr.ToolCallID] = struct{}{} + } + } + if i+1 < j { + // Preserve persisted tool result ordering and inject any + // synthetic results after the existing contiguous tool messages. + result = append(result, prompt[i+1:j]...) + i = j - 1 + } + + // Build synthetic results for any unanswered tool calls. + var missing []fantasy.MessagePart + for _, tc := range toolCalls { + if _, ok := answered[tc.ToolCallID]; !ok { + missing = append(missing, fantasy.ToolResultPart{ + ToolCallID: tc.ToolCallID, + Output: fantasy.ToolResultOutputContentError{ + Error: xerrors.New("tool call was interrupted and did not receive a result"), + }, + }) + } + } + if len(missing) > 0 { + result = append(result, fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: missing, + }) + } + } + return result +} + +func injectMissingToolUses( + prompt []fantasy.Message, + toolNameByCallID map[string]string, +) []fantasy.Message { + result := make([]fantasy.Message, 0, len(prompt)) + for _, msg := range prompt { + if msg.Role != fantasy.MessageRoleTool { + result = append(result, msg) + continue + } + + toolResults := make([]fantasy.ToolResultPart, 0, len(msg.Content)) + for _, part := range msg.Content { + toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) + if !ok { + continue + } + toolResults = append(toolResults, toolResult) + } + if len(toolResults) == 0 { + result = append(result, msg) + continue + } + + // Walk backwards through the result to find the nearest + // preceding assistant message (skipping over other tool + // messages that belong to the same batch of results). + answeredByPrevious := make(map[string]struct{}) + for k := len(result) - 1; k >= 0; k-- { + if result[k].Role == fantasy.MessageRoleAssistant { + for _, toolCall := range ExtractToolCalls(result[k].Content) { + toolCallID := sanitizeToolCallID(toolCall.ToolCallID) + if toolCallID == "" { + continue + } + answeredByPrevious[toolCallID] = struct{}{} + } + break + } + if result[k].Role != fantasy.MessageRoleTool { + break + } + } + + matchingResults := make([]fantasy.ToolResultPart, 0, len(toolResults)) + orphanResults := make([]fantasy.ToolResultPart, 0, len(toolResults)) + for _, toolResult := range toolResults { + toolCallID := sanitizeToolCallID(toolResult.ToolCallID) + if _, ok := answeredByPrevious[toolCallID]; ok { + matchingResults = append(matchingResults, toolResult) + continue + } + orphanResults = append(orphanResults, toolResult) + } + + if len(orphanResults) == 0 { + result = append(result, msg) + continue + } + + syntheticToolUse := syntheticToolUseMessage( + orphanResults, + toolNameByCallID, + ) + if len(syntheticToolUse.Content) == 0 { + result = append(result, msg) + continue + } + + if len(matchingResults) > 0 { + result = append(result, toolMessageFromToolResultParts(matchingResults)) + } + result = append(result, syntheticToolUse) + result = append(result, toolMessageFromToolResultParts(orphanResults)) + } + + return result +} + +func toolMessageFromToolResultParts(results []fantasy.ToolResultPart) fantasy.Message { + parts := make([]fantasy.MessagePart, 0, len(results)) + for _, result := range results { + parts = append(parts, result) + } + return fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: parts, + } +} + +func syntheticToolUseMessage( + toolResults []fantasy.ToolResultPart, + toolNameByCallID map[string]string, +) fantasy.Message { + parts := make([]fantasy.MessagePart, 0, len(toolResults)) + seen := make(map[string]struct{}, len(toolResults)) + + for _, toolResult := range toolResults { + toolCallID := sanitizeToolCallID(toolResult.ToolCallID) + if toolCallID == "" { + continue + } + if _, ok := seen[toolCallID]; ok { + continue + } + + toolName := strings.TrimSpace(toolNameByCallID[toolCallID]) + if toolName == "" { + continue + } + + seen[toolCallID] = struct{}{} + parts = append(parts, fantasy.ToolCallPart{ + ToolCallID: toolCallID, + ToolName: toolName, + Input: "{}", + }) + } + + return fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: parts, + } +} + +func parseSystemContent(raw pqtype.NullRawMessage) (string, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return "", nil + } + + var content string + if err := json.Unmarshal(raw.RawMessage, &content); err != nil { + return "", xerrors.Errorf("parse system message content: %w", err) + } + return content, nil +} + +func sanitizeToolCallID(id string) string { + if id == "" { + return "" + } + return toolCallIDSanitizer.ReplaceAllString(id, "_") +} + +func marshalContentBlock(block fantasy.Content) (json.RawMessage, error) { + encoded, err := json.Marshal(block) + if err != nil { + return nil, err + } + + title, ok := reasoningTitleFromContent(block) + if !ok || title == "" { + return encoded, nil + } + + var envelope struct { + Type string `json:"type"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(encoded, &envelope); err != nil { + return nil, err + } + + if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) { + return encoded, nil + } + if envelope.Data == nil { + envelope.Data = map[string]any{} + } + envelope.Data["title"] = title + + encodedWithTitle, err := json.Marshal(envelope) + if err != nil { + return nil, err + } + return encodedWithTitle, nil +} + +func reasoningTitleFromContent(block fantasy.Content) (string, bool) { + switch value := block.(type) { + case fantasy.ReasoningContent: + return ReasoningTitleFromFirstLine(value.Text), true + case *fantasy.ReasoningContent: + if value == nil { + return "", false + } + return ReasoningTitleFromFirstLine(value.Text), true + default: + return "", false + } +} + +func reasoningSummaryTitle(metadata fantasy.ProviderMetadata) string { + if len(metadata) == 0 { + return "" + } + + reasoningMetadata := fantasyopenai.GetReasoningMetadata( + fantasy.ProviderOptions(metadata), + ) + if reasoningMetadata == nil { + return "" + } + + for _, summary := range reasoningMetadata.Summary { + if title := compactReasoningSummaryTitle(summary); title != "" { + return title + } + } + + return "" +} + +func compactReasoningSummaryTitle(summary string) string { + const maxWords = 8 + const maxRunes = 80 + + summary = strings.TrimSpace(summary) + if summary == "" { + return "" + } + + summary = strings.Trim(summary, "\"'`") + summary = reasoningSummaryHeadline(summary) + words := strings.Fields(summary) + if len(words) == 0 { + return "" + } + + truncated := false + if len(words) > maxWords { + words = words[:maxWords] + truncated = true + } + + title := strings.Join(words, " ") + if truncated { + title += "…" + } + return truncateRunes(title, maxRunes) +} + +func reasoningSummaryHeadline(summary string) string { + summary = strings.TrimSpace(summary) + if summary == "" { + return "" + } + + // OpenAI summary_text may be markdown like: + // "**Title**\n\nLonger explanation ...". + // Keep only the heading segment for UI titles. + if idx := strings.Index(summary, "\n\n"); idx >= 0 { + summary = summary[:idx] + } + + if idx := strings.IndexAny(summary, "\r\n"); idx >= 0 { + summary = summary[:idx] + } + + summary = strings.TrimSpace(summary) + if summary == "" { + return "" + } + + if strings.HasPrefix(summary, "**") { + rest := summary[2:] + if end := strings.Index(rest, "**"); end >= 0 { + bold := strings.TrimSpace(rest[:end]) + if bold != "" { + summary = bold + } + } + } + + return strings.TrimSpace(strings.Trim(summary, "\"'`")) +} + +func truncateRunes(value string, maxLen int) string { + if maxLen <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + + return string(runes[:maxLen]) +} diff --git a/coderd/chatd/chatprompt/chatprompt_test.go b/coderd/chatd/chatprompt/chatprompt_test.go new file mode 100644 index 0000000000..ba398446a1 --- /dev/null +++ b/coderd/chatd/chatprompt/chatprompt_test.go @@ -0,0 +1,91 @@ +package chatprompt_test + +import ( + "encoding/json" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/database" +) + +func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input string + expected string + }{ + { + name: "empty input", + input: "", + expected: "{}", + }, + { + name: "invalid json", + input: "{\"command\":", + expected: "{}", + }, + { + name: "non-object json", + input: "[]", + expected: "{}", + }, + { + name: "valid object json", + input: "{\"command\":\"ls\"}", + expected: "{\"command\":\"ls\"}", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assistantContent, err := chatprompt.MarshalContent([]fantasy.Content{ + fantasy.ToolCallContent{ + ToolCallID: "toolu_01C4PqN6F2493pi7Ebag8Vg7", + ToolName: "execute", + Input: tc.input, + }, + }) + require.NoError(t, err) + + toolContent, err := chatprompt.MarshalToolResult( + "toolu_01C4PqN6F2493pi7Ebag8Vg7", + "execute", + json.RawMessage(`{"error":"tool call was interrupted before it produced a result"}`), + true, + ) + require.NoError(t, err) + + prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{ + { + Role: string(fantasy.MessageRoleAssistant), + Visibility: database.ChatMessageVisibilityBoth, + Content: assistantContent, + }, + { + Role: string(fantasy.MessageRoleTool), + Visibility: database.ChatMessageVisibilityBoth, + Content: toolContent, + }, + }) + require.NoError(t, err) + require.Len(t, prompt, 2) + + require.Equal(t, fantasy.MessageRoleAssistant, prompt[0].Role) + toolCalls := chatprompt.ExtractToolCalls(prompt[0].Content) + require.Len(t, toolCalls, 1) + require.Equal(t, tc.expected, toolCalls[0].Input) + require.Equal(t, "execute", toolCalls[0].ToolName) + require.Equal(t, "toolu_01C4PqN6F2493pi7Ebag8Vg7", toolCalls[0].ToolCallID) + + require.Equal(t, fantasy.MessageRoleTool, prompt[1].Role) + }) + } +} diff --git a/coderd/chatd/chatprovider/chatprovider.go b/coderd/chatd/chatprovider/chatprovider.go new file mode 100644 index 0000000000..7e08d98049 --- /dev/null +++ b/coderd/chatd/chatprovider/chatprovider.go @@ -0,0 +1,1330 @@ +package chatprovider + +import ( + "context" + "sort" + "strings" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasyazure "charm.land/fantasy/providers/azure" + fantasybedrock "charm.land/fantasy/providers/bedrock" + fantasygoogle "charm.land/fantasy/providers/google" + fantasyopenai "charm.land/fantasy/providers/openai" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" + fantasyopenrouter "charm.land/fantasy/providers/openrouter" + fantasyvercel "charm.land/fantasy/providers/vercel" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +var supportedProviderNames = []string{ + fantasyanthropic.Name, + fantasyazure.Name, + fantasybedrock.Name, + fantasygoogle.Name, + fantasyopenai.Name, + fantasyopenaicompat.Name, + fantasyopenrouter.Name, + fantasyvercel.Name, +} + +var envPresetProviderNames = []string{ + fantasyopenai.Name, + fantasyanthropic.Name, +} + +var providerDisplayNameByName = map[string]string{ + fantasyanthropic.Name: "Anthropic", + fantasyazure.Name: "Azure OpenAI", + fantasybedrock.Name: "AWS Bedrock", + fantasygoogle.Name: "Google", + fantasyopenai.Name: "OpenAI", + fantasyopenaicompat.Name: "OpenAI Compatible", + fantasyopenrouter.Name: "OpenRouter", + fantasyvercel.Name: "Vercel AI Gateway", +} + +// SupportedProviders returns all chat providers supported by Fantasy. +func SupportedProviders() []string { + return append([]string(nil), supportedProviderNames...) +} + +// IsEnvPresetProvider reports whether provider supports env presets. +func IsEnvPresetProvider(provider string) bool { + normalized := NormalizeProvider(provider) + for _, candidate := range envPresetProviderNames { + if candidate == normalized { + return true + } + } + return false +} + +// ProviderDisplayName returns a default display name for a provider. +func ProviderDisplayName(provider string) string { + normalized := NormalizeProvider(provider) + if displayName, ok := providerDisplayNameByName[normalized]; ok { + return displayName + } + return normalized +} + +// ProviderAPIKeys contains API keys for provider calls. +type ProviderAPIKeys struct { + OpenAI string + Anthropic string + ByProvider map[string]string + BaseURLByProvider map[string]string +} + +// ConfiguredProvider is an enabled provider loaded from database config. +type ConfiguredProvider struct { + Provider string + APIKey string + BaseURL string +} + +// ConfiguredModel is an enabled model loaded from database config. +type ConfiguredModel struct { + Provider string + Model string + DisplayName string +} + +// APIKey returns the effective API key for a provider. +func (k ProviderAPIKeys) APIKey(provider string) string { + normalized := NormalizeProvider(provider) + if normalized == "" { + return "" + } + + if k.ByProvider != nil { + if key := strings.TrimSpace(k.ByProvider[normalized]); key != "" { + return key + } + } + + switch normalized { + case fantasyopenai.Name: + return strings.TrimSpace(k.OpenAI) + case fantasyanthropic.Name: + return strings.TrimSpace(k.Anthropic) + default: + return "" + } +} + +//nolint:revive // Intentional: apiKey is the unexported helper for APIKey. +func (k ProviderAPIKeys) apiKey(provider string) string { + return k.APIKey(provider) +} + +// BaseURL returns the configured base URL for a provider. +func (k ProviderAPIKeys) BaseURL(provider string) string { + normalized := NormalizeProvider(provider) + if normalized == "" || k.BaseURLByProvider == nil { + return "" + } + return strings.TrimSpace(k.BaseURLByProvider[normalized]) +} + +// MergeProviderAPIKeys overlays configured provider keys over fallback keys. +func MergeProviderAPIKeys(fallback ProviderAPIKeys, providers []ConfiguredProvider) ProviderAPIKeys { + merged := ProviderAPIKeys{ + OpenAI: strings.TrimSpace(fallback.OpenAI), + Anthropic: strings.TrimSpace(fallback.Anthropic), + ByProvider: map[string]string{}, + BaseURLByProvider: map[string]string{}, + } + for provider, apiKey := range fallback.ByProvider { + normalizedProvider := NormalizeProvider(provider) + if normalizedProvider == "" { + continue + } + if key := strings.TrimSpace(apiKey); key != "" { + merged.ByProvider[normalizedProvider] = key + } + } + for provider, baseURL := range fallback.BaseURLByProvider { + normalizedProvider := NormalizeProvider(provider) + if normalizedProvider == "" { + continue + } + if url := strings.TrimSpace(baseURL); url != "" { + merged.BaseURLByProvider[normalizedProvider] = url + } + } + + if merged.OpenAI != "" { + merged.ByProvider[fantasyopenai.Name] = merged.OpenAI + } + if merged.Anthropic != "" { + merged.ByProvider[fantasyanthropic.Name] = merged.Anthropic + } + + for _, provider := range providers { + normalizedProvider := NormalizeProvider(provider.Provider) + if normalizedProvider == "" { + continue + } + + if key := strings.TrimSpace(provider.APIKey); key != "" { + merged.ByProvider[normalizedProvider] = key + } + if url := strings.TrimSpace(provider.BaseURL); url != "" { + merged.BaseURLByProvider[normalizedProvider] = url + } + + switch normalizedProvider { + case fantasyopenai.Name: + if key := strings.TrimSpace(provider.APIKey); key != "" { + merged.OpenAI = key + } + case fantasyanthropic.Name: + if key := strings.TrimSpace(provider.APIKey); key != "" { + merged.Anthropic = key + } + } + } + + return merged +} + +type ModelCatalog struct { + keys ProviderAPIKeys +} + +func NewModelCatalog(keys ProviderAPIKeys) *ModelCatalog { + return &ModelCatalog{ + keys: keys, + } +} + +// ListConfiguredModels returns a model catalog from enabled DB-backed model +// configs. The second return value reports whether DB-backed models were used. +func (c *ModelCatalog) ListConfiguredModels( + configuredProviders []ConfiguredProvider, + configuredModels []ConfiguredModel, +) (codersdk.ChatModelsResponse, bool) { + if len(configuredModels) == 0 { + return codersdk.ChatModelsResponse{}, false + } + + modelsByProvider := make(map[string][]codersdk.ChatModel) + seenByProvider := make(map[string]map[string]struct{}) + providerSet := make(map[string]struct{}) + + for _, provider := range configuredProviders { + normalized := normalizeProvider(provider.Provider) + if normalized == "" { + continue + } + providerSet[normalized] = struct{}{} + } + + for _, model := range configuredModels { + provider, modelID, err := ResolveModelWithProviderHint(model.Model, model.Provider) + if err != nil { + continue + } + + providerSet[provider] = struct{}{} + if seenByProvider[provider] == nil { + seenByProvider[provider] = make(map[string]struct{}) + } + normalizedModelID := strings.ToLower(strings.TrimSpace(modelID)) + if _, ok := seenByProvider[provider][normalizedModelID]; ok { + continue + } + seenByProvider[provider][normalizedModelID] = struct{}{} + modelsByProvider[provider] = append( + modelsByProvider[provider], + newChatModel(provider, modelID, model.DisplayName), + ) + } + + providers := orderProviders(providerSet) + if len(providers) == 0 { + return codersdk.ChatModelsResponse{}, false + } + + keys := MergeProviderAPIKeys(c.keys, configuredProviders) + response := codersdk.ChatModelsResponse{ + Providers: make([]codersdk.ChatModelProvider, 0, len(providers)), + } + for _, provider := range providers { + models := modelsByProvider[provider] + sortChatModels(models) + + result := codersdk.ChatModelProvider{ + Provider: provider, + Models: models, + } + if keys.apiKey(provider) == "" { + result.Available = false + result.UnavailableReason = codersdk.ChatModelProviderUnavailableMissingAPIKey + } else { + result.Available = true + } + + response.Providers = append(response.Providers, result) + } + + return response, true +} + +// ListConfiguredProviderAvailability returns provider availability derived from +// deployment/env keys merged with enabled DB provider keys. +func (c *ModelCatalog) ListConfiguredProviderAvailability( + configuredProviders []ConfiguredProvider, +) codersdk.ChatModelsResponse { + keys := MergeProviderAPIKeys(c.keys, configuredProviders) + response := codersdk.ChatModelsResponse{ + Providers: make([]codersdk.ChatModelProvider, 0, len(supportedProviderNames)), + } + + for _, provider := range supportedProviderNames { + result := codersdk.ChatModelProvider{ + Provider: provider, + Models: []codersdk.ChatModel{}, + } + if keys.apiKey(provider) == "" { + result.Available = false + result.UnavailableReason = codersdk.ChatModelProviderUnavailableMissingAPIKey + } else { + result.Available = true + } + + response.Providers = append(response.Providers, result) + } + + return response +} + +func newChatModel(provider, modelID, displayName string) codersdk.ChatModel { + name := strings.TrimSpace(displayName) + if name == "" { + name = modelID + } + + return codersdk.ChatModel{ + ID: canonicalModelID(provider, modelID), + Provider: provider, + Model: modelID, + DisplayName: name, + } +} + +func sortChatModels(models []codersdk.ChatModel) { + sort.Slice(models, func(i, j int) bool { + return models[i].Model < models[j].Model + }) +} + +func canonicalModelID(provider, modelID string) string { + return NormalizeProvider(provider) + ":" + strings.TrimSpace(modelID) +} + +func orderProviders(providerSet map[string]struct{}) []string { + if len(providerSet) == 0 { + return nil + } + + ordered := make([]string, 0, len(providerSet)) + for _, provider := range supportedProviderNames { + if _, ok := providerSet[provider]; ok { + ordered = append(ordered, provider) + } + } + + // Unknown providers are dropped. The providerSet keys are + // already normalized, so any provider not in + // supportedProviderNames is silently excluded. + return ordered +} + +// NormalizeProvider canonicalizes a provider name. +func NormalizeProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case fantasyanthropic.Name: + return fantasyanthropic.Name + case fantasyazure.Name: + return fantasyazure.Name + case fantasybedrock.Name: + return fantasybedrock.Name + case fantasygoogle.Name: + return fantasygoogle.Name + case fantasyopenai.Name: + return fantasyopenai.Name + case fantasyopenaicompat.Name: + return fantasyopenaicompat.Name + case fantasyopenrouter.Name: + return fantasyopenrouter.Name + case fantasyvercel.Name: + return fantasyvercel.Name + default: + return "" + } +} + +//nolint:revive // Intentional: normalizeProvider is the unexported helper for NormalizeProvider. +func normalizeProvider(provider string) string { + return NormalizeProvider(provider) +} + +func ResolveModelWithProviderHint(modelName, providerHint string) (provider string, model string, err error) { + modelName = strings.TrimSpace(modelName) + if modelName == "" { + return "", "", xerrors.New("model is required") + } + + if provider, modelID, ok := parseCanonicalModelRef(modelName); ok { + return provider, modelID, nil + } + + if provider := normalizeProvider(providerHint); provider != "" { + return provider, modelName, nil + } + + normalized := strings.ToLower(modelName) + switch normalized { + case "claude-opus-4-6": + return fantasyanthropic.Name, "claude-opus-4-6", nil + case "gpt-5.2": + return fantasyopenai.Name, "gpt-5.2", nil + case "gemini-2.5-flash": + return fantasygoogle.Name, "gemini-2.5-flash", nil + } + + if isChatModelForProvider(fantasyanthropic.Name, normalized) { + return fantasyanthropic.Name, modelName, nil + } + if isChatModelForProvider(fantasyopenai.Name, normalized) { + return fantasyopenai.Name, modelName, nil + } + + return "", "", xerrors.Errorf("unknown model %q", modelName) +} + +func parseCanonicalModelRef(modelRef string) (provider string, model string, ok bool) { + modelRef = strings.TrimSpace(modelRef) + if modelRef == "" { + return "", "", false + } + + for _, separator := range []string{":", "/"} { + parts := strings.SplitN(modelRef, separator, 2) + if len(parts) != 2 { + continue + } + + provider := normalizeProvider(parts[0]) + modelID := strings.TrimSpace(parts[1]) + if provider != "" && modelID != "" { + return provider, modelID, true + } + } + + return "", "", false +} + +func isChatModelForProvider(provider, modelID string) bool { + normalizedProvider := normalizeProvider(provider) + normalizedModel := strings.ToLower(strings.TrimSpace(modelID)) + switch normalizedProvider { + case fantasyopenai.Name: + return strings.HasPrefix(normalizedModel, "gpt-") || + strings.HasPrefix(normalizedModel, "chatgpt-") || + isOpenAIReasoningModel(normalizedModel) + case fantasyanthropic.Name: + return strings.HasPrefix(normalizedModel, "claude-") + case fantasygoogle.Name: + return strings.HasPrefix(normalizedModel, "gemini-") || + strings.HasPrefix(normalizedModel, "gemma-") + default: + return false + } +} + +func isOpenAIReasoningModel(modelID string) bool { + if len(modelID) < 2 || modelID[0] != 'o' { + return false + } + + index := 1 + for index < len(modelID) && modelID[index] >= '0' && modelID[index] <= '9' { + index++ + } + if index == 1 { + return false + } + + if index == len(modelID) { + return true + } + return modelID[index] == '-' || modelID[index] == '.' +} + +// ReasoningEffortFromChat normalizes chat-config reasoning effort values for a +// provider and returns the canonical provider effort value. +func ReasoningEffortFromChat(provider string, value *string) *string { + if value == nil { + return nil + } + + normalized := strings.ToLower(strings.TrimSpace(*value)) + if normalized == "" { + return nil + } + + switch NormalizeProvider(provider) { + case fantasyopenai.Name: + return normalizedEnumValue( + normalized, + string(fantasyopenai.ReasoningEffortMinimal), + string(fantasyopenai.ReasoningEffortLow), + string(fantasyopenai.ReasoningEffortMedium), + string(fantasyopenai.ReasoningEffortHigh), + ) + case fantasyanthropic.Name: + return normalizedEnumValue( + normalized, + string(fantasyanthropic.EffortLow), + string(fantasyanthropic.EffortMedium), + string(fantasyanthropic.EffortHigh), + string(fantasyanthropic.EffortMax), + ) + case fantasyopenrouter.Name: + return normalizedEnumValue( + normalized, + string(fantasyopenrouter.ReasoningEffortLow), + string(fantasyopenrouter.ReasoningEffortMedium), + string(fantasyopenrouter.ReasoningEffortHigh), + ) + case fantasyvercel.Name: + return normalizedEnumValue( + normalized, + string(fantasyvercel.ReasoningEffortNone), + string(fantasyvercel.ReasoningEffortMinimal), + string(fantasyvercel.ReasoningEffortLow), + string(fantasyvercel.ReasoningEffortMedium), + string(fantasyvercel.ReasoningEffortHigh), + string(fantasyvercel.ReasoningEffortXHigh), + ) + default: + return nil + } +} + +// OpenAITextVerbosityFromChat normalizes chat-config text verbosity values for +// OpenAI and returns the canonical provider verbosity value. +func OpenAITextVerbosityFromChat(value *string) *fantasyopenai.TextVerbosity { + if value == nil { + return nil + } + + normalized := strings.ToLower(strings.TrimSpace(*value)) + if normalized == "" { + return nil + } + + verbosity := normalizedEnumValue( + normalized, + string(fantasyopenai.TextVerbosityLow), + string(fantasyopenai.TextVerbosityMedium), + string(fantasyopenai.TextVerbosityHigh), + ) + if verbosity == nil { + return nil + } + valueCopy := fantasyopenai.TextVerbosity(*verbosity) + return &valueCopy +} + +func normalizedEnumValue(value string, allowed ...string) *string { + for _, candidate := range allowed { + if value == strings.ToLower(candidate) { + match := candidate + return &match + } + } + return nil +} + +// MergeMissingCallConfig fills unset call config values from defaults. +func MergeMissingCallConfig( + dst *codersdk.ChatModelCallConfig, + defaults codersdk.ChatModelCallConfig, +) { + if dst.MaxOutputTokens == nil { + dst.MaxOutputTokens = defaults.MaxOutputTokens + } + if dst.Temperature == nil { + dst.Temperature = defaults.Temperature + } + if dst.TopP == nil { + dst.TopP = defaults.TopP + } + if dst.TopK == nil { + dst.TopK = defaults.TopK + } + if dst.PresencePenalty == nil { + dst.PresencePenalty = defaults.PresencePenalty + } + if dst.FrequencyPenalty == nil { + dst.FrequencyPenalty = defaults.FrequencyPenalty + } + MergeMissingProviderOptions(&dst.ProviderOptions, defaults.ProviderOptions) +} + +// MergeMissingProviderOptions fills unset provider option fields from defaults. +func MergeMissingProviderOptions( + dst **codersdk.ChatModelProviderOptions, + defaults *codersdk.ChatModelProviderOptions, +) { + if defaults == nil { + return + } + if *dst == nil { + copied := *defaults + *dst = &copied + return + } + + current := *dst + for _, provider := range []string{ + fantasyopenai.Name, + fantasyanthropic.Name, + fantasygoogle.Name, + fantasyopenaicompat.Name, + fantasyopenrouter.Name, + fantasyvercel.Name, + } { + switch provider { + case fantasyopenai.Name: + if defaults.OpenAI == nil { + continue + } + if current.OpenAI == nil { + copied := *defaults.OpenAI + current.OpenAI = &copied + continue + } + dstOpenAI := current.OpenAI + defaultOpenAI := defaults.OpenAI + if dstOpenAI.Include == nil { + dstOpenAI.Include = defaultOpenAI.Include + } + if dstOpenAI.Instructions == nil { + dstOpenAI.Instructions = defaultOpenAI.Instructions + } + if dstOpenAI.LogitBias == nil { + dstOpenAI.LogitBias = defaultOpenAI.LogitBias + } + if dstOpenAI.LogProbs == nil { + dstOpenAI.LogProbs = defaultOpenAI.LogProbs + } + if dstOpenAI.TopLogProbs == nil { + dstOpenAI.TopLogProbs = defaultOpenAI.TopLogProbs + } + if dstOpenAI.MaxToolCalls == nil { + dstOpenAI.MaxToolCalls = defaultOpenAI.MaxToolCalls + } + if dstOpenAI.ParallelToolCalls == nil { + dstOpenAI.ParallelToolCalls = defaultOpenAI.ParallelToolCalls + } + if dstOpenAI.User == nil { + dstOpenAI.User = defaultOpenAI.User + } + if dstOpenAI.ReasoningEffort == nil { + dstOpenAI.ReasoningEffort = defaultOpenAI.ReasoningEffort + } + if dstOpenAI.ReasoningSummary == nil { + dstOpenAI.ReasoningSummary = defaultOpenAI.ReasoningSummary + } + if dstOpenAI.MaxCompletionTokens == nil { + dstOpenAI.MaxCompletionTokens = defaultOpenAI.MaxCompletionTokens + } + if dstOpenAI.TextVerbosity == nil { + dstOpenAI.TextVerbosity = defaultOpenAI.TextVerbosity + } + if dstOpenAI.Prediction == nil { + dstOpenAI.Prediction = defaultOpenAI.Prediction + } + if dstOpenAI.Store == nil { + dstOpenAI.Store = defaultOpenAI.Store + } + if dstOpenAI.Metadata == nil { + dstOpenAI.Metadata = defaultOpenAI.Metadata + } + if dstOpenAI.PromptCacheKey == nil { + dstOpenAI.PromptCacheKey = defaultOpenAI.PromptCacheKey + } + if dstOpenAI.SafetyIdentifier == nil { + dstOpenAI.SafetyIdentifier = defaultOpenAI.SafetyIdentifier + } + if dstOpenAI.ServiceTier == nil { + dstOpenAI.ServiceTier = defaultOpenAI.ServiceTier + } + if dstOpenAI.StructuredOutputs == nil { + dstOpenAI.StructuredOutputs = defaultOpenAI.StructuredOutputs + } + if dstOpenAI.StrictJSONSchema == nil { + dstOpenAI.StrictJSONSchema = defaultOpenAI.StrictJSONSchema + } + + case fantasyanthropic.Name: + if defaults.Anthropic == nil { + continue + } + if current.Anthropic == nil { + copied := *defaults.Anthropic + current.Anthropic = &copied + continue + } + dstAnthropic := current.Anthropic + defaultAnthropic := defaults.Anthropic + if dstAnthropic.SendReasoning == nil { + dstAnthropic.SendReasoning = defaultAnthropic.SendReasoning + } + if dstAnthropic.Thinking == nil { + dstAnthropic.Thinking = defaultAnthropic.Thinking + } else if defaultAnthropic.Thinking != nil && + dstAnthropic.Thinking.BudgetTokens == nil { + dstAnthropic.Thinking.BudgetTokens = defaultAnthropic.Thinking.BudgetTokens + } + if dstAnthropic.Effort == nil { + dstAnthropic.Effort = defaultAnthropic.Effort + } + if dstAnthropic.DisableParallelToolUse == nil { + dstAnthropic.DisableParallelToolUse = defaultAnthropic.DisableParallelToolUse + } + + case fantasygoogle.Name: + if defaults.Google == nil { + continue + } + if current.Google == nil { + copied := *defaults.Google + current.Google = &copied + continue + } + dstGoogle := current.Google + defaultGoogle := defaults.Google + if dstGoogle.ThinkingConfig == nil { + dstGoogle.ThinkingConfig = defaultGoogle.ThinkingConfig + } else if defaultGoogle.ThinkingConfig != nil { + if dstGoogle.ThinkingConfig.ThinkingBudget == nil { + dstGoogle.ThinkingConfig.ThinkingBudget = defaultGoogle.ThinkingConfig.ThinkingBudget + } + if dstGoogle.ThinkingConfig.IncludeThoughts == nil { + dstGoogle.ThinkingConfig.IncludeThoughts = defaultGoogle.ThinkingConfig.IncludeThoughts + } + } + if strings.TrimSpace(dstGoogle.CachedContent) == "" { + dstGoogle.CachedContent = defaultGoogle.CachedContent + } + if dstGoogle.SafetySettings == nil { + dstGoogle.SafetySettings = defaultGoogle.SafetySettings + } + if strings.TrimSpace(dstGoogle.Threshold) == "" { + dstGoogle.Threshold = defaultGoogle.Threshold + } + + case fantasyopenaicompat.Name: + if defaults.OpenAICompat == nil { + continue + } + if current.OpenAICompat == nil { + copied := *defaults.OpenAICompat + current.OpenAICompat = &copied + continue + } + dstCompat := current.OpenAICompat + defaultCompat := defaults.OpenAICompat + if dstCompat.User == nil { + dstCompat.User = defaultCompat.User + } + if dstCompat.ReasoningEffort == nil { + dstCompat.ReasoningEffort = defaultCompat.ReasoningEffort + } + + case fantasyopenrouter.Name: + if defaults.OpenRouter == nil { + continue + } + if current.OpenRouter == nil { + copied := *defaults.OpenRouter + current.OpenRouter = &copied + continue + } + dstRouter := current.OpenRouter + defaultRouter := defaults.OpenRouter + if dstRouter.Reasoning == nil { + dstRouter.Reasoning = defaultRouter.Reasoning + } else if defaultRouter.Reasoning != nil { + if dstRouter.Reasoning.Enabled == nil { + dstRouter.Reasoning.Enabled = defaultRouter.Reasoning.Enabled + } + if dstRouter.Reasoning.Exclude == nil { + dstRouter.Reasoning.Exclude = defaultRouter.Reasoning.Exclude + } + if dstRouter.Reasoning.MaxTokens == nil { + dstRouter.Reasoning.MaxTokens = defaultRouter.Reasoning.MaxTokens + } + if dstRouter.Reasoning.Effort == nil { + dstRouter.Reasoning.Effort = defaultRouter.Reasoning.Effort + } + } + if dstRouter.ExtraBody == nil { + dstRouter.ExtraBody = defaultRouter.ExtraBody + } + if dstRouter.IncludeUsage == nil { + dstRouter.IncludeUsage = defaultRouter.IncludeUsage + } + if dstRouter.LogitBias == nil { + dstRouter.LogitBias = defaultRouter.LogitBias + } + if dstRouter.LogProbs == nil { + dstRouter.LogProbs = defaultRouter.LogProbs + } + if dstRouter.ParallelToolCalls == nil { + dstRouter.ParallelToolCalls = defaultRouter.ParallelToolCalls + } + if dstRouter.User == nil { + dstRouter.User = defaultRouter.User + } + if dstRouter.Provider == nil { + dstRouter.Provider = defaultRouter.Provider + } else if defaultRouter.Provider != nil { + if dstRouter.Provider.Order == nil { + dstRouter.Provider.Order = defaultRouter.Provider.Order + } + if dstRouter.Provider.AllowFallbacks == nil { + dstRouter.Provider.AllowFallbacks = defaultRouter.Provider.AllowFallbacks + } + if dstRouter.Provider.RequireParameters == nil { + dstRouter.Provider.RequireParameters = defaultRouter.Provider.RequireParameters + } + if dstRouter.Provider.DataCollection == nil { + dstRouter.Provider.DataCollection = defaultRouter.Provider.DataCollection + } + if dstRouter.Provider.Only == nil { + dstRouter.Provider.Only = defaultRouter.Provider.Only + } + if dstRouter.Provider.Ignore == nil { + dstRouter.Provider.Ignore = defaultRouter.Provider.Ignore + } + if dstRouter.Provider.Quantizations == nil { + dstRouter.Provider.Quantizations = defaultRouter.Provider.Quantizations + } + if dstRouter.Provider.Sort == nil { + dstRouter.Provider.Sort = defaultRouter.Provider.Sort + } + } + + case fantasyvercel.Name: + if defaults.Vercel == nil { + continue + } + if current.Vercel == nil { + copied := *defaults.Vercel + current.Vercel = &copied + continue + } + dstVercel := current.Vercel + defaultVercel := defaults.Vercel + if dstVercel.Reasoning == nil { + dstVercel.Reasoning = defaultVercel.Reasoning + } else if defaultVercel.Reasoning != nil { + if dstVercel.Reasoning.Enabled == nil { + dstVercel.Reasoning.Enabled = defaultVercel.Reasoning.Enabled + } + if dstVercel.Reasoning.MaxTokens == nil { + dstVercel.Reasoning.MaxTokens = defaultVercel.Reasoning.MaxTokens + } + if dstVercel.Reasoning.Effort == nil { + dstVercel.Reasoning.Effort = defaultVercel.Reasoning.Effort + } + if dstVercel.Reasoning.Exclude == nil { + dstVercel.Reasoning.Exclude = defaultVercel.Reasoning.Exclude + } + } + if dstVercel.ProviderOptions == nil { + dstVercel.ProviderOptions = defaultVercel.ProviderOptions + } else if defaultVercel.ProviderOptions != nil { + if dstVercel.ProviderOptions.Order == nil { + dstVercel.ProviderOptions.Order = defaultVercel.ProviderOptions.Order + } + if dstVercel.ProviderOptions.Models == nil { + dstVercel.ProviderOptions.Models = defaultVercel.ProviderOptions.Models + } + } + if dstVercel.User == nil { + dstVercel.User = defaultVercel.User + } + if dstVercel.LogitBias == nil { + dstVercel.LogitBias = defaultVercel.LogitBias + } + if dstVercel.LogProbs == nil { + dstVercel.LogProbs = defaultVercel.LogProbs + } + if dstVercel.TopLogProbs == nil { + dstVercel.TopLogProbs = defaultVercel.TopLogProbs + } + if dstVercel.ParallelToolCalls == nil { + dstVercel.ParallelToolCalls = defaultVercel.ParallelToolCalls + } + if dstVercel.ExtraBody == nil { + dstVercel.ExtraBody = defaultVercel.ExtraBody + } + } + } +} + +// ModelFromConfig resolves a provider/model pair and constructs a fantasy +// language model client using the provided provider credentials. +func ModelFromConfig( + providerHint string, + modelName string, + providerKeys ProviderAPIKeys, +) (fantasy.LanguageModel, error) { + provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint) + if err != nil { + return nil, err + } + + apiKey := providerKeys.APIKey(provider) + if apiKey == "" { + return nil, missingProviderAPIKeyError(provider) + } + baseURL := providerKeys.BaseURL(provider) + + var providerClient fantasy.Provider + switch provider { + case fantasyanthropic.Name: + options := []fantasyanthropic.Option{ + fantasyanthropic.WithAPIKey(apiKey), + } + if baseURL != "" { + options = append(options, fantasyanthropic.WithBaseURL(baseURL)) + } + providerClient, err = fantasyanthropic.New(options...) + case fantasyazure.Name: + if baseURL == "" { + return nil, xerrors.New("AZURE_OPENAI_BASE_URL is not set") + } + providerClient, err = fantasyazure.New( + fantasyazure.WithAPIKey(apiKey), + fantasyazure.WithBaseURL(baseURL), + fantasyazure.WithUseResponsesAPI(), + ) + case fantasybedrock.Name: + providerClient, err = fantasybedrock.New(fantasybedrock.WithAPIKey(apiKey)) + case fantasygoogle.Name: + options := []fantasygoogle.Option{ + fantasygoogle.WithGeminiAPIKey(apiKey), + } + if baseURL != "" { + options = append(options, fantasygoogle.WithBaseURL(baseURL)) + } + providerClient, err = fantasygoogle.New(options...) + case fantasyopenai.Name: + options := []fantasyopenai.Option{ + fantasyopenai.WithAPIKey(apiKey), + fantasyopenai.WithUseResponsesAPI(), + } + if baseURL != "" { + options = append(options, fantasyopenai.WithBaseURL(baseURL)) + } + providerClient, err = fantasyopenai.New(options...) + case fantasyopenaicompat.Name: + options := []fantasyopenaicompat.Option{ + fantasyopenaicompat.WithAPIKey(apiKey), + } + if baseURL != "" { + options = append(options, fantasyopenaicompat.WithBaseURL(baseURL)) + } + providerClient, err = fantasyopenaicompat.New(options...) + case fantasyopenrouter.Name: + providerClient, err = fantasyopenrouter.New(fantasyopenrouter.WithAPIKey(apiKey)) + case fantasyvercel.Name: + options := []fantasyvercel.Option{ + fantasyvercel.WithAPIKey(apiKey), + } + if baseURL != "" { + options = append(options, fantasyvercel.WithBaseURL(baseURL)) + } + providerClient, err = fantasyvercel.New(options...) + default: + return nil, xerrors.Errorf("unsupported model provider %q", provider) + } + if err != nil { + return nil, xerrors.Errorf("create %s provider: %w", provider, err) + } + + model, err := providerClient.LanguageModel(context.Background(), modelID) + if err != nil { + return nil, xerrors.Errorf("load %s model: %w", provider, err) + } + return model, nil +} + +func missingProviderAPIKeyError(provider string) error { + switch provider { + case fantasyanthropic.Name: + return xerrors.New("ANTHROPIC_API_KEY is not set") + case fantasyazure.Name: + return xerrors.New("AZURE_OPENAI_API_KEY is not set") + case fantasybedrock.Name: + return xerrors.New("BEDROCK_API_KEY is not set") + case fantasygoogle.Name: + return xerrors.New("GOOGLE_API_KEY is not set") + case fantasyopenai.Name: + return xerrors.New("OPENAI_API_KEY is not set") + case fantasyopenaicompat.Name: + return xerrors.New("OPENAI_COMPAT_API_KEY is not set") + case fantasyopenrouter.Name: + return xerrors.New("OPENROUTER_API_KEY is not set") + case fantasyvercel.Name: + return xerrors.New("VERCEL_API_KEY is not set") + default: + return xerrors.Errorf("API key for provider %q is not set", provider) + } +} + +// ProviderOptionsFromChatModelConfig converts chat model provider options to +// fantasy provider options used for inference calls. +func ProviderOptionsFromChatModelConfig( + model fantasy.LanguageModel, + options *codersdk.ChatModelProviderOptions, +) fantasy.ProviderOptions { + if options == nil { + return nil + } + + result := fantasy.ProviderOptions{} + + if options.OpenAI != nil { + result[fantasyopenai.Name] = openAIProviderOptionsFromChatConfig( + model, + options.OpenAI, + ) + } + if options.Anthropic != nil { + result[fantasyanthropic.Name] = anthropicProviderOptionsFromChatConfig( + options.Anthropic, + ) + } + if options.Google != nil { + result[fantasygoogle.Name] = googleProviderOptionsFromChatConfig( + options.Google, + ) + } + if options.OpenAICompat != nil { + result[fantasyopenaicompat.Name] = openAICompatProviderOptionsFromChatConfig( + options.OpenAICompat, + ) + } + if options.OpenRouter != nil { + result[fantasyopenrouter.Name] = openRouterProviderOptionsFromChatConfig( + options.OpenRouter, + ) + } + if options.Vercel != nil { + result[fantasyvercel.Name] = vercelProviderOptionsFromChatConfig( + options.Vercel, + ) + } + + if len(result) == 0 { + return nil + } + return result +} + +func openAIProviderOptionsFromChatConfig( + model fantasy.LanguageModel, + options *codersdk.ChatModelOpenAIProviderOptions, +) fantasy.ProviderOptionsData { + reasoningEffort := openAIReasoningEffortFromChat(options.ReasoningEffort) + if useOpenAIResponsesOptions(model) { + include := ensureOpenAIResponseIncludes(openAIIncludeFromChat(options.Include)) + providerOptions := &fantasyopenai.ResponsesProviderOptions{ + Include: include, + Instructions: normalizedStringPointer(options.Instructions), + Logprobs: openAIResponsesLogProbsFromChat(options), + MaxToolCalls: options.MaxToolCalls, + Metadata: options.Metadata, + ParallelToolCalls: options.ParallelToolCalls, + PromptCacheKey: normalizedStringPointer(options.PromptCacheKey), + ReasoningEffort: reasoningEffort, + ReasoningSummary: normalizedStringPointer(options.ReasoningSummary), + SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier), + ServiceTier: openAIServiceTierFromChat(options.ServiceTier), + StrictJSONSchema: options.StrictJSONSchema, + TextVerbosity: OpenAITextVerbosityFromChat(options.TextVerbosity), + User: normalizedStringPointer(options.User), + } + return providerOptions + } + + return &fantasyopenai.ProviderOptions{ + LogitBias: options.LogitBias, + LogProbs: options.LogProbs, + TopLogProbs: options.TopLogProbs, + ParallelToolCalls: options.ParallelToolCalls, + User: normalizedStringPointer(options.User), + ReasoningEffort: reasoningEffort, + MaxCompletionTokens: options.MaxCompletionTokens, + TextVerbosity: normalizedStringPointer(options.TextVerbosity), + Prediction: options.Prediction, + Store: options.Store, + Metadata: options.Metadata, + PromptCacheKey: normalizedStringPointer(options.PromptCacheKey), + SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier), + ServiceTier: normalizedStringPointer(options.ServiceTier), + StructuredOutputs: options.StructuredOutputs, + } +} + +func anthropicProviderOptionsFromChatConfig( + options *codersdk.ChatModelAnthropicProviderOptions, +) *fantasyanthropic.ProviderOptions { + result := &fantasyanthropic.ProviderOptions{ + SendReasoning: options.SendReasoning, + Effort: anthropicEffortFromChat(options.Effort), + DisableParallelToolUse: options.DisableParallelToolUse, + } + if options.Thinking != nil && options.Thinking.BudgetTokens != nil { + result.Thinking = &fantasyanthropic.ThinkingProviderOption{ + BudgetTokens: *options.Thinking.BudgetTokens, + } + } + return result +} + +func googleProviderOptionsFromChatConfig( + options *codersdk.ChatModelGoogleProviderOptions, +) *fantasygoogle.ProviderOptions { + result := &fantasygoogle.ProviderOptions{ + CachedContent: strings.TrimSpace(options.CachedContent), + Threshold: strings.TrimSpace(options.Threshold), + } + if options.ThinkingConfig != nil { + result.ThinkingConfig = &fantasygoogle.ThinkingConfig{ + ThinkingBudget: options.ThinkingConfig.ThinkingBudget, + IncludeThoughts: options.ThinkingConfig.IncludeThoughts, + } + } + if options.SafetySettings != nil { + result.SafetySettings = make( + []fantasygoogle.SafetySetting, + 0, + len(options.SafetySettings), + ) + for _, setting := range options.SafetySettings { + result.SafetySettings = append(result.SafetySettings, fantasygoogle.SafetySetting{ + Category: strings.TrimSpace(setting.Category), + Threshold: strings.TrimSpace(setting.Threshold), + }) + } + } + return result +} + +func openAICompatProviderOptionsFromChatConfig( + options *codersdk.ChatModelOpenAICompatProviderOptions, +) *fantasyopenaicompat.ProviderOptions { + return &fantasyopenaicompat.ProviderOptions{ + User: normalizedStringPointer(options.User), + ReasoningEffort: openAIReasoningEffortFromChat(options.ReasoningEffort), + } +} + +func openRouterProviderOptionsFromChatConfig( + options *codersdk.ChatModelOpenRouterProviderOptions, +) *fantasyopenrouter.ProviderOptions { + result := &fantasyopenrouter.ProviderOptions{ + ExtraBody: options.ExtraBody, + IncludeUsage: options.IncludeUsage, + LogitBias: options.LogitBias, + LogProbs: options.LogProbs, + ParallelToolCalls: options.ParallelToolCalls, + User: normalizedStringPointer(options.User), + } + if options.Reasoning != nil { + result.Reasoning = &fantasyopenrouter.ReasoningOptions{ + Enabled: options.Reasoning.Enabled, + Exclude: options.Reasoning.Exclude, + MaxTokens: options.Reasoning.MaxTokens, + Effort: openRouterReasoningEffortFromChat(options.Reasoning.Effort), + } + } + if options.Provider != nil { + result.Provider = &fantasyopenrouter.Provider{ + Order: options.Provider.Order, + AllowFallbacks: options.Provider.AllowFallbacks, + RequireParameters: options.Provider.RequireParameters, + DataCollection: normalizedStringPointer(options.Provider.DataCollection), + Only: options.Provider.Only, + Ignore: options.Provider.Ignore, + Quantizations: options.Provider.Quantizations, + Sort: normalizedStringPointer(options.Provider.Sort), + } + } + return result +} + +func vercelProviderOptionsFromChatConfig( + options *codersdk.ChatModelVercelProviderOptions, +) *fantasyvercel.ProviderOptions { + result := &fantasyvercel.ProviderOptions{ + User: normalizedStringPointer(options.User), + LogitBias: options.LogitBias, + LogProbs: options.LogProbs, + TopLogProbs: options.TopLogProbs, + ParallelToolCalls: options.ParallelToolCalls, + ExtraBody: options.ExtraBody, + } + if options.Reasoning != nil { + result.Reasoning = &fantasyvercel.ReasoningOptions{ + Enabled: options.Reasoning.Enabled, + MaxTokens: options.Reasoning.MaxTokens, + Effort: vercelReasoningEffortFromChat(options.Reasoning.Effort), + Exclude: options.Reasoning.Exclude, + } + } + if options.ProviderOptions != nil { + result.ProviderOptions = &fantasyvercel.GatewayProviderOptions{ + Order: options.ProviderOptions.Order, + Models: options.ProviderOptions.Models, + } + } + return result +} + +func openAIResponsesLogProbsFromChat( + options *codersdk.ChatModelOpenAIProviderOptions, +) any { + if options.TopLogProbs != nil { + return *options.TopLogProbs + } + if options.LogProbs != nil { + return *options.LogProbs + } + return nil +} + +func openAIIncludeFromChat(values []string) []fantasyopenai.IncludeType { + if values == nil { + return nil + } + + result := make([]fantasyopenai.IncludeType, 0, len(values)) + for _, value := range values { + switch strings.TrimSpace(value) { + case string(fantasyopenai.IncludeReasoningEncryptedContent): + result = append(result, fantasyopenai.IncludeReasoningEncryptedContent) + case string(fantasyopenai.IncludeFileSearchCallResults): + result = append(result, fantasyopenai.IncludeFileSearchCallResults) + case string(fantasyopenai.IncludeMessageOutputTextLogprobs): + result = append(result, fantasyopenai.IncludeMessageOutputTextLogprobs) + } + } + return result +} + +func ensureOpenAIResponseIncludes( + values []fantasyopenai.IncludeType, +) []fantasyopenai.IncludeType { + const required = fantasyopenai.IncludeReasoningEncryptedContent + + for _, value := range values { + if value == required { + return values + } + } + return append(values, required) +} + +func useOpenAIResponsesOptions(model fantasy.LanguageModel) bool { + if model == nil { + return false + } + switch model.Provider() { + case fantasyopenai.Name, fantasyazure.Name: + return fantasyopenai.IsResponsesModel(model.Model()) + default: + return false + } +} + +func normalizedStringPointer(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func openAIReasoningEffortFromChat(value *string) *fantasyopenai.ReasoningEffort { + effort := ReasoningEffortFromChat(fantasyopenai.Name, value) + if effort == nil { + return nil + } + valueCopy := fantasyopenai.ReasoningEffort(*effort) + return &valueCopy +} + +func anthropicEffortFromChat(value *string) *fantasyanthropic.Effort { + effort := ReasoningEffortFromChat(fantasyanthropic.Name, value) + if effort == nil { + return nil + } + valueCopy := fantasyanthropic.Effort(*effort) + return &valueCopy +} + +func openRouterReasoningEffortFromChat(value *string) *fantasyopenrouter.ReasoningEffort { + effort := ReasoningEffortFromChat(fantasyopenrouter.Name, value) + if effort == nil { + return nil + } + valueCopy := fantasyopenrouter.ReasoningEffort(*effort) + return &valueCopy +} + +func vercelReasoningEffortFromChat(value *string) *fantasyvercel.ReasoningEffort { + effort := ReasoningEffortFromChat(fantasyvercel.Name, value) + if effort == nil { + return nil + } + valueCopy := fantasyvercel.ReasoningEffort(*effort) + return &valueCopy +} + +func openAIServiceTierFromChat(value *string) *fantasyopenai.ServiceTier { + normalized := normalizedStringPointer(value) + if normalized == nil { + return nil + } + switch strings.ToLower(*normalized) { + case string(fantasyopenai.ServiceTierAuto): + serviceTier := fantasyopenai.ServiceTierAuto + return &serviceTier + case string(fantasyopenai.ServiceTierFlex): + serviceTier := fantasyopenai.ServiceTierFlex + return &serviceTier + case string(fantasyopenai.ServiceTierPriority): + serviceTier := fantasyopenai.ServiceTierPriority + return &serviceTier + default: + return nil + } +} diff --git a/coderd/chatd/chatprovider/chatprovider_test.go b/coderd/chatd/chatprovider/chatprovider_test.go new file mode 100644 index 0000000000..277aead26c --- /dev/null +++ b/coderd/chatd/chatprovider/chatprovider_test.go @@ -0,0 +1,191 @@ +package chatprovider_test + +import ( + "testing" + + fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasyopenai "charm.land/fantasy/providers/openai" + fantasyopenrouter "charm.land/fantasy/providers/openrouter" + fantasyvercel "charm.land/fantasy/providers/vercel" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/chatd/chatprovider" + "github.com/coder/coder/v2/codersdk" +) + +func TestReasoningEffortFromChat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider string + input *string + want *string + }{ + { + name: "OpenAICaseInsensitive", + provider: "openai", + input: stringPtr(" HIGH "), + want: stringPtr(string(fantasyopenai.ReasoningEffortHigh)), + }, + { + name: "AnthropicEffort", + provider: "anthropic", + input: stringPtr("max"), + want: stringPtr(string(fantasyanthropic.EffortMax)), + }, + { + name: "OpenRouterEffort", + provider: "openrouter", + input: stringPtr("medium"), + want: stringPtr(string(fantasyopenrouter.ReasoningEffortMedium)), + }, + { + name: "VercelEffort", + provider: "vercel", + input: stringPtr("xhigh"), + want: stringPtr(string(fantasyvercel.ReasoningEffortXHigh)), + }, + { + name: "InvalidEffortReturnsNil", + provider: "openai", + input: stringPtr("unknown"), + want: nil, + }, + { + name: "UnsupportedProviderReturnsNil", + provider: "bedrock", + input: stringPtr("high"), + want: nil, + }, + { + name: "NilInputReturnsNil", + provider: "openai", + input: nil, + want: nil, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := chatprovider.ReasoningEffortFromChat(tt.provider, tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) { + t.Parallel() + + options := &codersdk.ChatModelProviderOptions{ + OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{ + Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{ + Enabled: boolPtr(true), + }, + Provider: &codersdk.ChatModelOpenRouterProvider{ + Order: []string{"openai"}, + }, + }, + } + defaults := &codersdk.ChatModelProviderOptions{ + OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{ + Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{ + Enabled: boolPtr(false), + Exclude: boolPtr(true), + MaxTokens: int64Ptr(123), + Effort: stringPtr("high"), + }, + IncludeUsage: boolPtr(true), + Provider: &codersdk.ChatModelOpenRouterProvider{ + Order: []string{"anthropic"}, + AllowFallbacks: boolPtr(true), + RequireParameters: boolPtr(false), + DataCollection: stringPtr("allow"), + Only: []string{"openai"}, + Ignore: []string{"foo"}, + Quantizations: []string{"int8"}, + Sort: stringPtr("latency"), + }, + }, + } + + chatprovider.MergeMissingProviderOptions(&options, defaults) + + require.NotNil(t, options) + require.NotNil(t, options.OpenRouter) + require.NotNil(t, options.OpenRouter.Reasoning) + require.True(t, *options.OpenRouter.Reasoning.Enabled) + require.Equal(t, true, *options.OpenRouter.Reasoning.Exclude) + require.EqualValues(t, 123, *options.OpenRouter.Reasoning.MaxTokens) + require.Equal(t, "high", *options.OpenRouter.Reasoning.Effort) + require.NotNil(t, options.OpenRouter.IncludeUsage) + require.True(t, *options.OpenRouter.IncludeUsage) + + require.NotNil(t, options.OpenRouter.Provider) + require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Order) + require.NotNil(t, options.OpenRouter.Provider.AllowFallbacks) + require.True(t, *options.OpenRouter.Provider.AllowFallbacks) + require.NotNil(t, options.OpenRouter.Provider.RequireParameters) + require.False(t, *options.OpenRouter.Provider.RequireParameters) + require.Equal(t, "allow", *options.OpenRouter.Provider.DataCollection) + require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Only) + require.Equal(t, []string{"foo"}, options.OpenRouter.Provider.Ignore) + require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations) + require.Equal(t, "latency", *options.OpenRouter.Provider.Sort) +} + +func TestMergeMissingCallConfig_FillsUnsetFields(t *testing.T) { + t.Parallel() + + dst := codersdk.ChatModelCallConfig{ + Temperature: float64Ptr(0.2), + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ + User: stringPtr("alice"), + }, + }, + } + defaults := codersdk.ChatModelCallConfig{ + MaxOutputTokens: int64Ptr(512), + Temperature: float64Ptr(0.9), + TopP: float64Ptr(0.8), + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ + User: stringPtr("bob"), + ReasoningEffort: stringPtr("medium"), + }, + }, + } + + chatprovider.MergeMissingCallConfig(&dst, defaults) + + require.NotNil(t, dst.MaxOutputTokens) + require.EqualValues(t, 512, *dst.MaxOutputTokens) + require.NotNil(t, dst.Temperature) + require.Equal(t, 0.2, *dst.Temperature) + require.NotNil(t, dst.TopP) + require.Equal(t, 0.8, *dst.TopP) + require.NotNil(t, dst.ProviderOptions) + require.NotNil(t, dst.ProviderOptions.OpenAI) + require.Equal(t, "alice", *dst.ProviderOptions.OpenAI.User) + require.Equal(t, "medium", *dst.ProviderOptions.OpenAI.ReasoningEffort) +} + +func stringPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} + +func int64Ptr(value int64) *int64 { + return &value +} + +func float64Ptr(value float64) *float64 { + return &value +} diff --git a/coderd/chatd/chattest/anthropic.go b/coderd/chatd/chattest/anthropic.go new file mode 100644 index 0000000000..b8ef601cfd --- /dev/null +++ b/coderd/chatd/chattest/anthropic.go @@ -0,0 +1,403 @@ +package chattest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/google/uuid" +) + +// AnthropicHandler handles Anthropic API requests and returns a response. +type AnthropicHandler func(req *AnthropicRequest) AnthropicResponse + +// AnthropicResponse represents a response to an Anthropic request. +// Either StreamingChunks or Response should be set, not both. +type AnthropicResponse struct { + StreamingChunks <-chan AnthropicChunk + Response *AnthropicMessage +} + +// AnthropicRequest represents an Anthropic messages request. +type AnthropicRequest struct { + *http.Request // Embed http.Request + Model string `json:"model"` + Messages []AnthropicRequestMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + // TODO: encoding/json ignores inline tags. Add custom UnmarshalJSON to capture unknown keys. + Options map[string]interface{} `json:",inline"` //nolint:revive +} + +// AnthropicRequestMessage represents a message in an Anthropic request. +// Content may be either a string or a structured content array. +type AnthropicRequestMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// AnthropicMessage represents a message in an Anthropic response. +type AnthropicMessage struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + Model string `json:"model,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Usage AnthropicUsage `json:"usage,omitempty"` +} + +// AnthropicUsage represents usage information in an Anthropic response. +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// AnthropicChunk represents a streaming chunk from Anthropic. +type AnthropicChunk struct { + Type string `json:"type"` + Index int `json:"index,omitempty"` + Message AnthropicChunkMessage `json:"message,omitempty"` + ContentBlock AnthropicContentBlock `json:"content_block,omitempty"` + Delta AnthropicDeltaBlock `json:"delta,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage AnthropicUsage `json:"usage,omitempty"` +} + +// AnthropicChunkMessage represents message metadata in a chunk. +type AnthropicChunkMessage struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` +} + +// AnthropicContentBlock represents a content block in a chunk. +type AnthropicContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// AnthropicDeltaBlock represents a delta block in a chunk. +type AnthropicDeltaBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` +} + +// anthropicServer is a test server that mocks the Anthropic API. +type anthropicServer struct { + mu sync.Mutex + server *httptest.Server + handler AnthropicHandler + request *AnthropicRequest +} + +// NewAnthropic creates a new Anthropic test server with a handler function. +// The handler is called for each request and should return either a streaming +// response (via channel) or a non-streaming response. +// Returns the base URL of the server. +func NewAnthropic(t testing.TB, handler AnthropicHandler) string { + t.Helper() + + s := &anthropicServer{ + handler: handler, + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/messages", s.handleMessages) + + s.server = httptest.NewServer(mux) + + t.Cleanup(func() { + s.server.Close() + }) + + return s.server.URL +} + +func (s *anthropicServer) handleMessages(w http.ResponseWriter, r *http.Request) { + var req AnthropicRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Return a more detailed error for debugging + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + req.Request = r // Embed the original http.Request + + s.mu.Lock() + s.request = &req + s.mu.Unlock() + + resp := s.handler(&req) + s.writeResponse(w, &req, resp) +} + +func (s *anthropicServer) writeResponse(w http.ResponseWriter, req *AnthropicRequest, resp AnthropicResponse) { + hasStreaming := resp.StreamingChunks != nil + hasNonStreaming := resp.Response != nil + + switch { + case hasStreaming && hasNonStreaming: + http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError) + return + case !hasStreaming && !hasNonStreaming: + http.Error(w, "handler returned empty response", http.StatusInternalServerError) + return + case req.Stream && !hasStreaming: + http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError) + return + case !req.Stream && !hasNonStreaming: + http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError) + return + case hasStreaming: + s.writeStreamingResponse(w, resp.StreamingChunks) + default: + s.writeNonStreamingResponse(w, resp.Response) + } +} + +func (s *anthropicServer) writeStreamingResponse(w http.ResponseWriter, chunks <-chan AnthropicChunk) { + _ = s // receiver unused but kept for consistency + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("anthropic-version", "2023-06-01") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + for chunk := range chunks { + chunkData := make(map[string]interface{}) + chunkData["type"] = chunk.Type + + switch chunk.Type { + case "message_start": + chunkData["message"] = chunk.Message + case "content_block_start": + chunkData["index"] = chunk.Index + chunkData["content_block"] = chunk.ContentBlock + case "content_block_delta": + chunkData["index"] = chunk.Index + chunkData["delta"] = chunk.Delta + case "content_block_stop": + chunkData["index"] = chunk.Index + case "message_delta": + chunkData["delta"] = map[string]interface{}{ + "stop_reason": chunk.StopReason, + "stop_sequence": chunk.StopSequence, + } + chunkData["usage"] = chunk.Usage + case "message_stop": + // No additional fields + } + + chunkBytes, err := json.Marshal(chunkData) + if err != nil { + return + } + + // Send both event and data lines to match Anthropic API format + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", chunk.Type, chunkBytes); err != nil { + return + } + flusher.Flush() + } +} + +func (s *anthropicServer) writeNonStreamingResponse(w http.ResponseWriter, resp *AnthropicMessage) { + _ = s // receiver unused but kept for consistency + response := map[string]interface{}{ + "id": resp.ID, + "type": resp.Type, + "role": resp.Role, + "model": resp.Model, + "content": []map[string]interface{}{ + { + "type": "text", + "text": resp.Content, + }, + }, + "stop_reason": resp.StopReason, + "usage": resp.Usage, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("anthropic-version", "2023-06-01") + _ = json.NewEncoder(w).Encode(response) +} + +// AnthropicStreamingResponse creates a streaming response from chunks. +func AnthropicStreamingResponse(chunks ...AnthropicChunk) AnthropicResponse { + ch := make(chan AnthropicChunk, len(chunks)) + go func() { + for _, chunk := range chunks { + ch <- chunk + } + close(ch) + }() + return AnthropicResponse{StreamingChunks: ch} +} + +// AnthropicNonStreamingResponse creates a non-streaming response with the given text. +func AnthropicNonStreamingResponse(text string) AnthropicResponse { + return AnthropicResponse{ + Response: &AnthropicMessage{ + ID: fmt.Sprintf("msg-%s", uuid.New().String()[:8]), + Type: "message", + Role: "assistant", + Content: text, + Model: "claude-3-opus-20240229", + StopReason: "end_turn", + Usage: AnthropicUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + } +} + +// AnthropicTextChunks creates a complete streaming response with text deltas. +// Takes text deltas and creates all required chunks (message_start, +// content_block_start, content_block_delta for each delta, +// content_block_stop, message_delta, message_stop). +func AnthropicTextChunks(deltas ...string) []AnthropicChunk { + if len(deltas) == 0 { + return nil + } + + messageID := fmt.Sprintf("msg-%s", uuid.New().String()[:8]) + model := "claude-3-opus-20240229" + + chunks := []AnthropicChunk{ + { + Type: "message_start", + Message: AnthropicChunkMessage{ + ID: messageID, + Type: "message", + Role: "assistant", + Model: model, + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: AnthropicContentBlock{ + Type: "text", + Text: "", // According to Anthropic API spec, text should be empty in content_block_start + }, + }, + } + + // Add a delta chunk for each delta + for _, delta := range deltas { + chunks = append(chunks, AnthropicChunk{ + Type: "content_block_delta", + Index: 0, + Delta: AnthropicDeltaBlock{ + Type: "text_delta", + Text: delta, + }, + }) + } + + chunks = append(chunks, + AnthropicChunk{ + Type: "content_block_stop", + Index: 0, + }, + AnthropicChunk{ + Type: "message_delta", + StopReason: "end_turn", + Usage: AnthropicUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + AnthropicChunk{ + Type: "message_stop", + }, + ) + + return chunks +} + +// AnthropicToolCallChunks creates a complete streaming response for a tool call. +// Input JSON can be split across multiple deltas, matching Anthropic's +// input_json_delta streaming behavior. +func AnthropicToolCallChunks(toolName string, inputJSONDeltas ...string) []AnthropicChunk { + if len(inputJSONDeltas) == 0 { + return nil + } + if toolName == "" { + toolName = "tool" + } + + messageID := fmt.Sprintf("msg-%s", uuid.New().String()[:8]) + model := "claude-3-opus-20240229" + toolCallID := fmt.Sprintf("toolu_%s", uuid.New().String()[:8]) + + chunks := []AnthropicChunk{ + { + Type: "message_start", + Message: AnthropicChunkMessage{ + ID: messageID, + Type: "message", + Role: "assistant", + Model: model, + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: AnthropicContentBlock{ + Type: "tool_use", + ID: toolCallID, + Name: toolName, + Input: json.RawMessage("{}"), + }, + }, + } + + for _, delta := range inputJSONDeltas { + chunks = append(chunks, AnthropicChunk{ + Type: "content_block_delta", + Index: 0, + Delta: AnthropicDeltaBlock{ + Type: "input_json_delta", + PartialJSON: delta, + }, + }) + } + + chunks = append(chunks, + AnthropicChunk{ + Type: "content_block_stop", + Index: 0, + }, + AnthropicChunk{ + Type: "message_delta", + StopReason: "tool_use", + Usage: AnthropicUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + AnthropicChunk{ + Type: "message_stop", + }, + ) + + return chunks +} diff --git a/coderd/chatd/chattest/anthropic_test.go b/coderd/chatd/chattest/anthropic_test.go new file mode 100644 index 0000000000..531183db38 --- /dev/null +++ b/coderd/chatd/chattest/anthropic_test.go @@ -0,0 +1,221 @@ +package chattest_test + +import ( + "context" + "sync/atomic" + "testing" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/chatd/chattest" +) + +func TestAnthropic_Streaming(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + return chattest.AnthropicStreamingResponse( + chattest.AnthropicTextChunks("Hello", " world", "!")..., + ) + }) + + // Create fantasy client pointing to our test server + client, err := fantasyanthropic.New( + fantasyanthropic.WithAPIKey("test-key"), + fantasyanthropic.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "claude-3-opus-20240229") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Say hello"}, + }, + }, + }, + } + + stream, err := model.Stream(ctx, call) + require.NoError(t, err) + + expectedDeltas := []string{"Hello", " world", "!"} + deltaIndex := 0 + + var allParts []fantasy.StreamPart + for part := range stream { + allParts = append(allParts, part) + if part.Type == fantasy.StreamPartTypeTextDelta { + require.Less(t, deltaIndex, len(expectedDeltas), "Received more deltas than expected") + require.Equal(t, expectedDeltas[deltaIndex], part.Delta, + "Delta at index %d should be %q, got %q", deltaIndex, expectedDeltas[deltaIndex], part.Delta) + deltaIndex++ + } + } + + require.Equal(t, len(expectedDeltas), deltaIndex, "Expected %d deltas, got %d. Total parts received: %d", len(expectedDeltas), deltaIndex, len(allParts)) +} + +func TestAnthropic_ToolCalls(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + switch requestCount.Add(1) { + case 1: + return chattest.AnthropicStreamingResponse( + chattest.AnthropicToolCallChunks("get_weather", `{"location":"San Francisco"}`)..., + ) + default: + return chattest.AnthropicStreamingResponse( + chattest.AnthropicTextChunks("The weather in San Francisco is 72F.")..., + ) + } + }) + + client, err := fantasyanthropic.New( + fantasyanthropic.WithAPIKey("test-key"), + fantasyanthropic.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229") + require.NoError(t, err) + + type weatherInput struct { + Location string `json:"location"` + } + var toolCallCount atomic.Int32 + weatherTool := fantasy.NewAgentTool( + "get_weather", + "Get weather for a location.", + func(ctx context.Context, input weatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + toolCallCount.Add(1) + require.Equal(t, "San Francisco", input.Location) + return fantasy.NewTextResponse("72F"), nil + }, + ) + + agent := fantasy.NewAgent( + model, + fantasy.WithSystemPrompt("You are a helpful assistant."), + fantasy.WithTools(weatherTool), + ) + + result, err := agent.Stream(context.Background(), fantasy.AgentStreamCall{ + Prompt: "What's the weather in San Francisco?", + }) + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, int32(1), toolCallCount.Load(), "expected exactly one tool execution") + require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution") +} + +func TestAnthropic_NonStreaming(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + return chattest.AnthropicNonStreamingResponse("Response text") + }) + + // Create fantasy client pointing to our test server + client, err := fantasyanthropic.New( + fantasyanthropic.WithAPIKey("test-key"), + fantasyanthropic.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "claude-3-opus-20240229") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Test message"}, + }, + }, + }, + } + + response, err := model.Generate(ctx, call) + require.NoError(t, err) + require.NotNil(t, response) +} + +func TestAnthropic_Streaming_MismatchReturnsErrorPart(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + return chattest.AnthropicNonStreamingResponse("wrong response type") + }) + + client, err := fantasyanthropic.New( + fantasyanthropic.WithAPIKey("test-key"), + fantasyanthropic.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229") + require.NoError(t, err) + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }, + }, + }) + require.NoError(t, err) + + var streamErr error + for part := range stream { + if part.Type == fantasy.StreamPartTypeError { + streamErr = part.Error + break + } + } + require.Error(t, streamErr) + require.Contains(t, streamErr.Error(), "500 Internal Server Error") +} + +func TestAnthropic_NonStreaming_MismatchReturnsError(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + return chattest.AnthropicStreamingResponse( + chattest.AnthropicTextChunks("wrong", " response")..., + ) + }) + + client, err := fantasyanthropic.New( + fantasyanthropic.WithAPIKey("test-key"), + fantasyanthropic.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229") + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "500 Internal Server Error") +} diff --git a/coderd/chatd/chattest/openai.go b/coderd/chatd/chattest/openai.go new file mode 100644 index 0000000000..d8df47650c --- /dev/null +++ b/coderd/chatd/chattest/openai.go @@ -0,0 +1,458 @@ +package chattest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/google/uuid" +) + +// OpenAIHandler handles OpenAI API requests and returns a response. +type OpenAIHandler func(req *OpenAIRequest) OpenAIResponse + +// OpenAIResponse represents a response to an OpenAI request. +// Either StreamingChunks or Response should be set, not both. +type OpenAIResponse struct { + StreamingChunks <-chan OpenAIChunk + Response *OpenAICompletion +} + +// OpenAIRequest represents an OpenAI chat completion request. +type OpenAIRequest struct { + *http.Request + Model string `json:"model"` + Messages []OpenAIMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + Prompt []interface{} `json:"prompt,omitempty"` // For responses API + // TODO: encoding/json ignores inline tags. Add custom UnmarshalJSON to capture unknown keys. + Options map[string]interface{} `json:",inline"` //nolint:revive +} + +// OpenAIMessage represents a message in an OpenAI request. +type OpenAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// OpenAIToolCallFunction represents the function details in a tool call. +type OpenAIToolCallFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +// OpenAIToolCall represents a tool call in a streaming chunk or completion. +type OpenAIToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function OpenAIToolCallFunction `json:"function,omitempty"` + Index int `json:"index,omitempty"` // For streaming deltas +} + +// OpenAIChunkChoice represents a choice in a streaming chunk. +type OpenAIChunkChoice struct { + Index int `json:"index"` + Delta string `json:"delta,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` +} + +// OpenAIChunk represents a streaming chunk from OpenAI. +type OpenAIChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChunkChoice `json:"choices"` +} + +// OpenAICompletionChoice represents a choice in a completion response. +type OpenAICompletionChoice struct { + Index int `json:"index"` + Message OpenAIMessage `json:"message"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` +} + +// OpenAICompletionUsage represents usage information in a completion response. +type OpenAICompletionUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// OpenAICompletion represents a non-streaming OpenAI completion response. +type OpenAICompletion struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAICompletionChoice `json:"choices"` + Usage OpenAICompletionUsage `json:"usage"` +} + +// openAIServer is a test server that mocks the OpenAI API. +type openAIServer struct { + mu sync.Mutex + server *httptest.Server + handler OpenAIHandler + request *OpenAIRequest +} + +// NewOpenAI creates a new OpenAI test server with a handler function. +// The handler is called for each request and should return either a streaming +// response (via channel) or a non-streaming response. +// Returns the base URL of the server. +func NewOpenAI(t testing.TB, handler OpenAIHandler) string { + t.Helper() + + s := &openAIServer{ + handler: handler, + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /chat/completions", s.handleChatCompletions) + mux.HandleFunc("POST /responses", s.handleResponses) + + s.server = httptest.NewServer(mux) + + t.Cleanup(func() { + s.server.Close() + }) + + return s.server.URL +} + +func (s *openAIServer) handleChatCompletions(w http.ResponseWriter, r *http.Request) { + var req OpenAIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + req.Request = r + + s.mu.Lock() + s.request = &req + s.mu.Unlock() + + resp := s.handler(&req) + s.writeChatCompletionsResponse(w, &req, resp) +} + +func (s *openAIServer) handleResponses(w http.ResponseWriter, r *http.Request) { + var req OpenAIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + req.Request = r + + s.mu.Lock() + s.request = &req + s.mu.Unlock() + + resp := s.handler(&req) + s.writeResponsesAPIResponse(w, &req, resp) +} + +func (s *openAIServer) writeChatCompletionsResponse(w http.ResponseWriter, req *OpenAIRequest, resp OpenAIResponse) { + hasStreaming := resp.StreamingChunks != nil + hasNonStreaming := resp.Response != nil + + switch { + case hasStreaming && hasNonStreaming: + http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError) + return + case !hasStreaming && !hasNonStreaming: + http.Error(w, "handler returned empty response", http.StatusInternalServerError) + return + case req.Stream && !hasStreaming: + http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError) + return + case !req.Stream && !hasNonStreaming: + http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError) + return + case hasStreaming: + s.writeChatCompletionsStreaming(w, resp.StreamingChunks) + default: + s.writeChatCompletionsNonStreaming(w, resp.Response) + } +} + +func (s *openAIServer) writeResponsesAPIResponse(w http.ResponseWriter, req *OpenAIRequest, resp OpenAIResponse) { + hasStreaming := resp.StreamingChunks != nil + hasNonStreaming := resp.Response != nil + + switch { + case hasStreaming && hasNonStreaming: + http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError) + return + case !hasStreaming && !hasNonStreaming: + http.Error(w, "handler returned empty response", http.StatusInternalServerError) + return + case req.Stream && !hasStreaming: + http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError) + return + case !req.Stream && !hasNonStreaming: + http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError) + return + case hasStreaming: + s.writeResponsesAPIStreaming(w, resp.StreamingChunks) + default: + s.writeResponsesAPINonStreaming(w, resp.Response) + } +} + +func (s *openAIServer) writeChatCompletionsStreaming(w http.ResponseWriter, chunks <-chan OpenAIChunk) { + _ = s // receiver unused but kept for consistency + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + for chunk := range chunks { + choicesData := make([]map[string]interface{}, len(chunk.Choices)) + for i, choice := range chunk.Choices { + choiceData := map[string]interface{}{ + "index": choice.Index, + } + if choice.Delta != "" { + choiceData["delta"] = map[string]interface{}{ + "content": choice.Delta, + } + } + if len(choice.ToolCalls) > 0 { + // Tool calls come in the delta + if choiceData["delta"] == nil { + choiceData["delta"] = make(map[string]interface{}) + } + delta, ok := choiceData["delta"].(map[string]interface{}) + if !ok { + delta = make(map[string]interface{}) + choiceData["delta"] = delta + } + delta["tool_calls"] = choice.ToolCalls + } + if choice.FinishReason != "" { + choiceData["finish_reason"] = choice.FinishReason + } + choicesData[i] = choiceData + } + + chunkData := map[string]interface{}{ + "id": chunk.ID, + "object": chunk.Object, + "created": chunk.Created, + "model": chunk.Model, + "choices": choicesData, + } + + chunkBytes, err := json.Marshal(chunkData) + if err != nil { + return + } + + if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkBytes); err != nil { + return + } + flusher.Flush() + } + + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() +} + +func (s *openAIServer) writeResponsesAPIStreaming(w http.ResponseWriter, chunks <-chan OpenAIChunk) { + _ = s // receiver unused but kept for consistency + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + itemIDs := make(map[int]string) + + for chunk := range chunks { + // Responses API sends one event per choice + for outputIndex, choice := range chunk.Choices { + if choice.Index != 0 { + outputIndex = choice.Index + } + itemID, found := itemIDs[outputIndex] + if !found { + itemID = fmt.Sprintf("msg_%s", uuid.New().String()[:8]) + itemIDs[outputIndex] = itemID + } + + chunkData := map[string]interface{}{ + "type": "response.output_text.delta", + "item_id": itemID, + "output_index": outputIndex, + "created": chunk.Created, + "model": chunk.Model, + "content_index": 0, + "delta": choice.Delta, + } + + chunkBytes, err := json.Marshal(chunkData) + if err != nil { + return + } + + if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkBytes); err != nil { + return + } + flusher.Flush() + } + } + + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() +} + +func (s *openAIServer) writeChatCompletionsNonStreaming(w http.ResponseWriter, resp *OpenAICompletion) { + _ = s // receiver unused but kept for consistency + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp *OpenAICompletion) { + _ = s // receiver unused but kept for consistency + // Convert all choices to output format + outputs := make([]map[string]interface{}, len(resp.Choices)) + for i, choice := range resp.Choices { + outputs[i] = map[string]interface{}{ + "id": uuid.New().String(), + "type": "message", + "role": "assistant", + "content": []map[string]interface{}{ + { + "type": "output_text", + "text": choice.Message.Content, + }, + }, + } + } + + response := map[string]interface{}{ + "id": resp.ID, + "object": "response", + "created": resp.Created, + "model": resp.Model, + "output": outputs, + "usage": resp.Usage, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// OpenAIStreamingResponse creates a streaming response from chunks. +func OpenAIStreamingResponse(chunks ...OpenAIChunk) OpenAIResponse { + ch := make(chan OpenAIChunk, len(chunks)) + go func() { + for _, chunk := range chunks { + ch <- chunk + } + close(ch) + }() + return OpenAIResponse{StreamingChunks: ch} +} + +// OpenAINonStreamingResponse creates a non-streaming response with the given text. +func OpenAINonStreamingResponse(text string) OpenAIResponse { + return OpenAIResponse{ + Response: &OpenAICompletion{ + ID: fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8]), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: "gpt-4", + Choices: []OpenAICompletionChoice{ + { + Index: 0, + Message: OpenAIMessage{ + Role: "assistant", + Content: text, + }, + FinishReason: "stop", + }, + }, + Usage: OpenAICompletionUsage{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + }, + }, + } +} + +// OpenAITextChunks creates streaming chunks with text deltas. +// Each delta string becomes a separate chunk with a single choice. +// Returns a slice of chunks, one per delta, with each choice having its index (0, 1, 2, ...). +func OpenAITextChunks(deltas ...string) []OpenAIChunk { + if len(deltas) == 0 { + return nil + } + + chunkID := fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8]) + now := time.Now().Unix() + chunks := make([]OpenAIChunk, len(deltas)) + + for i, delta := range deltas { + chunks[i] = OpenAIChunk{ + ID: chunkID, + Object: "chat.completion.chunk", + Created: now, + Model: "gpt-4", + Choices: []OpenAIChunkChoice{ + { + Index: i, + Delta: delta, + }, + }, + } + } + + return chunks +} + +// OpenAIToolCallChunk creates a streaming chunk with a tool call. +// Takes the tool name and arguments JSON string, creates a tool call for choice index 0. +func OpenAIToolCallChunk(toolName, arguments string) OpenAIChunk { + return OpenAIChunk{ + ID: fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8]), + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: "gpt-4", + Choices: []OpenAIChunkChoice{ + { + Index: 0, + ToolCalls: []OpenAIToolCall{ + { + Index: 0, + ID: fmt.Sprintf("call_%s", uuid.New().String()[:8]), + Type: "function", + Function: OpenAIToolCallFunction{ + Name: toolName, + Arguments: arguments, + }, + }, + }, + }, + }, + } +} diff --git a/coderd/chatd/chattest/openai_test.go b/coderd/chatd/chattest/openai_test.go new file mode 100644 index 0000000000..56e05563d8 --- /dev/null +++ b/coderd/chatd/chattest/openai_test.go @@ -0,0 +1,367 @@ +package chattest_test + +import ( + "context" + "sync/atomic" + "testing" + + "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/chatd/chattest" +) + +func TestOpenAI_Streaming(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAIStreamingResponse( + append( + append( + chattest.OpenAITextChunks("Hello", "Hi"), + chattest.OpenAITextChunks(" world", " there")..., + ), + chattest.OpenAITextChunks("!", "!")..., + )..., + ) + }) + + // Create fantasy client pointing to our test server + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "gpt-4") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Say hello"}, + }, + }, + }, + } + + stream, err := model.Stream(ctx, call) + require.NoError(t, err) + + // We expect chunks in order: one choice per chunk + // So we get: "Hello" (choice 0), "Hi" (choice 1), " world" (choice 0), " there" (choice 1), "!" (choice 0), "!" (choice 1) + expectedDeltas := []string{"Hello", "Hi", " world", " there", "!", "!"} + deltaIndex := 0 + + for part := range stream { + if part.Type == fantasy.StreamPartTypeTextDelta { + // Verify we're getting deltas in the expected order + require.Less(t, deltaIndex, len(expectedDeltas), "Received more deltas than expected") + require.Equal(t, expectedDeltas[deltaIndex], part.Delta, + "Delta at index %d should be %q, got %q", deltaIndex, expectedDeltas[deltaIndex], part.Delta) + deltaIndex++ + } + } + + // Verify we received all expected deltas + require.Equal(t, len(expectedDeltas), deltaIndex, "Expected %d deltas, got %d", len(expectedDeltas), deltaIndex) +} + +func TestOpenAI_Streaming_ResponsesAPI(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAIStreamingResponse( + append( + append( + chattest.OpenAITextChunks("First", "Second"), + chattest.OpenAITextChunks(" output", " output")..., + ), + chattest.OpenAITextChunks("!", "!")..., + )..., + ) + }) + + // Create fantasy client pointing to our test server (responses API) + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + fantasyopenai.WithUseResponsesAPI(), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "gpt-4") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Say hello"}, + }, + }, + }, + } + + stream, err := model.Stream(ctx, call) + require.NoError(t, err) + + var parts []fantasy.StreamPart + for part := range stream { + parts = append(parts, part) + } + + // Verify we received the chunks in order + require.Greater(t, len(parts), 0) + + // Extract text deltas from parts and verify they match expected chunks in order + // We expect: "First", " output", "!" for choice 0, and "Second", " output", "!" for choice 1 + var allDeltas []string + for _, part := range parts { + if part.Type == fantasy.StreamPartTypeTextDelta { + allDeltas = append(allDeltas, part.Delta) + } + } + + // Verify we received deltas (responses API may handle multiple choices differently) + // If we got text deltas, verify the content + if len(allDeltas) > 0 { + allText := "" + for _, delta := range allDeltas { + allText += delta + } + require.Contains(t, allText, "First") + require.Contains(t, allText, "Second") + require.Contains(t, allText, "output") + require.Contains(t, allText, "!") + } else { + // If no text deltas, at least verify we got some parts (may be different format) + require.Greater(t, len(parts), 0, "Expected at least one stream part") + } +} + +func TestOpenAI_NonStreaming_CompletionsAPI(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAINonStreamingResponse("First response") + }) + + // Create fantasy client pointing to our test server (completions API) + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "gpt-4") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Test message"}, + }, + }, + }, + } + + response, err := model.Generate(ctx, call) + require.NoError(t, err) + require.NotNil(t, response) +} + +func TestOpenAI_ToolCalls(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + switch requestCount.Add(1) { + case 1: + return chattest.OpenAIStreamingResponse( + chattest.OpenAIToolCallChunk("get_weather", `{"location":"San Francisco"}`), + ) + default: + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("The weather in San Francisco is 72F.")..., + ) + } + }) + + // Create fantasy client pointing to our test server + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "gpt-4") + require.NoError(t, err) + + type weatherInput struct { + Location string `json:"location"` + } + var toolCallCount atomic.Int32 + weatherTool := fantasy.NewAgentTool( + "get_weather", + "Get weather for a location.", + func(ctx context.Context, input weatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + toolCallCount.Add(1) + require.Equal(t, "San Francisco", input.Location) + return fantasy.NewTextResponse("72F"), nil + }, + ) + + agent := fantasy.NewAgent( + model, + fantasy.WithSystemPrompt("You are a helpful assistant."), + fantasy.WithTools(weatherTool), + ) + + result, err := agent.Stream(ctx, fantasy.AgentStreamCall{ + Prompt: "What's the weather in San Francisco?", + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int32(1), toolCallCount.Load(), "expected exactly one tool execution") + require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution") +} + +func TestOpenAI_NonStreaming_ResponsesAPI(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAINonStreamingResponse("First output") + }) + + // Create fantasy client pointing to our test server (responses API) + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + fantasyopenai.WithUseResponsesAPI(), + ) + require.NoError(t, err) + + ctx := context.Background() + model, err := client.LanguageModel(ctx, "gpt-4") + require.NoError(t, err) + + call := fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Test message"}, + }, + }, + }, + } + + response, err := model.Generate(ctx, call) + require.NoError(t, err) + require.NotNil(t, response) +} + +func TestOpenAI_Streaming_MismatchReturnsErrorPart(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAINonStreamingResponse("wrong response type") + }) + + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "gpt-4") + require.NoError(t, err) + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }, + }, + }) + require.NoError(t, err) + + var streamErr error + for part := range stream { + if part.Type == fantasy.StreamPartTypeError { + streamErr = part.Error + break + } + } + require.Error(t, streamErr) + require.Contains(t, streamErr.Error(), "non-streaming response for streaming request") +} + +func TestOpenAI_NonStreaming_MismatchReturnsError_CompletionsAPI(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("wrong response type")...) + }) + + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "gpt-4") + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "streaming response for non-streaming request") +} + +func TestOpenAI_NonStreaming_MismatchReturnsError_ResponsesAPI(t *testing.T) { + t.Parallel() + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("wrong response type")...) + }) + + client, err := fantasyopenai.New( + fantasyopenai.WithAPIKey("test-key"), + fantasyopenai.WithBaseURL(serverURL), + fantasyopenai.WithUseResponsesAPI(), + ) + require.NoError(t, err) + + model, err := client.LanguageModel(context.Background(), "gpt-4") + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "streaming response for non-streaming request") +} diff --git a/coderd/chatd/chattool/chattool.go b/coderd/chatd/chattool/chattool.go new file mode 100644 index 0000000000..f12d6cbf90 --- /dev/null +++ b/coderd/chatd/chattool/chattool.go @@ -0,0 +1,33 @@ +package chattool + +import ( + "encoding/json" + "unicode/utf8" + + "charm.land/fantasy" +) + +// toolResponse builds a fantasy.ToolResponse from a JSON-serializable +// result payload. +func toolResponse(result map[string]any) fantasy.ToolResponse { + data, err := json.Marshal(result) + if err != nil { + return fantasy.NewTextResponse("{}") + } + return fantasy.NewTextResponse(string(data)) +} + +func truncateRunes(value string, maxLen int) string { + if maxLen <= 0 || value == "" { + return "" + } + if utf8.RuneCountInString(value) <= maxLen { + return value + } + + runes := []rune(value) + if maxLen > len(runes) { + maxLen = len(runes) + } + return string(runes[:maxLen]) +} diff --git a/coderd/chatd/chattool/createworkspace.go b/coderd/chatd/chattool/createworkspace.go new file mode 100644 index 0000000000..d86d6cd49f --- /dev/null +++ b/coderd/chatd/chattool/createworkspace.go @@ -0,0 +1,426 @@ +package chattool + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/namesgenerator" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +const ( + // buildPollInterval is how often we check if the workspace + // build has completed. + buildPollInterval = 2 * time.Second + // buildTimeout is the maximum time to wait for a workspace + // build to complete before giving up. + buildTimeout = 10 * time.Minute + // agentConnectTimeout is the maximum time to wait for the + // workspace agent to become reachable after a successful build. + agentConnectTimeout = 2 * time.Minute + // agentRetryInterval is how often we retry connecting to the + // workspace agent. + agentRetryInterval = 2 * time.Second + // agentAttemptTimeout is the timeout for a single connection + // attempt to the workspace agent during the retry loop. + agentAttemptTimeout = 5 * time.Second + // agentPingTimeout is the timeout for a single agent ping + // when checking whether an existing workspace is alive. + agentPingTimeout = 5 * time.Second +) + +// CreateWorkspaceFn creates a workspace for the given owner. +type CreateWorkspaceFn func( + ctx context.Context, + ownerID uuid.UUID, + req codersdk.CreateWorkspaceRequest, +) (codersdk.Workspace, error) + +// AgentConnFunc provides access to workspace agent connections. +type AgentConnFunc func( + ctx context.Context, + agentID uuid.UUID, +) (workspacesdk.AgentConn, func(), error) + +// CreateWorkspaceOptions configures the create_workspace tool. +type CreateWorkspaceOptions struct { + DB database.Store + OwnerID uuid.UUID + ChatID uuid.UUID + CreateFn CreateWorkspaceFn + AgentConnFn AgentConnFunc + WorkspaceMu *sync.Mutex +} + +type createWorkspaceArgs struct { + TemplateID string `json:"template_id"` + Name string `json:"name,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` +} + +// CreateWorkspace returns a tool that creates a new workspace from a +// template. The tool is idempotent: if the chat already has a +// workspace that is building or running, it returns the existing +// workspace instead of creating a new one. A mutex prevents parallel +// calls from creating duplicate workspaces. +func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "create_workspace", + "Create a new workspace from a template. Requires a "+ + "template_id (from list_templates). Optionally provide "+ + "a name and parameter values (from read_template). "+ + "If no name is given, one will be generated. "+ + "This tool is idempotent β€” if the chat already has a "+ + "workspace that is building or running, the existing "+ + "workspace is returned.", + func(ctx context.Context, args createWorkspaceArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.CreateFn == nil { + return fantasy.NewTextErrorResponse("workspace creator is not configured"), nil + } + + templateIDStr := strings.TrimSpace(args.TemplateID) + if templateIDStr == "" { + return fantasy.NewTextErrorResponse("template_id is required; use list_templates to find one"), nil + } + templateID, err := uuid.Parse(templateIDStr) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("invalid template_id: %w", err).Error(), + ), nil + } + + // Serialize workspace creation to prevent parallel + // tool calls from creating duplicate workspaces. + if options.WorkspaceMu != nil { + options.WorkspaceMu.Lock() + defer options.WorkspaceMu.Unlock() + } + + // Check for an existing workspace on the chat. + if options.DB != nil && options.ChatID != uuid.Nil { + existing, done, existErr := checkExistingWorkspace( + ctx, options.DB, options.ChatID, + options.AgentConnFn, + ) + if existErr != nil { + return fantasy.NewTextErrorResponse(existErr.Error()), nil + } + if done { + return toolResponse(existing), nil + } + } + + ownerID := options.OwnerID + + // Set up dbauthz context for DB lookups. + if options.DB != nil { + ownerCtx, ownerErr := asOwner(ctx, options.DB, ownerID) + if ownerErr != nil { + return fantasy.NewTextErrorResponse(ownerErr.Error()), nil + } + ctx = ownerCtx + } + + createReq := codersdk.CreateWorkspaceRequest{ + TemplateID: templateID, + } + + // Resolve workspace name. + name := strings.TrimSpace(args.Name) + if name == "" { + seed := "workspace" + if options.DB != nil { + if t, lookupErr := options.DB.GetTemplateByID(ctx, templateID); lookupErr == nil { + seed = t.Name + } + } + name = generatedWorkspaceName(seed) + } else if err := codersdk.NameValid(name); err != nil { + name = generatedWorkspaceName(name) + } + createReq.Name = name + + // Map parameters. + for k, v := range args.Parameters { + createReq.RichParameterValues = append( + createReq.RichParameterValues, + codersdk.WorkspaceBuildParameter{Name: k, Value: v}, + ) + } + + workspace, err := options.CreateFn(ctx, ownerID, createReq) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + // Wait for the build to complete and the agent to + // come online so subsequent tools can use the + // workspace immediately. + if options.DB != nil { + if err := waitForBuild(ctx, options.DB, workspace.ID); err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("workspace build failed: %w", err).Error(), + ), nil + } + } + + // Look up the first agent so we can link it to the chat. + workspaceAgentID := uuid.Nil + if options.DB != nil { + agents, agentErr := options.DB.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) + if agentErr == nil && len(agents) > 0 { + workspaceAgentID = agents[0].ID + } + } + + // Persist workspace + agent association on the chat. + if options.DB != nil && options.ChatID != uuid.Nil { + _, _ = options.DB.UpdateChatWorkspace(ctx, database.UpdateChatWorkspaceParams{ + ID: options.ChatID, + WorkspaceID: uuid.NullUUID{ + UUID: workspace.ID, + Valid: true, + }, + WorkspaceAgentID: uuid.NullUUID{ + UUID: workspaceAgentID, + Valid: workspaceAgentID != uuid.Nil, + }, + }) + } + + // Wait for the agent to come online. + if workspaceAgentID != uuid.Nil && options.AgentConnFn != nil { + if err := waitForAgent(ctx, options.AgentConnFn, workspaceAgentID); err != nil { + // Non-fatal: the workspace was created + // successfully, the agent just isn't ready + // yet. The model can retry. + return toolResponse(map[string]any{ + "created": true, + "workspace_name": workspace.FullName(), + "agent_status": "not_ready", + "agent_error": err.Error(), + }), nil + } + } + + return toolResponse(map[string]any{ + "created": true, + "workspace_name": workspace.FullName(), + }), nil + }, + ) +} + +// checkExistingWorkspace checks whether the chat already has a usable +// workspace. Returns the result map and true if the caller should +// return early (workspace exists and is alive or building). Returns +// false if the caller should proceed with creation (workspace is dead +// or missing). +func checkExistingWorkspace( + ctx context.Context, + db database.Store, + chatID uuid.UUID, + agentConnFn AgentConnFunc, +) (map[string]any, bool, error) { + chat, err := db.GetChatByID(ctx, chatID) + if err != nil { + return nil, false, xerrors.Errorf("load chat: %w", err) + } + if !chat.WorkspaceID.Valid { + return nil, false, nil + } + + // Check if workspace still exists. + ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + // Workspace was deleted β€” allow creation. + return nil, false, nil + } + return nil, false, xerrors.Errorf("load workspace: %w", err) + } + + // Check the latest build status. + build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, ws.ID) + if err != nil { + // Can't determine status β€” allow creation. + return nil, false, nil + } + + job, err := db.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + return nil, false, nil + } + + switch job.JobStatus { + case database.ProvisionerJobStatusPending, + database.ProvisionerJobStatusRunning: + // Build is in progress β€” wait for it instead of + // creating a new workspace. + if err := waitForBuild(ctx, db, ws.ID); err != nil { + return nil, false, xerrors.Errorf( + "existing workspace build failed: %w", err, + ) + } + return map[string]any{ + "created": false, + "workspace_name": ws.Name, + "status": "already_exists", + "message": "workspace was already being built and is now ready", + }, true, nil + + case database.ProvisionerJobStatusSucceeded: + // Build succeeded β€” check if agent is reachable. + if chat.WorkspaceAgentID.Valid && agentConnFn != nil { + pingCtx, cancel := context.WithTimeout( + ctx, agentPingTimeout, + ) + defer cancel() + + conn, release, connErr := agentConnFn( + pingCtx, chat.WorkspaceAgentID.UUID, + ) + if connErr == nil { + release() + _ = conn + return map[string]any{ + "created": false, + "workspace_name": ws.Name, + "status": "already_exists", + "message": "workspace is already running and reachable", + }, true, nil + } + // Agent unreachable β€” workspace is dead, allow + // creation. + } + // No agent ID or no conn func β€” allow creation. + return nil, false, nil + + default: + // Failed, canceled, etc β€” allow creation. + return nil, false, nil + } +} + +// waitForBuild polls the workspace's latest build until it +// completes or the context expires. +func waitForBuild( + ctx context.Context, + db database.Store, + workspaceID uuid.UUID, +) error { + buildCtx, cancel := context.WithTimeout(ctx, buildTimeout) + defer cancel() + + ticker := time.NewTicker(buildPollInterval) + defer ticker.Stop() + + for { + build, err := db.GetLatestWorkspaceBuildByWorkspaceID( + buildCtx, workspaceID, + ) + if err != nil { + return xerrors.Errorf("get latest build: %w", err) + } + + job, err := db.GetProvisionerJobByID(buildCtx, build.JobID) + if err != nil { + return xerrors.Errorf("get provisioner job: %w", err) + } + + switch job.JobStatus { + case database.ProvisionerJobStatusSucceeded: + return nil + case database.ProvisionerJobStatusFailed: + errMsg := "build failed" + if job.Error.Valid { + errMsg = job.Error.String + } + return xerrors.New(errMsg) + case database.ProvisionerJobStatusCanceled: + return xerrors.New("build was canceled") + case database.ProvisionerJobStatusPending, + database.ProvisionerJobStatusRunning, + database.ProvisionerJobStatusCanceling: + // Still in progress β€” keep waiting. + default: + return xerrors.Errorf("unexpected job status: %s", job.JobStatus) + } + + select { + case <-buildCtx.Done(): + return xerrors.Errorf( + "timed out waiting for workspace build: %w", + buildCtx.Err(), + ) + case <-ticker.C: + } + } +} + +// waitForAgent retries connecting to the workspace agent until it +// succeeds or the timeout expires. +func waitForAgent( + ctx context.Context, + agentConnFn AgentConnFunc, + agentID uuid.UUID, +) error { + agentCtx, cancel := context.WithTimeout(ctx, agentConnectTimeout) + defer cancel() + + ticker := time.NewTicker(agentRetryInterval) + defer ticker.Stop() + + var lastErr error + for { + attemptCtx, attemptCancel := context.WithTimeout(agentCtx, agentAttemptTimeout) + conn, release, err := agentConnFn(attemptCtx, agentID) + attemptCancel() + if err == nil { + release() + _ = conn + return nil + } + lastErr = err + + select { + case <-agentCtx.Done(): + return xerrors.Errorf( + "timed out waiting for workspace agent: %w", + lastErr, + ) + case <-ticker.C: + } + } +} + +func generatedWorkspaceName(seed string) string { + base := codersdk.UsernameFrom(strings.TrimSpace(strings.ToLower(seed))) + if strings.TrimSpace(base) == "" { + base = "workspace" + } + + suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:4] + if len(base) > 27 { + base = strings.Trim(base[:27], "-") + } + if base == "" { + base = "workspace" + } + + name := fmt.Sprintf("%s-%s", base, suffix) + if err := codersdk.NameValid(name); err == nil { + return name + } + return namesgenerator.NameDigitWith("-") +} diff --git a/coderd/chatd/chattool/editfiles.go b/coderd/chatd/chattool/editfiles.go new file mode 100644 index 0000000000..1d601efb53 --- /dev/null +++ b/coderd/chatd/chattool/editfiles.go @@ -0,0 +1,50 @@ +package chattool + +import ( + "context" + + "charm.land/fantasy" + + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +type EditFilesOptions struct { + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) +} + +type EditFilesArgs struct { + Files []workspacesdk.FileEdits `json:"files"` +} + +func EditFiles(options EditFilesOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "edit_files", + "Perform search-and-replace edits on one or more files in the workspace."+ + " Each file can have multiple edits applied atomically.", + func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.GetWorkspaceConn == nil { + return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil + } + conn, err := options.GetWorkspaceConn(ctx) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return executeEditFilesTool(ctx, conn, args) + }, + ) +} + +func executeEditFilesTool( + ctx context.Context, + conn workspacesdk.AgentConn, + args EditFilesArgs, +) (fantasy.ToolResponse, error) { + if len(args.Files) == 0 { + return fantasy.NewTextErrorResponse("files is required"), nil + } + + if err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}); err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return toolResponse(map[string]any{"ok": true}), nil +} diff --git a/coderd/chatd/chattool/execute.go b/coderd/chatd/chattool/execute.go new file mode 100644 index 0000000000..a4d58b4086 --- /dev/null +++ b/coderd/chatd/chattool/execute.go @@ -0,0 +1,133 @@ +package chattool + +import ( + "context" + "time" + + "charm.land/fantasy" + "golang.org/x/crypto/ssh" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +const ( + defaultExecuteTimeout = 60 * time.Second + chatAgentEnvVar = "CODER_CHAT_AGENT" + gitAuthRequiredPrefix = "CODER_GITAUTH_REQUIRED:" + authRequiredResultReason = "authentication_required" +) + +type ExecuteOptions struct { + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) + DefaultTimeout time.Duration +} + +type ExecuteArgs struct { + Command string `json:"command"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` +} + +func Execute(options ExecuteOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "execute", + "Execute a shell command in the workspace.", + func(ctx context.Context, args ExecuteArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.GetWorkspaceConn == nil { + return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil + } + conn, err := options.GetWorkspaceConn(ctx) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return executeTool(ctx, conn, args, options.DefaultTimeout), nil + }, + ) +} + +func executeTool( + ctx context.Context, + conn workspacesdk.AgentConn, + args ExecuteArgs, + defaultTimeout time.Duration, +) fantasy.ToolResponse { + if args.Command == "" { + return fantasy.NewTextErrorResponse("command is required") + } + + timeout := defaultTimeout + if timeout <= 0 { + timeout = defaultExecuteTimeout + } + if args.TimeoutSeconds != nil { + timeout = time.Duration(*args.TimeoutSeconds) * time.Second + } + cmdCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + output, exitCode, err := runCommand(cmdCtx, conn, args.Command) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()) + } + return toolResponse(map[string]any{ + "output": output, + "exit_code": exitCode, + }) +} + +func runCommand( + ctx context.Context, + conn workspacesdk.AgentConn, + command string, +) (string, int, error) { + sshClient, err := conn.SSHClient(ctx) + if err != nil { + return "", 0, err + } + defer sshClient.Close() + + session, err := sshClient.NewSession() + if err != nil { + return "", 0, err + } + defer session.Close() + if err := session.Setenv(chatAgentEnvVar, "true"); err != nil { + return "", 0, xerrors.Errorf("set %s: %w", chatAgentEnvVar, err) + } + + resultCh := make(chan struct { + output string + exitCode int + err error + }, 1) + + go func() { + output, err := session.CombinedOutput(command) + exitCode := 0 + if err != nil { + var exitErr *ssh.ExitError + if xerrors.As(err, &exitErr) { + exitCode = exitErr.ExitStatus() + } else { + exitCode = 1 + } + } + resultCh <- struct { + output string + exitCode int + err error + }{ + output: string(output), + exitCode: exitCode, + err: err, + } + }() + + select { + case <-ctx.Done(): + _ = session.Close() + return "", 0, ctx.Err() + case result := <-resultCh: + return result.output, result.exitCode, result.err + } +} diff --git a/coderd/chatd/chattool/listtemplates.go b/coderd/chatd/chattool/listtemplates.go new file mode 100644 index 0000000000..cfc077da41 --- /dev/null +++ b/coderd/chatd/chattool/listtemplates.go @@ -0,0 +1,94 @@ +package chattool + +import ( + "context" + "database/sql" + "strings" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" +) + +// ListTemplatesOptions configures the list_templates tool. +type ListTemplatesOptions struct { + DB database.Store + OwnerID uuid.UUID +} + +type listTemplatesArgs struct { + Query string `json:"query,omitempty"` +} + +// ListTemplates returns a tool that lists available workspace templates. +// The agent uses this to discover templates before creating a workspace. +func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "list_templates", + "List available workspace templates. Optionally filter by a "+ + "search query matching template name or description. "+ + "Use this to find a template before creating a workspace.", + func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.DB == nil { + return fantasy.NewTextErrorResponse("database is not configured"), nil + } + + ctx, err := asOwner(ctx, options.DB, options.OwnerID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + filterParams := database.GetTemplatesWithFilterParams{ + Deleted: false, + Deprecated: sql.NullBool{ + Bool: false, + Valid: true, + }, + } + query := strings.TrimSpace(args.Query) + if query != "" { + filterParams.FuzzyName = query + } + + templates, err := options.DB.GetTemplatesWithFilter(ctx, filterParams) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + items := make([]map[string]any, 0, len(templates)) + for _, t := range templates { + item := map[string]any{ + "id": t.ID.String(), + "name": t.Name, + } + if display := strings.TrimSpace(t.DisplayName); display != "" { + item["display_name"] = display + } + if desc := strings.TrimSpace(t.Description); desc != "" { + item["description"] = truncateRunes(desc, 200) + } + items = append(items, item) + } + + return toolResponse(map[string]any{ + "templates": items, + "count": len(items), + }), nil + }, + ) +} + +// asOwner sets up a dbauthz context for the given owner so that +// subsequent database calls are scoped to what that user can access. +func asOwner(ctx context.Context, db database.Store, ownerID uuid.UUID) (context.Context, error) { + actor, _, err := httpmw.UserRBACSubject(ctx, db, ownerID, rbac.ScopeAll) + if err != nil { + return ctx, xerrors.Errorf("load user authorization: %w", err) + } + return dbauthz.As(ctx, actor), nil +} diff --git a/coderd/chatd/chattool/readfile.go b/coderd/chatd/chattool/readfile.go new file mode 100644 index 0000000000..6144016921 --- /dev/null +++ b/coderd/chatd/chattool/readfile.go @@ -0,0 +1,72 @@ +package chattool + +import ( + "context" + "io" + + "charm.land/fantasy" + + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +type ReadFileOptions struct { + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) +} + +type ReadFileArgs struct { + Path string `json:"path"` + Offset *int64 `json:"offset,omitempty"` + Limit *int64 `json:"limit,omitempty"` +} + +func ReadFile(options ReadFileOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "read_file", + "Read a file from the workspace.", + func(ctx context.Context, args ReadFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.GetWorkspaceConn == nil { + return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil + } + conn, err := options.GetWorkspaceConn(ctx) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return executeReadFileTool(ctx, conn, args) + }, + ) +} + +func executeReadFileTool( + ctx context.Context, + conn workspacesdk.AgentConn, + args ReadFileArgs, +) (fantasy.ToolResponse, error) { + if args.Path == "" { + return fantasy.NewTextErrorResponse("path is required"), nil + } + + offset := int64(0) + limit := int64(0) + if args.Offset != nil { + offset = *args.Offset + } + if args.Limit != nil { + limit = *args.Limit + } + + reader, mimeType, err := conn.ReadFile(ctx, args.Path, offset, limit) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolResponse(map[string]any{ + "content": string(data), + "mime_type": mimeType, + }), nil +} diff --git a/coderd/chatd/chattool/readtemplate.go b/coderd/chatd/chattool/readtemplate.go new file mode 100644 index 0000000000..beae79ce46 --- /dev/null +++ b/coderd/chatd/chattool/readtemplate.go @@ -0,0 +1,130 @@ +package chattool + +import ( + "context" + "encoding/json" + "strings" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +// ReadTemplateOptions configures the read_template tool. +type ReadTemplateOptions struct { + DB database.Store + OwnerID uuid.UUID +} + +type readTemplateArgs struct { + TemplateID string `json:"template_id"` +} + +// ReadTemplate returns a tool that retrieves details about a specific +// template, including its configurable rich parameters. The agent +// uses this after list_templates and before create_workspace. +func ReadTemplate(options ReadTemplateOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "read_template", + "Get details about a workspace template, including its "+ + "configurable parameters. Use this after finding a "+ + "template with list_templates and before creating a "+ + "workspace with create_workspace.", + func(ctx context.Context, args readTemplateArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.DB == nil { + return fantasy.NewTextErrorResponse("database is not configured"), nil + } + + templateIDStr := strings.TrimSpace(args.TemplateID) + if templateIDStr == "" { + return fantasy.NewTextErrorResponse("template_id is required"), nil + } + templateID, err := uuid.Parse(templateIDStr) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("invalid template_id: %w", err).Error(), + ), nil + } + + ctx, err = asOwner(ctx, options.DB, options.OwnerID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + template, err := options.DB.GetTemplateByID(ctx, templateID) + if err != nil { + return fantasy.NewTextErrorResponse("template not found"), nil + } + + params, err := options.DB.GetTemplateVersionParameters(ctx, template.ActiveVersionID) + if err != nil { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("failed to get template parameters: %w", err).Error(), + ), nil + } + + templateInfo := map[string]any{ + "id": template.ID.String(), + "name": template.Name, + "active_version_id": template.ActiveVersionID.String(), + } + if display := strings.TrimSpace(template.DisplayName); display != "" { + templateInfo["display_name"] = display + } + if desc := strings.TrimSpace(template.Description); desc != "" { + templateInfo["description"] = desc + } + + paramList := make([]map[string]any, 0, len(params)) + for _, p := range params { + param := map[string]any{ + "name": p.Name, + "type": p.Type, + "required": p.Required, + } + if display := strings.TrimSpace(p.DisplayName); display != "" { + param["display_name"] = display + } + if desc := strings.TrimSpace(p.Description); desc != "" { + param["description"] = truncateRunes(desc, 300) + } + if p.DefaultValue != "" { + param["default"] = p.DefaultValue + } + if p.Mutable { + param["mutable"] = true + } + if p.Ephemeral { + param["ephemeral"] = true + } + if p.FormType != "" { + param["form_type"] = string(p.FormType) + } + if len(p.Options) > 0 && string(p.Options) != "null" && string(p.Options) != "[]" { + var opts []map[string]any + if err := json.Unmarshal(p.Options, &opts); err == nil && len(opts) > 0 { + param["options"] = opts + } + } + if p.ValidationRegex != "" { + param["validation_regex"] = p.ValidationRegex + } + if p.ValidationMin.Valid { + param["validation_min"] = p.ValidationMin.Int32 + } + if p.ValidationMax.Valid { + param["validation_max"] = p.ValidationMax.Int32 + } + + paramList = append(paramList, param) + } + + return toolResponse(map[string]any{ + "template": templateInfo, + "parameters": paramList, + }), nil + }, + ) +} diff --git a/coderd/chatd/chattool/writefile.go b/coderd/chatd/chattool/writefile.go new file mode 100644 index 0000000000..a9c372ca48 --- /dev/null +++ b/coderd/chatd/chattool/writefile.go @@ -0,0 +1,51 @@ +package chattool + +import ( + "context" + "strings" + + "charm.land/fantasy" + + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +type WriteFileOptions struct { + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) +} + +type WriteFileArgs struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func WriteFile(options WriteFileOptions) fantasy.AgentTool { + return fantasy.NewAgentTool( + "write_file", + "Write a file to the workspace.", + func(ctx context.Context, args WriteFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if options.GetWorkspaceConn == nil { + return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil + } + conn, err := options.GetWorkspaceConn(ctx) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return executeWriteFileTool(ctx, conn, args) + }, + ) +} + +func executeWriteFileTool( + ctx context.Context, + conn workspacesdk.AgentConn, + args WriteFileArgs, +) (fantasy.ToolResponse, error) { + if args.Path == "" { + return fantasy.NewTextErrorResponse("path is required"), nil + } + + if err := conn.WriteFile(ctx, args.Path, strings.NewReader(args.Content)); err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return toolResponse(map[string]any{"ok": true}), nil +} diff --git a/coderd/chatd/instruction.go b/coderd/chatd/instruction.go new file mode 100644 index 0000000000..0d682e786e --- /dev/null +++ b/coderd/chatd/instruction.go @@ -0,0 +1,126 @@ +package chatd + +import ( + "context" + "io" + "net/http" + "regexp" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +const ( + coderHomeInstructionDir = ".coder" + coderHomeInstructionFile = "AGENTS.md" + maxInstructionFileBytes = 64 * 1024 +) + +var markdownCommentPattern = regexp.MustCompile(``) + +func readHomeInstructionFile( + ctx context.Context, + conn workspacesdk.AgentConn, +) (content string, sourcePath string, truncated bool, err error) { + if conn == nil { + return "", "", false, nil + } + + coderDir, err := conn.LS(ctx, "", workspacesdk.LSRequest{ + Path: []string{coderHomeInstructionDir}, + Relativity: workspacesdk.LSRelativityHome, + }) + if err != nil { + if isCodersdkStatusCode(err, http.StatusNotFound) { + return "", "", false, nil + } + return "", "", false, xerrors.Errorf("list home instruction directory: %w", err) + } + + var filePath string + for _, entry := range coderDir.Contents { + if entry.IsDir { + continue + } + if strings.EqualFold(strings.TrimSpace(entry.Name), coderHomeInstructionFile) { + filePath = strings.TrimSpace(entry.AbsolutePathString) + break + } + } + if filePath == "" { + return "", "", false, nil + } + + reader, _, err := conn.ReadFile( + ctx, + filePath, + 0, + maxInstructionFileBytes+1, + ) + if err != nil { + if isCodersdkStatusCode(err, http.StatusNotFound) { + return "", "", false, nil + } + return "", "", false, xerrors.Errorf("read home instruction file: %w", err) + } + defer reader.Close() + + raw, err := io.ReadAll(reader) + if err != nil { + return "", "", false, xerrors.Errorf("read home instruction bytes: %w", err) + } + + truncated = int64(len(raw)) > maxInstructionFileBytes + if truncated { + raw = raw[:maxInstructionFileBytes] + } + + content = sanitizeInstructionMarkdown(string(raw)) + if content == "" { + return "", "", truncated, nil + } + + return content, filePath, truncated, nil +} + +func sanitizeInstructionMarkdown(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + content = markdownCommentPattern.ReplaceAllString(content, "") + return strings.TrimSpace(content) +} + +//nolint:revive // Boolean indicates content was truncated. +func formatHomeInstruction(content string, sourcePath string, truncated bool) string { + content = strings.TrimSpace(content) + if content == "" { + return "" + } + sourcePath = strings.TrimSpace(sourcePath) + if sourcePath == "" { + sourcePath = "~/.coder/AGENTS.md" + } + + var b strings.Builder + _, _ = b.WriteString("\n") + _, _ = b.WriteString("Source: ") + _, _ = b.WriteString(sourcePath) + if truncated { + _, _ = b.WriteString(" (truncated to 64KiB)") + } + _, _ = b.WriteString("\n\n") + _, _ = b.WriteString(content) + _, _ = b.WriteString("\n") + return b.String() +} + +func isCodersdkStatusCode(err error, statusCode int) bool { + var sdkErr *codersdk.Error + if !xerrors.As(err, &sdkErr) { + return false + } + return sdkErr.StatusCode() == statusCode +} diff --git a/coderd/chatd/instruction_test.go b/coderd/chatd/instruction_test.go new file mode 100644 index 0000000000..a6dd49e5e0 --- /dev/null +++ b/coderd/chatd/instruction_test.go @@ -0,0 +1,134 @@ +package chatd //nolint:testpackage // Uses internal symbols. + +import ( + "context" + "io" + "strings" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" +) + +func TestSanitizeInstructionMarkdown(t *testing.T) { + t.Parallel() + + input := "line 1\r\n\r\nline 2\r\n" + require.Equal(t, "line 1\n\nline 2", sanitizeInstructionMarkdown(input)) +} + +func TestReadHomeInstructionFileNotFound(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + conn := agentconnmock.NewMockAgentConn(ctrl) + conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).DoAndReturn( + func(context.Context, string, workspacesdk.LSRequest) (workspacesdk.LSResponse, error) { + return workspacesdk.LSResponse{}, codersdk.NewTestError(404, "POST", "/api/v0/list-directory") + }, + ) + + content, sourcePath, truncated, err := readHomeInstructionFile(context.Background(), conn) + require.NoError(t, err) + require.Empty(t, content) + require.Empty(t, sourcePath) + require.False(t, truncated) +} + +func TestReadHomeInstructionFileSuccess(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + conn := agentconnmock.NewMockAgentConn(ctrl) + + conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).DoAndReturn( + func(context.Context, string, workspacesdk.LSRequest) (workspacesdk.LSResponse, error) { + return workspacesdk.LSResponse{ + Contents: []workspacesdk.LSFile{{ + Name: "AGENTS.md", + AbsolutePathString: "/home/coder/.coder/AGENTS.md", + }}, + }, nil + }, + ) + conn.EXPECT().ReadFile( + gomock.Any(), + "/home/coder/.coder/AGENTS.md", + int64(0), + int64(maxInstructionFileBytes+1), + ).Return( + io.NopCloser(strings.NewReader("base\n\nlocal")), + "text/markdown", + nil, + ) + + content, sourcePath, truncated, err := readHomeInstructionFile(context.Background(), conn) + require.NoError(t, err) + require.Equal(t, "base\n\nlocal", content) + require.Equal(t, "/home/coder/.coder/AGENTS.md", sourcePath) + require.False(t, truncated) +} + +func TestReadHomeInstructionFileTruncates(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + conn := agentconnmock.NewMockAgentConn(ctrl) + content := strings.Repeat("a", maxInstructionFileBytes+8) + + conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( + workspacesdk.LSResponse{ + Contents: []workspacesdk.LSFile{{ + Name: "AGENTS.md", + AbsolutePathString: "/home/coder/.coder/AGENTS.md", + }}, + }, + nil, + ) + conn.EXPECT().ReadFile( + gomock.Any(), + "/home/coder/.coder/AGENTS.md", + int64(0), + int64(maxInstructionFileBytes+1), + ).Return(io.NopCloser(strings.NewReader(content)), "text/markdown", nil) + + got, _, truncated, err := readHomeInstructionFile(context.Background(), conn) + require.NoError(t, err) + require.True(t, truncated) + require.Len(t, got, maxInstructionFileBytes) +} + +func TestInsertSystemInstructionAfterSystemMessages(t *testing.T) { + t.Parallel() + + prompt := []fantasy.Message{ + { + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "base"}, + }, + }, + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello"}, + }, + }, + } + + got := chatprompt.InsertSystem(prompt, "project rules") + require.Len(t, got, 3) + require.Equal(t, fantasy.MessageRoleSystem, got[0].Role) + require.Equal(t, fantasy.MessageRoleSystem, got[1].Role) + require.Equal(t, fantasy.MessageRoleUser, got[2].Role) + + part, ok := fantasy.AsMessagePart[fantasy.TextPart](got[1].Content[0]) + require.True(t, ok) + require.Equal(t, "project rules", part.Text) +} diff --git a/coderd/chatd/prompt.go b/coderd/chatd/prompt.go new file mode 100644 index 0000000000..9b8f6850c5 --- /dev/null +++ b/coderd/chatd/prompt.go @@ -0,0 +1,73 @@ +package chatd + +// DefaultSystemPrompt is used for new chats when no deployment override is +// configured. +const DefaultSystemPrompt = `You are the Coder agent β€” an interactive chat tool that helps users with software-engineering tasks inside of the Coder product. +Use the instructions below and the tools available to you to assist User. + +IMPORTANT β€” obey every rule in this prompt before anything else. +Do EXACTLY what the User asked, never more, never less. + + +You MUST execute AS MANY TOOLS to help the user accomplish their task. +You are COMFORTABLE with vague tasks - using your tools to collect the most relevant answer possible. +If a user asks how something works, no matter how vague, you MUST use your tools to collect the most relevant answer possible. +DO NOT ask the user for clarification - just use your tools. + + + +Analytical β€” You break problems into measurable steps, relying on tool output and data rather than intuition. +Organized β€” You structure every interaction with clear tags, TODO lists, and section boundaries. +Precision-Oriented β€” You insist on exact formatting, package-manager choice, and rule adherence. +Efficiency-Focused β€” You minimize chatter, run tasks in parallel, and favor small, complete answers. +Clarity-Seeking β€” You ask for missing details instead of guessing, avoiding any ambiguity. + + + +Be concise, direct, and to the point. +NO emojis unless the User explicitly asks for them. +If a task appears incomplete or ambiguous, **pause and ask the User** rather than guessing or marking "done". +Prefer accuracy over reassurance; confirm facts with tool calls instead of assuming the User is right. +If you face an architectural, tooling, or package-manager choice, **ask the User's preference first**. +Default to the project's existing package manager / tooling; never substitute without confirmation. +You MUST avoid text before/after your response, such as "The answer is" or "Short answer:", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". +Mimic the style of the User's messages. +Do not remind the User you are happy to help. +Do not inherently assume the User is correct; they may be making assumptions. +If you are not confident in your answer, DO NOT provide an answer. Use your tools to collect more information, or ask the User for help. +Do not act with sycophantic flattery or over-the-top enthusiasm. + +Here are examples to demonstrate appropriate communication style and level of verbosity: + + +user: find me a good issue to work on +assistant: Issue [#1234](https://example) indicates a bug in the frontend, which you've contributed to in the past. + + + +user: work on this issue +...assistant does work... +assistant: I've put up this pull request: https://github.com/example/example/pull/1824. Please let me know your thoughts! + + + +user: what is 2+2? +assistant: 4 + + + +user: how does X work in ? +assistant: Let me take a look at the code... +[tool calls to investigate the repository] + + + + +When a user asks for help with a task or there is ambiguity on the objective, always start by asking clarifying questions to understand: +- What specific aspect they want to focus on +- Their goals and vision for the changes +- Their preferences for approach or style +- What problems they're trying to solve + +Don't assume what needs to be done - collaborate to define the scope together. +` diff --git a/coderd/chatd/subagent.go b/coderd/chatd/subagent.go new file mode 100644 index 0000000000..f818f29f50 --- /dev/null +++ b/coderd/chatd/subagent.go @@ -0,0 +1,512 @@ +package chatd + +import ( + "context" + "encoding/json" + "sort" + "strings" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/database" +) + +var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat") + +const ( + subagentAwaitPollInterval = 200 * time.Millisecond + defaultSubagentWaitTimeout = 5 * time.Minute +) + +type spawnAgentArgs struct { + Prompt string `json:"prompt"` + Title string `json:"title,omitempty"` +} + +type waitAgentArgs struct { + ChatID string `json:"chat_id"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` +} + +type messageAgentArgs struct { + ChatID string `json:"chat_id"` + Message string `json:"message"` + Interrupt bool `json:"interrupt,omitempty"` +} + +type closeAgentArgs struct { + ChatID string `json:"chat_id"` +} + +func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.AgentTool { + return []fantasy.AgentTool{ + fantasy.NewAgentTool( + "spawn_agent", + "Spawn a delegated child agent chat from the root chat.", + func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + parent := currentChat() + if parent.ParentChatID.Valid { + return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil + } + + parent, err := p.db.GetChatByID(ctx, parent.ID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + childChat, err := p.createChildSubagentChat( + ctx, + parent, + args.Prompt, + args.Title, + ) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolJSONResponse(map[string]any{ + "chat_id": childChat.ID.String(), + "title": childChat.Title, + "status": string(childChat.Status), + }), nil + }, + ), + fantasy.NewAgentTool( + "wait_agent", + "Wait until a delegated descendant agent reaches a non-streaming status.", + func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + targetChatID, err := parseSubagentToolChatID(args.ChatID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + timeout := defaultSubagentWaitTimeout + if args.TimeoutSeconds != nil { + timeout = time.Duration(*args.TimeoutSeconds) * time.Second + } + + parent := currentChat() + targetChat, report, err := p.awaitSubagentCompletion( + ctx, + parent.ID, + targetChatID, + timeout, + ) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolJSONResponse(map[string]any{ + "chat_id": targetChatID.String(), + "title": targetChat.Title, + "report": report, + "status": string(targetChat.Status), + }), nil + }, + ), + fantasy.NewAgentTool( + "message_agent", + "Send a message to a delegated descendant agent. Use wait_agent to collect a response.", + func(ctx context.Context, args messageAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + targetChatID, err := parseSubagentToolChatID(args.ChatID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + parent := currentChat() + busyBehavior := SendMessageBusyBehaviorQueue + if args.Interrupt { + busyBehavior = SendMessageBusyBehaviorInterrupt + } + targetChat, err := p.sendSubagentMessage( + ctx, + parent.ID, + targetChatID, + args.Message, + busyBehavior, + ) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolJSONResponse(map[string]any{ + "chat_id": targetChatID.String(), + "title": targetChat.Title, + "status": string(targetChat.Status), + "interrupted": args.Interrupt, + }), nil + }, + ), + fantasy.NewAgentTool( + "close_agent", + "Interrupt a delegated descendant agent immediately.", + func(ctx context.Context, args closeAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + targetChatID, err := parseSubagentToolChatID(args.ChatID) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + parent := currentChat() + targetChat, err := p.closeSubagent( + ctx, + parent.ID, + targetChatID, + ) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolJSONResponse(map[string]any{ + "chat_id": targetChatID.String(), + "title": targetChat.Title, + "terminated": true, + "status": string(targetChat.Status), + }), nil + }, + ), + } +} + +func parseSubagentToolChatID(raw string) (uuid.UUID, error) { + chatID, err := uuid.Parse(strings.TrimSpace(raw)) + if err != nil { + return uuid.Nil, xerrors.New("chat_id must be a valid UUID") + } + return chatID, nil +} + +func (p *Server) createChildSubagentChat( + ctx context.Context, + parent database.Chat, + prompt string, + title string, +) (database.Chat, error) { + if parent.ParentChatID.Valid { + return database.Chat{}, xerrors.New("delegated chats cannot create child subagents") + } + + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return database.Chat{}, xerrors.New("prompt is required") + } + + title = strings.TrimSpace(title) + if title == "" { + title = subagentFallbackChatTitle(prompt) + } + + rootChatID := parent.ID + if parent.RootChatID.Valid { + rootChatID = parent.RootChatID.UUID + } + if parent.LastModelConfigID == uuid.Nil { + return database.Chat{}, xerrors.New("parent chat model config id is required") + } + + child, err := p.CreateChat(ctx, CreateOptions{ + OwnerID: parent.OwnerID, + WorkspaceID: parent.WorkspaceID, + WorkspaceAgentID: parent.WorkspaceAgentID, + ParentChatID: uuid.NullUUID{ + UUID: parent.ID, + Valid: true, + }, + RootChatID: uuid.NullUUID{ + UUID: rootChatID, + Valid: true, + }, + ModelConfigID: parent.LastModelConfigID, + Title: title, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: prompt}}, + }) + if err != nil { + return database.Chat{}, xerrors.Errorf("create child chat: %w", err) + } + + return child, nil +} + +func (p *Server) sendSubagentMessage( + ctx context.Context, + parentChatID uuid.UUID, + targetChatID uuid.UUID, + message string, + busyBehavior SendMessageBusyBehavior, +) (database.Chat, error) { + message = strings.TrimSpace(message) + if message == "" { + return database.Chat{}, xerrors.New("message is required") + } + + isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID) + if err != nil { + return database.Chat{}, err + } + if !isDescendant { + return database.Chat{}, ErrSubagentNotDescendant + } + + sendResult, err := p.SendMessage(ctx, SendMessageOptions{ + ChatID: targetChatID, + Content: []fantasy.Content{fantasy.TextContent{Text: message}}, + BusyBehavior: busyBehavior, + }) + if err != nil { + return database.Chat{}, err + } + + return sendResult.Chat, nil +} + +func (p *Server) awaitSubagentCompletion( + ctx context.Context, + parentChatID uuid.UUID, + targetChatID uuid.UUID, + timeout time.Duration, +) (database.Chat, string, error) { + isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID) + if err != nil { + return database.Chat{}, "", err + } + if !isDescendant { + return database.Chat{}, "", ErrSubagentNotDescendant + } + + if timeout <= 0 { + timeout = defaultSubagentWaitTimeout + } + timer := time.NewTimer(timeout) + defer timer.Stop() + + ticker := time.NewTicker(subagentAwaitPollInterval) + defer ticker.Stop() + + for { + targetChat, report, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID) + if checkErr != nil { + return database.Chat{}, "", checkErr + } + if done { + if targetChat.Status == database.ChatStatusError { + reason := strings.TrimSpace(report) + if reason == "" { + reason = "agent reached error status" + } + return database.Chat{}, "", xerrors.New(reason) + } + return targetChat, report, nil + } + + select { + case <-ticker.C: + case <-timer.C: + return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion") + case <-ctx.Done(): + return database.Chat{}, "", ctx.Err() + } + } +} + +func (p *Server) closeSubagent( + ctx context.Context, + parentChatID uuid.UUID, + targetChatID uuid.UUID, +) (database.Chat, error) { + isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID) + if err != nil { + return database.Chat{}, err + } + if !isDescendant { + return database.Chat{}, ErrSubagentNotDescendant + } + + targetChat, err := p.db.GetChatByID(ctx, targetChatID) + if err != nil { + return database.Chat{}, xerrors.Errorf("get target chat: %w", err) + } + + if targetChat.Status == database.ChatStatusWaiting { + return targetChat, nil + } + + updatedChat := p.InterruptChat(ctx, targetChat) + if updatedChat.Status != database.ChatStatusWaiting { + return database.Chat{}, xerrors.New("set target chat waiting") + } + return updatedChat, nil +} + +func (p *Server) checkSubagentCompletion( + ctx context.Context, + chatID uuid.UUID, +) (database.Chat, string, bool, error) { + chat, err := p.db.GetChatByID(ctx, chatID) + if err != nil { + return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err) + } + + if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning { + return database.Chat{}, "", false, nil + } + + report, err := latestSubagentAssistantMessage(ctx, p.db, chatID) + if err != nil { + return database.Chat{}, "", false, err + } + + return chat, report, true, nil +} + +func latestSubagentAssistantMessage( + ctx context.Context, + store database.Store, + chatID uuid.UUID, +) (string, error) { + messages, err := store.GetChatMessagesByChatID(ctx, chatID) + if err != nil { + return "", xerrors.Errorf("get chat messages: %w", err) + } + + sort.Slice(messages, func(i, j int) bool { + if messages[i].CreatedAt.Equal(messages[j].CreatedAt) { + return messages[i].ID < messages[j].ID + } + return messages[i].CreatedAt.Before(messages[j].CreatedAt) + }) + + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + if message.Role != string(fantasy.MessageRoleAssistant) || + message.Visibility == database.ChatMessageVisibilityModel { + continue + } + + content, parseErr := chatprompt.ParseContent(message.Role, message.Content) + if parseErr != nil { + continue + } + text := strings.TrimSpace(contentBlocksToText(content)) + if text == "" { + continue + } + return text, nil + } + + return "", nil +} + +func isSubagentDescendant( + ctx context.Context, + store database.Store, + ancestorChatID uuid.UUID, + targetChatID uuid.UUID, +) (bool, error) { + if ancestorChatID == targetChatID { + return false, nil + } + + descendants, err := listSubagentDescendants(ctx, store, ancestorChatID) + if err != nil { + return false, err + } + for _, descendant := range descendants { + if descendant.ID == targetChatID { + return true, nil + } + } + return false, nil +} + +func listSubagentDescendants( + ctx context.Context, + store database.Store, + chatID uuid.UUID, +) ([]database.Chat, error) { + queue := []uuid.UUID{chatID} + visited := map[uuid.UUID]struct{}{chatID: {}} + + out := make([]database.Chat, 0) + for len(queue) > 0 { + parentChatID := queue[0] + queue = queue[1:] + + children, err := store.ListChildChatsByParentID(ctx, parentChatID) + if err != nil { + return nil, xerrors.Errorf("list child chats for %s: %w", parentChatID, err) + } + + for _, child := range children { + if _, ok := visited[child.ID]; ok { + continue + } + visited[child.ID] = struct{}{} + out = append(out, child) + queue = append(queue, child.ID) + } + } + + return out, nil +} + +func subagentFallbackChatTitle(message string) string { + const maxWords = 6 + const maxRunes = 80 + + words := strings.Fields(message) + if len(words) == 0 { + return "New Chat" + } + + truncated := false + if len(words) > maxWords { + words = words[:maxWords] + truncated = true + } + + title := strings.Join(words, " ") + if truncated { + title += "..." + } + + return subagentTruncateRunes(title, maxRunes) +} + +func subagentTruncateRunes(value string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + + return string(runes[:maxRunes]) +} + +func toolJSONResponse(result map[string]any) fantasy.ToolResponse { + data, err := json.Marshal(result) + if err != nil { + return fantasy.NewTextResponse("{}") + } + return fantasy.NewTextResponse(string(data)) +} diff --git a/coderd/chatd/title.go b/coderd/chatd/title.go new file mode 100644 index 0000000000..efae88fdbd --- /dev/null +++ b/coderd/chatd/title.go @@ -0,0 +1,216 @@ +package chatd + +import ( + "context" + "strings" + "time" + + "charm.land/fantasy" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/database" + coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" +) + +const titleGenerationPrompt = "Generate a concise title (max 8 words, under 128 characters) for " + + "the user's first message. Return plain text only β€” no quotes, no emoji, " + + "no markdown, no special characters." + +// maybeGenerateChatTitle generates an AI title for the chat when +// appropriate (first user message, no assistant reply yet, and the +// current title is either empty or still the fallback truncation). +// It is a best-effort operation that logs and swallows errors. +func (p *Server) maybeGenerateChatTitle( + ctx context.Context, + chat database.Chat, + messages []database.ChatMessage, + model fantasy.LanguageModel, + logger slog.Logger, +) { + input, ok := titleInput(chat, messages) + if !ok { + return + } + + titleCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + title, err := generateTitle(titleCtx, model, input) + if err != nil { + logger.Debug(ctx, "failed to generate chat title", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return + } + if title == "" || title == chat.Title { + return + } + + _, err = p.db.UpdateChatByID(ctx, database.UpdateChatByIDParams{ + ID: chat.ID, + Title: title, + }) + if err != nil { + logger.Warn(ctx, "failed to update generated chat title", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return + } + chat.Title = title + p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange) +} + +// generateTitle calls the model with a title-generation system prompt +// and returns the normalized result. +func generateTitle( + ctx context.Context, + model fantasy.LanguageModel, + input string, +) (string, error) { + prompt := []fantasy.Message{ + { + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: titleGenerationPrompt}, + }, + }, + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: input}, + }, + }, + } + toolChoice := fantasy.ToolChoiceNone + response, err := model.Generate(ctx, fantasy.Call{ + Prompt: prompt, + ToolChoice: &toolChoice, + }) + if err != nil { + return "", xerrors.Errorf("generate title text: %w", err) + } + + title := normalizeTitleOutput(contentBlocksToText(response.Content)) + if title == "" { + return "", xerrors.New("generated title was empty") + } + return title, nil +} + +// titleInput returns the first user message text and whether title +// generation should proceed. It returns false when the chat already +// has assistant/tool replies, has more than one visible user message, +// or the current title doesn't look like a candidate for replacement. +func titleInput( + chat database.Chat, + messages []database.ChatMessage, +) (string, bool) { + userCount := 0 + firstUserText := "" + + for _, message := range messages { + if message.Visibility == database.ChatMessageVisibilityModel { + continue + } + + switch message.Role { + case string(fantasy.MessageRoleAssistant), string(fantasy.MessageRoleTool): + return "", false + case string(fantasy.MessageRoleUser): + userCount++ + if firstUserText == "" { + parsed, err := chatprompt.ParseContent( + string(fantasy.MessageRoleUser), message.Content, + ) + if err != nil { + return "", false + } + firstUserText = strings.TrimSpace( + contentBlocksToText(parsed), + ) + } + } + } + + if userCount != 1 || firstUserText == "" { + return "", false + } + + currentTitle := strings.TrimSpace(chat.Title) + if currentTitle == "" { + return firstUserText, true + } + + if currentTitle != fallbackChatTitle(firstUserText) { + return "", false + } + + return firstUserText, true +} + +func normalizeTitleOutput(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "" + } + + title = strings.Trim(title, "\"'`") + title = strings.Join(strings.Fields(title), " ") + return truncateRunes(title, 80) +} + +func fallbackChatTitle(message string) string { + const maxWords = 6 + const maxRunes = 80 + + words := strings.Fields(message) + if len(words) == 0 { + return "New Chat" + } + + truncated := false + if len(words) > maxWords { + words = words[:maxWords] + truncated = true + } + + title := strings.Join(words, " ") + if truncated { + title += "…" + } + + return truncateRunes(title, maxRunes) +} + +// contentBlocksToText concatenates the text parts of content blocks +// into a single space-separated string. +func contentBlocksToText(content []fantasy.Content) string { + parts := make([]string, 0, len(content)) + for _, block := range content { + textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block) + if !ok { + continue + } + text := strings.TrimSpace(textBlock.Text) + if text == "" { + continue + } + parts = append(parts, text) + } + return strings.Join(parts, " ") +} + +func truncateRunes(value string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + return string(runes[:maxLen]) +} diff --git a/coderd/chats.go b/coderd/chats.go new file mode 100644 index 0000000000..08ad05a2fe --- /dev/null +++ b/coderd/chats.go @@ -0,0 +1,3093 @@ +package coderd + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "charm.land/fantasy" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/chatd" + "github.com/coder/coder/v2/coderd/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" +) + +const ( + chatDiffStatusTTL = 120 * time.Second + chatDiffBackgroundRefreshTimeout = 20 * time.Second + githubAPIBaseURL = "https://api.github.com" + chatStreamBatchSize = 256 + + chatContextLimitModelConfigKey = "context_limit" + chatContextCompressionThresholdModelConfigKey = "context_compression_threshold" + defaultChatContextCompressionThreshold = int32(70) + minChatContextCompressionThreshold = int32(0) + maxChatContextCompressionThreshold = int32(100) +) + +// chatDiffRefreshBackoffSchedule defines the delays between successive +// background diff refresh attempts. The trigger fires when the agent +// obtains a GitHub token, which is typically right before a git push +// or PR creation. The backoff gives progressively more time for the +// push and any PR workflow to complete before querying the GitHub API. +var chatDiffRefreshBackoffSchedule = []time.Duration{ + 1 * time.Second, + 3 * time.Second, + 5 * time.Second, + 10 * time.Second, + 20 * time.Second, +} + +// chatGitRef holds the branch and remote origin reported by the +// workspace agent during a git operation. +type chatGitRef struct { + Branch string + RemoteOrigin string +} + +var ( + githubPullRequestPathPattern = regexp.MustCompile( + `^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([0-9]+)(?:[/?#].*)?$`, + ) + githubRepositoryHTTPSPattern = regexp.MustCompile( + `^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`, + ) + githubRepositorySSHPathPattern = regexp.MustCompile( + `^(?:ssh://)?git@github\.com[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`, + ) +) + +type githubPullRequestRef struct { + Owner string + Repo string + Number int +} + +type githubPullRequestStatus struct { + PullRequestState string + ChangesRequested bool + Additions int32 + Deletions int32 + ChangedFiles int32 +} + +type chatRepositoryRef struct { + Provider string + RemoteOrigin string + Branch string + Owner string + Repo string +} + +type chatDiffReference struct { + PullRequestURL string + RepositoryRef *chatRepositoryRef +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + sendEvent, senderClosed, err := httpapi.OneWayWebSocketEventSender(api.Logger)(rw, r) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to open chat watch stream.", + Detail: err.Error(), + }) + return + } + defer func() { + <-senderClosed + }() + + cancelSubscribe, err := api.Pubsub.SubscribeWithErr(pubsub.ChatEventChannel(apiKey.UserID), + pubsub.HandleChatEvent( + func(ctx context.Context, payload pubsub.ChatEvent, err error) { + if err != nil { + api.Logger.Error(ctx, "chat event subscription error", slog.Error(err)) + return + } + _ = sendEvent(codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeData, + Data: payload, + }) + }, + )) + if err != nil { + _ = sendEvent(codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error subscribing to chat events.", + Detail: err.Error(), + }, + }) + return + } + defer cancelSubscribe() + + // Send initial ping to signal the connection is ready. + _ = sendEvent(codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypePing, + }) + + for { + select { + case <-ctx.Done(): + return + case <-senderClosed: + return + } + } +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + chats, err := api.Database.GetChatsByOwnerID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats.", + Detail: err.Error(), + }) + return + } + + diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, chats) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertChats(chats, diffStatusesByChatID)) +} + +func (api *API) getChatDiffStatusesByChatID( + ctx context.Context, + chats []database.Chat, +) (map[uuid.UUID]database.ChatDiffStatus, error) { + if len(chats) == 0 { + return map[uuid.UUID]database.ChatDiffStatus{}, nil + } + + chatIDs := make([]uuid.UUID, 0, len(chats)) + for _, chat := range chats { + chatIDs = append(chatIDs, chat.ID) + } + + statuses, err := api.Database.GetChatDiffStatusesByChatIDs(ctx, chatIDs) + if err != nil { + return nil, xerrors.Errorf("get chat diff statuses: %w", err) + } + + statusesByChatID := make(map[uuid.UUID]database.ChatDiffStatus, len(statuses)) + for _, status := range statuses { + statusesByChatID[status.ChatID] = status + } + return statusesByChatID, nil +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + var req codersdk.CreateChatRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + contentBlocks, titleSource, inputError := createChatInputFromRequest(req) + if inputError != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError) + return + } + + workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ctx, req) + if validationError != nil { + httpapi.Write(ctx, rw, validationStatus, *validationError) + return + } + + title := chatTitleFromMessage(titleSource) + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + modelConfigID, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, req) + if modelConfigError != nil { + httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError) + return + } + + chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: apiKey.UserID, + WorkspaceID: workspaceSelection.WorkspaceID, + WorkspaceAgentID: workspaceSelection.WorkspaceAgentID, + Title: title, + ModelConfigID: modelConfigID, + SystemPrompt: defaultChatSystemPrompt(), + InitialUserContent: contentBlocks, + }) + if err != nil { + if database.IsForeignKeyViolation( + err, + database.ForeignKeyChatsLastModelConfigID, + database.ForeignKeyChatMessagesModelConfigID, + ) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid model config ID.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, nil)) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + //nolint:gocritic // System context required to read enabled chat models. + systemCtx := dbauthz.AsSystemRestricted(ctx) + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + enabledProviders, err := api.Database.GetEnabledChatProviders( + systemCtx, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to load chat model configuration.", + Detail: err.Error(), + }) + return + } + enabledModels, err := api.Database.GetEnabledChatModelConfigs( + systemCtx, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to load chat model configuration.", + Detail: err.Error(), + }) + return + } + + configuredProviders := make( + []chatprovider.ConfiguredProvider, 0, len(enabledProviders), + ) + for _, provider := range enabledProviders { + configuredProviders = append( + configuredProviders, chatprovider.ConfiguredProvider{ + Provider: provider.Provider, + APIKey: provider.APIKey, + BaseURL: provider.BaseUrl, + }, + ) + } + configuredModels := make( + []chatprovider.ConfiguredModel, 0, len(enabledModels), + ) + for _, model := range enabledModels { + configuredModels = append(configuredModels, chatprovider.ConfiguredModel{ + Provider: model.Provider, + Model: model.Model, + DisplayName: model.DisplayName, + }) + } + + keys := chatprovider.MergeProviderAPIKeys( + chatProviderAPIKeysFromDeploymentValues(api.DeploymentValues), + configuredProviders, + ) + catalog := chatprovider.NewModelCatalog(keys) + var response codersdk.ChatModelsResponse + if configured, ok := catalog.ListConfiguredModels( + configuredProviders, configuredModels, + ); ok { + response = configured + } else { + response = catalog.ListConfiguredProviderAvailability(configuredProviders) + } + + httpapi.Write(ctx, rw, http.StatusOK, response) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + messages, err := api.Database.GetChatMessagesByChatID(ctx, chatID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat messages.", + Detail: err.Error(), + }) + return + } + + queuedMessages, err := api.Database.GetChatQueuedMessages(ctx, chatID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get queued messages.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWithMessages{ + Chat: convertChat(chat, nil), + Messages: convertChatMessages(messages), + QueuedMessages: convertChatQueuedMessages(queuedMessages), + }) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + var err error + if api.chatDaemon != nil { + err = api.chatDaemon.DeleteChat(ctx, chatID) + } else { + err = deleteChatTree(ctx, api.Database, chatID) + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete chat.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +func deleteChatTree( + ctx context.Context, + store database.Store, + chatID uuid.UUID, +) error { + // Child chats (sub-agent chats) reference their parent via + // parent_chat_id with ON DELETE SET NULL, so without explicit + // cleanup they would become orphaned root-level items. + return store.InTx(func(tx database.Store) error { + // Recursively collect all descendant chat IDs. + var descendantIDs []uuid.UUID + queue := []uuid.UUID{chatID} + for len(queue) > 0 { + parentID := queue[0] + queue = queue[1:] + children, err := tx.ListChildChatsByParentID(ctx, parentID) + if err != nil { + return xerrors.Errorf("list children of chat %s: %w", parentID, err) + } + for _, child := range children { + descendantIDs = append(descendantIDs, child.ID) + queue = append(queue, child.ID) + } + } + + // Delete descendants first. The FK is ON DELETE SET NULL so + // order doesn't strictly matter, but deleting children before + // parents is cleaner. + for i := len(descendantIDs) - 1; i >= 0; i-- { + if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil { + return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err) + } + } + + // Delete the target chat itself. + if err := tx.DeleteChatByID(ctx, chatID); err != nil { + return xerrors.Errorf("delete chat: %w", err) + } + + return nil + }, nil) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + var req codersdk.CreateChatMessageRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + contentBlocks, _, inputError := createChatInputFromParts(req.Content, "content") + if inputError != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: inputError.Message, + Detail: inputError.Detail, + }) + return + } + + sendResult, sendErr := api.chatDaemon.SendMessage( + ctx, + chatd.SendMessageOptions{ + ChatID: chatID, + Content: contentBlocks, + ModelConfigID: req.ModelConfigID, + BusyBehavior: chatd.SendMessageBusyBehaviorQueue, + }, + ) + if sendErr != nil { + if xerrors.Is(sendErr, chatd.ErrMessageQueueFull) { + httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{ + Message: "Message queue is full.", + Detail: fmt.Sprintf("Maximum %d messages can be queued.", chatd.MaxQueueSize), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat message.", + Detail: sendErr.Error(), + }) + return + } + + response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued} + if sendResult.Queued { + if sendResult.QueuedMessage != nil { + response.QueuedMessage = convertChatQueuedMessagePtr(*sendResult.QueuedMessage) + } + } else { + message := convertChatMessage(sendResult.Message) + response.Message = &message + } + + httpapi.Write(ctx, rw, http.StatusOK, response) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + messageIDStr := chi.URLParam(r, "message") + messageID, err := strconv.ParseInt(messageIDStr, 10, 64) + if err != nil || messageID <= 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat message ID.", + Detail: "Message ID must be a positive integer.", + }) + return + } + + var req codersdk.EditChatMessageRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + contentBlocks, _, inputError := createChatInputFromParts(req.Content, "content") + if inputError != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: inputError.Message, + Detail: inputError.Detail, + }) + return + } + + editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + EditedMessageID: messageID, + Content: contentBlocks, + }) + if editErr != nil { + switch { + case xerrors.Is(editErr, chatd.ErrEditedMessageNotFound): + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Chat message not found.", + Detail: "Message does not belong to this chat.", + }) + case xerrors.Is(editErr, chatd.ErrEditedMessageNotUser): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Only user messages can be edited.", + }) + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to edit chat message.", + Detail: editErr.Error(), + }) + } + return + } + + message := convertChatMessage(editResult.Message) + httpapi.Write(ctx, rw, http.StatusOK, message) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) deleteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + queuedMessageIDStr := chi.URLParam(r, "queuedMessage") + queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid queued message ID.", + Detail: err.Error(), + }) + return + } + + if api.chatDaemon != nil { + err = api.chatDaemon.DeleteQueued(ctx, chatID, queuedMessageID) + } else { + err = api.Database.DeleteChatQueuedMessage(ctx, database.DeleteChatQueuedMessageParams{ + ID: queuedMessageID, + ChatID: chatID, + }) + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete queued message.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + queuedMessageIDStr := chi.URLParam(r, "queuedMessage") + queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid queued message ID.", + Detail: err.Error(), + }) + return + } + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + promoteResult, txErr := api.chatDaemon.PromoteQueued(ctx, chatd.PromoteQueuedOptions{ + ChatID: chatID, + QueuedMessageID: queuedMessageID, + }) + + if txErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to promote queued message.", + Detail: txErr.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertChatMessage(promoteResult.PromotedMessage)) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat streaming is not available.", + Detail: "Chat processor is not configured.", + }) + return + } + + sendEvent, senderClosed, err := httpapi.OneWayWebSocketEventSender(api.Logger)(rw, r) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to open chat stream.", + Detail: err.Error(), + }) + return + } + defer func() { + <-senderClosed + }() + + snapshot, events, cancel, ok := api.chatDaemon.Subscribe(ctx, chatID, r.Header) + if !ok { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat streaming is not available.", + Detail: "Chat stream state is not configured.", + }) + return + } + defer cancel() + + sendChatStreamBatch := func(batch []codersdk.ChatStreamEvent) error { + if len(batch) == 0 { + return nil + } + return sendEvent(codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeData, + Data: batch, + }) + } + + drainChatStreamBatch := func( + first codersdk.ChatStreamEvent, + maxBatchSize int, + ) ([]codersdk.ChatStreamEvent, bool) { + batch := []codersdk.ChatStreamEvent{first} + if maxBatchSize <= 1 { + return batch, false + } + + for len(batch) < maxBatchSize { + select { + case event, ok := <-events: + if !ok { + return batch, true + } + batch = append(batch, event) + default: + return batch, false + } + } + + return batch, false + } + + for start := 0; start < len(snapshot); start += chatStreamBatchSize { + end := start + chatStreamBatchSize + if end > len(snapshot) { + end = len(snapshot) + } + if err := sendChatStreamBatch(snapshot[start:end]); err != nil { + api.Logger.Debug(ctx, "failed to send chat stream snapshot", slog.Error(err)) + return + } + } + + for { + select { + case <-ctx.Done(): + return + case <-senderClosed: + return + case firstEvent, ok := <-events: + if !ok { + return + } + batch, streamClosed := drainChatStreamBatch( + firstEvent, + chatStreamBatchSize, + ) + if err := sendChatStreamBatch(batch); err != nil { + api.Logger.Debug(ctx, "failed to send chat stream event", slog.Error(err)) + return + } + if streamClosed { + return + } + } + } +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + if api.chatDaemon != nil { + chat = api.chatDaemon.InterruptChat(ctx, chat) + } else { + updatedChat, updateErr := api.Database.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chatID, + Status: database.ChatStatusWaiting, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + }) + if updateErr != nil { + api.Logger.Error(ctx, "failed to mark chat as waiting", + slog.F("chat_id", chatID), slog.Error(updateErr)) + } else { + chat = updatedChat + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil)) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) getChatDiffStatus(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + status, err := api.resolveChatDiffStatus(ctx, chat) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat diff status.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertChatDiffStatus(chatID, status)) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) getChatDiffContents(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + + diff, err := api.resolveChatDiffContents(ctx, chat) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat diff.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, diff) +} + +// chatCreateWorkspace provides workspace creation for the chat +// processor. RBAC authorization uses context-based checks via +// dbauthz.As rather than fake *http.Request objects. +func (api *API) chatCreateWorkspace( + ctx context.Context, + ownerID uuid.UUID, + req codersdk.CreateWorkspaceRequest, +) (codersdk.Workspace, error) { + actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll) + if err != nil { + return codersdk.Workspace{}, xerrors.Errorf("load user authorization: %w", err) + } + ctx = dbauthz.As(ctx, actor) + + ownerUser, err := api.Database.GetUserByID(ctx, ownerID) + if err != nil { + return codersdk.Workspace{}, xerrors.Errorf("get workspace owner: %w", err) + } + owner := workspaceOwner{ + ID: ownerUser.ID, + Username: ownerUser.Username, + AvatarURL: ownerUser.AvatarURL, + } + + auditor := api.Auditor.Load() + if auditor == nil { + return codersdk.Workspace{}, xerrors.New("auditor is not configured") + } + + // The audit system requires a ResponseWriter to capture the + // HTTP status code. Since this is a programmatic call, we use + // a recorder. The audit entry still captures the owner, action, + // and resource correctly. + rw := httptest.NewRecorder() + sw := &tracing.StatusWriter{ResponseWriter: rw} + + // Build a minimal synthetic request so the audit commit + // closure can extract a request ID and user agent. The RBAC + // subject is already on the context via dbauthz.As above. + auditReq, err := http.NewRequestWithContext( + httpmw.WithRequestID(ctx, uuid.New()), + http.MethodPost, + "http://localhost/internal/chat/workspace", + nil, + ) + if err != nil { + return codersdk.Workspace{}, xerrors.Errorf("create audit request: %w", err) + } + + aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](sw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: auditReq, + Action: database.AuditActionCreate, + AdditionalFields: audit.AdditionalFields{ + WorkspaceOwner: owner.Username, + }, + }) + aReq.UserID = ownerID + defer commitAudit() + + workspace, err := createWorkspace(ctx, aReq, ownerID, api, owner, req, nil) + if err != nil { + sw.WriteHeader(chatWorkspaceAuditStatus(err)) + return codersdk.Workspace{}, err + } + + sw.WriteHeader(http.StatusCreated) + return workspace, nil +} + +func chatWorkspaceAuditStatus(err error) int { + if responder, ok := httperror.IsResponder(err); ok { + status, _ := responder.Response() + return status + } + return http.StatusInternalServerError +} + +func (api *API) resolveChatDiffStatus( + ctx context.Context, + chat database.Chat, +) (*database.ChatDiffStatus, error) { + return api.resolveChatDiffStatusWithOptions(ctx, chat, false) +} + +//nolint:revive // Boolean forces cache refresh bypass. +func (api *API) resolveChatDiffStatusWithOptions( + ctx context.Context, + chat database.Chat, + forceRefresh bool, +) (*database.ChatDiffStatus, error) { + status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID) + if err != nil { + return nil, err + } + + now := time.Now().UTC() + + reference, err := api.resolveChatDiffReference(ctx, chat, found, status) + if err != nil { + return nil, err + } + if reference.PullRequestURL != "" { + if !found || !strings.EqualFold(strings.TrimSpace(status.Url.String), reference.PullRequestURL) { + status, err = api.upsertChatDiffStatusReference(ctx, chat.ID, reference.PullRequestURL, now.Add(-time.Second)) + if err != nil { + return nil, err + } + found = true + } + } + + if !found { + return nil, nil //nolint:nilnil // Callers handle nil status explicitly. + } + if reference.PullRequestURL == "" { + return &status, nil + } + if !shouldRefreshChatDiffStatus(status, now, forceRefresh) { + return &status, nil + } + + refreshed, err := api.refreshChatDiffStatus( + ctx, + chat.OwnerID, + chat.ID, + reference.PullRequestURL, + ) + if err == nil { + return &refreshed, nil + } + + api.Logger.Warn(ctx, "failed to refresh chat diff status", + slog.F("chat_id", chat.ID), + slog.F("pull_request_url", reference.PullRequestURL), + slog.Error(err), + ) + + backoffStatus, backoffErr := api.upsertChatDiffStatusReference(ctx, chat.ID, reference.PullRequestURL, now.Add(chatDiffStatusTTL)) + if backoffErr != nil { + api.Logger.Warn(ctx, "failed to extend chat diff status stale timestamp", + slog.F("chat_id", chat.ID), + slog.Error(backoffErr), + ) + return &status, nil + } + + return &backoffStatus, nil +} + +//nolint:revive // Boolean forces cache refresh bypass. +func shouldRefreshChatDiffStatus(status database.ChatDiffStatus, now time.Time, forceRefresh bool) bool { + if forceRefresh { + return true + } + return chatDiffStatusIsStale(status, now) +} + +func (api *API) triggerWorkspaceChatDiffStatusRefresh(workspace database.Workspace, gitRef chatGitRef) { + if workspace.ID == uuid.Nil || workspace.OwnerID == uuid.Nil { + return + } + + go func(workspaceID, workspaceOwnerID uuid.UUID, gitRef chatGitRef) { + ctx := api.ctx + if ctx == nil { + ctx = context.Background() + } + //nolint:gocritic // Background goroutine for diff status refresh has no user context. + ctx = dbauthz.AsSystemRestricted(ctx) + + // Always store the git ref so the data is persisted even + // before a PR exists. The frontend can show branch info + // and the refresh loop can resolve a PR later. + api.storeChatGitRef(ctx, workspaceID, workspaceOwnerID, gitRef) + + for _, delay := range chatDiffRefreshBackoffSchedule { + t := api.Clock.NewTimer(delay, "chat_diff_refresh") + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + } + + // Refresh and publish status on every iteration. + // Stop the loop once a PR is discovered β€” there's + // nothing more to wait for after that. + if api.refreshWorkspaceChatDiffStatuses(ctx, workspaceID, workspaceOwnerID) { + return + } + } + }(workspace.ID, workspace.OwnerID, gitRef) +} + +// storeChatGitRef persists the git branch and remote origin reported +// by the workspace agent on all chats associated with the workspace. +func (api *API) storeChatGitRef(ctx context.Context, workspaceID, workspaceOwnerID uuid.UUID, gitRef chatGitRef) { + chats, err := api.Database.GetChatsByOwnerID(ctx, workspaceOwnerID) + if err != nil { + api.Logger.Warn(ctx, "failed to list chats for git ref storage", + slog.F("workspace_id", workspaceID), + slog.Error(err), + ) + return + } + + for _, chat := range filterChatsByWorkspaceID(chats, workspaceID) { + _, err := api.Database.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{ + ChatID: chat.ID, + GitBranch: gitRef.Branch, + GitRemoteOrigin: gitRef.RemoteOrigin, + StaleAt: time.Now().UTC().Add(-time.Second), + Url: sql.NullString{}, + }) + if err != nil { + api.Logger.Warn(ctx, "failed to store git ref on chat diff status", + slog.F("chat_id", chat.ID), + slog.F("workspace_id", workspaceID), + slog.Error(err), + ) + } + } +} + +// refreshWorkspaceChatDiffStatuses refreshes the diff status for all +// chats associated with the given workspace. It returns true when +// every chat has a PR URL resolved, signaling that the caller can +// stop polling. +func (api *API) refreshWorkspaceChatDiffStatuses(ctx context.Context, workspaceID, workspaceOwnerID uuid.UUID) bool { + chats, err := api.Database.GetChatsByOwnerID(ctx, workspaceOwnerID) + if err != nil { + api.Logger.Warn(ctx, "failed to list workspace owner chats for diff refresh", + slog.F("workspace_id", workspaceID), + slog.F("workspace_owner_id", workspaceOwnerID), + slog.Error(err), + ) + return false + } + + filtered := filterChatsByWorkspaceID(chats, workspaceID) + if len(filtered) == 0 { + return false + } + + allHavePR := true + for _, chat := range filtered { + refreshCtx, cancel := context.WithTimeout(ctx, chatDiffBackgroundRefreshTimeout) + status, err := api.resolveChatDiffStatusWithOptions(refreshCtx, chat, true) + cancel() + if err != nil { + api.Logger.Warn(ctx, "failed to refresh chat diff status after workspace external auth", + slog.F("workspace_id", workspaceID), + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + allHavePR = false + } else if status == nil || !status.Url.Valid || strings.TrimSpace(status.Url.String) == "" { + allHavePR = false + } + + api.publishChatStatusEvent(ctx, chat.ID) + } + + return allHavePR +} + +func filterChatsByWorkspaceID(chats []database.Chat, workspaceID uuid.UUID) []database.Chat { + filteredChats := make([]database.Chat, 0, len(chats)) + for _, chat := range chats { + if !chat.WorkspaceID.Valid || chat.WorkspaceID.UUID != workspaceID { + continue + } + filteredChats = append(filteredChats, chat) + } + return filteredChats +} + +func (api *API) publishChatStatusEvent(ctx context.Context, chatID uuid.UUID) { + if api.chatDaemon == nil { + return + } + + if err := api.chatDaemon.RefreshStatus(ctx, chatID); err != nil { + api.Logger.Debug(ctx, "failed to refresh published chat status", + slog.F("chat_id", chatID), + slog.Error(err), + ) + } +} + +func (api *API) resolveChatDiffContents( + ctx context.Context, + chat database.Chat, +) (codersdk.ChatDiffContents, error) { + result := codersdk.ChatDiffContents{ChatID: chat.ID} + + status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID) + if err != nil { + return result, err + } + + reference, err := api.resolveChatDiffReference(ctx, chat, found, status) + if err != nil { + return result, err + } + + if reference.RepositoryRef != nil { + provider := strings.TrimSpace(reference.RepositoryRef.Provider) + if provider != "" { + result.Provider = &provider + } + + origin := strings.TrimSpace(reference.RepositoryRef.RemoteOrigin) + if origin != "" { + result.RemoteOrigin = &origin + } + + branch := strings.TrimSpace(reference.RepositoryRef.Branch) + if branch != "" { + result.Branch = &branch + } + } + + if reference.PullRequestURL != "" { + pullRequestURL := strings.TrimSpace(reference.PullRequestURL) + result.PullRequestURL = &pullRequestURL + if !found || !strings.EqualFold(strings.TrimSpace(status.Url.String), pullRequestURL) { + _, err := api.upsertChatDiffStatusReference(ctx, chat.ID, pullRequestURL, time.Now().UTC().Add(-time.Second)) + if err != nil { + return result, err + } + } + } + + if reference.RepositoryRef == nil { + return result, nil + } + if !strings.EqualFold(reference.RepositoryRef.Provider, string(codersdk.EnhancedExternalAuthProviderGitHub)) { + return result, nil + } + + token := api.resolveChatGitHubAccessToken(ctx, chat.OwnerID) + + if reference.PullRequestURL != "" { + diff, err := api.fetchGitHubPullRequestDiff(ctx, reference.PullRequestURL, token) + if err != nil { + return result, err + } + result.Diff = diff + return result, nil + } + + diff, err := api.fetchGitHubCompareDiff(ctx, *reference.RepositoryRef, token) + if err != nil { + return result, err + } + result.Diff = diff + return result, nil +} + +// resolveChatDiffReference builds the diff reference from the cached +// status stored in the database. The git branch and remote origin are +// populated by the workspace agent during git operations (via the +// gitaskpass flow), so no SSH into the workspace is needed here. +// +//nolint:revive // Boolean indicates whether diff status was found. +func (api *API) resolveChatDiffReference( + ctx context.Context, + chat database.Chat, + found bool, + status database.ChatDiffStatus, +) (chatDiffReference, error) { + reference := chatDiffReference{} + if !found { + return reference, nil + } + + reference.PullRequestURL = strings.TrimSpace(status.Url.String) + + // Build the repository ref from the stored git branch/origin + // that the agent reported. + reference.RepositoryRef = api.buildChatRepositoryRefFromStatus(status) + + // If we have a repo ref with a branch, try to resolve the + // current open PR. This picks up new PRs after the previous + // one was closed. + if reference.RepositoryRef != nil && + strings.EqualFold(reference.RepositoryRef.Provider, string(codersdk.EnhancedExternalAuthProviderGitHub)) { + pullRequestURL, lookupErr := api.resolveGitHubPullRequestURLFromRepositoryRef(ctx, chat.OwnerID, *reference.RepositoryRef) + if lookupErr != nil { + api.Logger.Debug(ctx, "failed to resolve pull request from repository reference", + slog.F("chat_id", chat.ID), + slog.F("provider", reference.RepositoryRef.Provider), + slog.F("remote_origin", reference.RepositoryRef.RemoteOrigin), + slog.F("branch", reference.RepositoryRef.Branch), + slog.Error(lookupErr), + ) + } else if pullRequestURL != "" { + reference.PullRequestURL = pullRequestURL + } + } + + reference.PullRequestURL = normalizeGitHubPullRequestURL(reference.PullRequestURL) + + // If we have a PR URL but no repo ref (e.g. the agent hasn't + // reported branch/origin yet), derive a partial ref from the + // PR URL so the caller can still show provider/owner/repo. + if reference.RepositoryRef == nil && reference.PullRequestURL != "" { + if parsed, ok := parseGitHubPullRequestURL(reference.PullRequestURL); ok { + reference.RepositoryRef = &chatRepositoryRef{ + Provider: string(codersdk.EnhancedExternalAuthProviderGitHub), + RemoteOrigin: fmt.Sprintf("https://github.com/%s/%s", parsed.Owner, parsed.Repo), + Owner: parsed.Owner, + Repo: parsed.Repo, + } + } + } + + return reference, nil +} + +// buildChatRepositoryRefFromStatus constructs a chatRepositoryRef +// from the git branch and remote origin stored in the cached status. +// Returns nil if no ref data is available. +func (api *API) buildChatRepositoryRefFromStatus(status database.ChatDiffStatus) *chatRepositoryRef { + branch := strings.TrimSpace(status.GitBranch) + origin := strings.TrimSpace(status.GitRemoteOrigin) + if branch == "" || origin == "" { + return nil + } + + repoRef := &chatRepositoryRef{ + Provider: strings.TrimSpace(api.resolveExternalAuthProviderType(origin)), + RemoteOrigin: origin, + Branch: branch, + } + + if owner, repo, normalizedOrigin, ok := parseGitHubRepositoryOrigin(repoRef.RemoteOrigin); ok { + if repoRef.Provider == "" { + repoRef.Provider = string(codersdk.EnhancedExternalAuthProviderGitHub) + } + repoRef.RemoteOrigin = normalizedOrigin + repoRef.Owner = owner + repoRef.Repo = repo + } + + if repoRef.Provider == "" { + return nil + } + + return repoRef +} + +func (api *API) upsertChatDiffStatusReference( + ctx context.Context, + chatID uuid.UUID, + pullRequestURL string, + staleAt time.Time, +) (database.ChatDiffStatus, error) { + status, err := api.Database.UpsertChatDiffStatusReference( + ctx, + database.UpsertChatDiffStatusReferenceParams{ + ChatID: chatID, + Url: sql.NullString{ + String: pullRequestURL, + Valid: strings.TrimSpace(pullRequestURL) != "", + }, + // Empty strings preserve existing values via the + // CASE expression in the SQL query. + GitBranch: "", + GitRemoteOrigin: "", + StaleAt: staleAt, + }, + ) + if err != nil { + return database.ChatDiffStatus{}, xerrors.Errorf("upsert chat diff status reference: %w", err) + } + return status, nil +} + +func (api *API) getCachedChatDiffStatus( + ctx context.Context, + chatID uuid.UUID, +) (database.ChatDiffStatus, bool, error) { + status, err := api.Database.GetChatDiffStatusByChatID(ctx, chatID) + if err == nil { + return status, true, nil + } + if xerrors.Is(err, sql.ErrNoRows) { + return database.ChatDiffStatus{}, false, nil + } + return database.ChatDiffStatus{}, false, xerrors.Errorf( + "get chat diff status: %w", + err, + ) +} + +func (api *API) resolveExternalAuthProviderType(match string) string { + match = strings.TrimSpace(match) + if match == "" { + return "" + } + + for _, extAuth := range api.ExternalAuthConfigs { + if extAuth.Regex == nil || !extAuth.Regex.MatchString(match) { + continue + } + return strings.ToLower(strings.TrimSpace(extAuth.Type)) + } + + return "" +} + +func parseGitHubRepositoryOrigin(raw string) (owner string, repo string, normalizedOrigin string, ok bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", "", false + } + + matches := githubRepositoryHTTPSPattern.FindStringSubmatch(raw) + if len(matches) != 3 { + matches = githubRepositorySSHPathPattern.FindStringSubmatch(raw) + } + if len(matches) != 3 { + return "", "", "", false + } + + owner = strings.TrimSpace(matches[1]) + repo = strings.TrimSpace(matches[2]) + repo = strings.TrimSuffix(repo, ".git") + if owner == "" || repo == "" { + return "", "", "", false + } + + return owner, repo, fmt.Sprintf("https://github.com/%s/%s", owner, repo), true +} + +func buildGitHubBranchURL(owner string, repo string, branch string) string { + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + branch = strings.TrimSpace(branch) + if owner == "" || repo == "" || branch == "" { + return "" + } + + return fmt.Sprintf( + "https://github.com/%s/%s/tree/%s", + owner, + repo, + url.PathEscape(branch), + ) +} + +func chatDiffStatusIsStale(status database.ChatDiffStatus, now time.Time) bool { + if !status.RefreshedAt.Valid { + return true + } + return !status.StaleAt.After(now) +} + +func (api *API) refreshChatDiffStatus( + ctx context.Context, + chatOwnerID uuid.UUID, + chatID uuid.UUID, + pullRequestURL string, +) (database.ChatDiffStatus, error) { + status, err := api.fetchGitHubPullRequestStatus( + ctx, + pullRequestURL, + api.resolveChatGitHubAccessToken(ctx, chatOwnerID), + ) + if err != nil { + return database.ChatDiffStatus{}, err + } + + refreshedAt := time.Now().UTC() + refreshedStatus, err := api.Database.UpsertChatDiffStatus( + ctx, + database.UpsertChatDiffStatusParams{ + ChatID: chatID, + Url: sql.NullString{String: pullRequestURL, Valid: true}, + PullRequestState: sql.NullString{ + String: status.PullRequestState, + Valid: status.PullRequestState != "", + }, + ChangesRequested: status.ChangesRequested, + Additions: status.Additions, + Deletions: status.Deletions, + ChangedFiles: status.ChangedFiles, + RefreshedAt: refreshedAt, + StaleAt: refreshedAt.Add(chatDiffStatusTTL), + }, + ) + if err != nil { + return database.ChatDiffStatus{}, xerrors.Errorf("upsert chat diff status: %w", err) + } + return refreshedStatus, nil +} + +func (api *API) resolveChatGitHubAccessToken( + ctx context.Context, + userID uuid.UUID, +) string { + providerIDs := []string{"github"} + for _, config := range api.ExternalAuthConfigs { + if !strings.EqualFold( + config.Type, + string(codersdk.EnhancedExternalAuthProviderGitHub), + ) { + continue + } + providerIDs = append(providerIDs, config.ID) + } + + seen := map[string]struct{}{} + for _, providerID := range providerIDs { + if _, ok := seen[providerID]; ok { + continue + } + seen[providerID] = struct{}{} + + link, err := api.Database.GetExternalAuthLink( + ctx, + database.GetExternalAuthLinkParams{ + ProviderID: providerID, + UserID: userID, + }, + ) + if err != nil { + continue + } + + token := strings.TrimSpace(link.OAuthAccessToken) + if token != "" { + return token + } + } + + return "" +} + +func (api *API) resolveGitHubPullRequestURLFromRepositoryRef( + ctx context.Context, + userID uuid.UUID, + repositoryRef chatRepositoryRef, +) (string, error) { + if repositoryRef.Owner == "" || repositoryRef.Repo == "" || repositoryRef.Branch == "" { + return "", nil + } + + query := url.Values{} + query.Set("state", "open") + query.Set("head", fmt.Sprintf("%s:%s", repositoryRef.Owner, repositoryRef.Branch)) + query.Set("sort", "updated") + query.Set("direction", "desc") + query.Set("per_page", "1") + + requestURL := fmt.Sprintf( + "%s/repos/%s/%s/pulls?%s", + githubAPIBaseURL, + repositoryRef.Owner, + repositoryRef.Repo, + query.Encode(), + ) + + var pulls []struct { + HTMLURL string `json:"html_url"` + } + + token := api.resolveChatGitHubAccessToken(ctx, userID) + if err := api.decodeGitHubJSON(ctx, requestURL, token, &pulls); err != nil { + return "", err + } + if len(pulls) == 0 { + return "", nil + } + + return normalizeGitHubPullRequestURL(pulls[0].HTMLURL), nil +} + +func (api *API) fetchGitHubPullRequestDiff( + ctx context.Context, + pullRequestURL string, + token string, +) (string, error) { + ref, ok := parseGitHubPullRequestURL(pullRequestURL) + if !ok { + return "", xerrors.Errorf("invalid GitHub pull request URL %q", pullRequestURL) + } + + requestURL := fmt.Sprintf( + "%s/repos/%s/%s/pulls/%d", + githubAPIBaseURL, + ref.Owner, + ref.Repo, + ref.Number, + ) + + return api.fetchGitHubDiff(ctx, requestURL, token) +} + +func (api *API) fetchGitHubCompareDiff( + ctx context.Context, + repositoryRef chatRepositoryRef, + token string, +) (string, error) { + if repositoryRef.Owner == "" || repositoryRef.Repo == "" || repositoryRef.Branch == "" { + return "", nil + } + + var repository struct { + DefaultBranch string `json:"default_branch"` + } + + repositoryURL := fmt.Sprintf( + "%s/repos/%s/%s", + githubAPIBaseURL, + repositoryRef.Owner, + repositoryRef.Repo, + ) + if err := api.decodeGitHubJSON(ctx, repositoryURL, token, &repository); err != nil { + return "", err + } + defaultBranch := strings.TrimSpace(repository.DefaultBranch) + if defaultBranch == "" { + return "", xerrors.New("github repository default branch is empty") + } + + requestURL := fmt.Sprintf( + "%s/repos/%s/%s/compare/%s...%s", + githubAPIBaseURL, + repositoryRef.Owner, + repositoryRef.Repo, + url.PathEscape(defaultBranch), + url.PathEscape(repositoryRef.Branch), + ) + + return api.fetchGitHubDiff(ctx, requestURL, token) +} + +func (api *API) fetchGitHubDiff( + ctx context.Context, + requestURL string, + token string, +) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return "", xerrors.Errorf("create github diff request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.diff") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "coder-chat-diff") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + httpClient := api.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", xerrors.Errorf("execute github diff request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return "", xerrors.Errorf("github diff request failed with status %d", resp.StatusCode) + } + return "", xerrors.Errorf( + "github diff request failed with status %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + } + + diff, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", xerrors.Errorf("read github diff response: %w", err) + } + return string(diff), nil +} + +func (api *API) fetchGitHubPullRequestStatus( + ctx context.Context, + pullRequestURL string, + token string, +) (githubPullRequestStatus, error) { + ref, ok := parseGitHubPullRequestURL(pullRequestURL) + if !ok { + return githubPullRequestStatus{}, xerrors.Errorf( + "invalid GitHub pull request URL %q", + pullRequestURL, + ) + } + + pullEndpoint := fmt.Sprintf( + "%s/repos/%s/%s/pulls/%d", + githubAPIBaseURL, + ref.Owner, + ref.Repo, + ref.Number, + ) + + var pull struct { + State string `json:"state"` + Additions int32 `json:"additions"` + Deletions int32 `json:"deletions"` + ChangedFiles int32 `json:"changed_files"` + } + if err := api.decodeGitHubJSON(ctx, pullEndpoint, token, &pull); err != nil { + return githubPullRequestStatus{}, err + } + + var reviews []struct { + ID int64 `json:"id"` + State string `json:"state"` + User struct { + Login string `json:"login"` + } `json:"user"` + } + if err := api.decodeGitHubJSON( + ctx, + pullEndpoint+"/reviews?per_page=100", + token, + &reviews, + ); err != nil { + return githubPullRequestStatus{}, err + } + + return githubPullRequestStatus{ + PullRequestState: strings.ToLower(strings.TrimSpace(pull.State)), + ChangesRequested: hasOutstandingGitHubChangesRequested(reviews), + Additions: pull.Additions, + Deletions: pull.Deletions, + ChangedFiles: pull.ChangedFiles, + }, nil +} + +func (api *API) decodeGitHubJSON( + ctx context.Context, + requestURL string, + token string, + dest any, +) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return xerrors.Errorf("create github request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "coder-chat-diff-status") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + httpClient := api.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := httpClient.Do(req) + if err != nil { + return xerrors.Errorf("execute github request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return xerrors.Errorf( + "github request failed with status %d", + resp.StatusCode, + ) + } + return xerrors.Errorf( + "github request failed with status %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + } + + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return xerrors.Errorf("decode github response: %w", err) + } + return nil +} + +func hasOutstandingGitHubChangesRequested( + reviews []struct { + ID int64 `json:"id"` + State string `json:"state"` + User struct { + Login string `json:"login"` + } `json:"user"` + }, +) bool { + type reviewerState struct { + reviewID int64 + state string + } + + statesByReviewer := make(map[string]reviewerState) + for _, review := range reviews { + login := strings.ToLower(strings.TrimSpace(review.User.Login)) + if login == "" { + continue + } + + state := strings.ToUpper(strings.TrimSpace(review.State)) + switch state { + case "CHANGES_REQUESTED", "APPROVED", "DISMISSED": + default: + continue + } + + current, exists := statesByReviewer[login] + if exists && current.reviewID > review.ID { + continue + } + statesByReviewer[login] = reviewerState{ + reviewID: review.ID, + state: state, + } + } + + for _, state := range statesByReviewer { + if state.state == "CHANGES_REQUESTED" { + return true + } + } + return false +} + +func normalizeGitHubPullRequestURL(raw string) string { + ref, ok := parseGitHubPullRequestURL(strings.TrimRight( + strings.TrimSpace(raw), + "),.;", + )) + if !ok { + return "" + } + return fmt.Sprintf("https://github.com/%s/%s/pull/%d", ref.Owner, ref.Repo, ref.Number) +} + +func parseGitHubPullRequestURL(raw string) (githubPullRequestRef, bool) { + matches := githubPullRequestPathPattern.FindStringSubmatch(strings.TrimSpace(raw)) + if len(matches) != 4 { + return githubPullRequestRef{}, false + } + + number, err := strconv.Atoi(matches[3]) + if err != nil { + return githubPullRequestRef{}, false + } + + return githubPullRequestRef{ + Owner: matches[1], + Repo: matches[2], + Number: number, + }, true +} + +type createChatWorkspaceSelection struct { + WorkspaceID uuid.NullUUID + WorkspaceAgentID uuid.NullUUID +} + +func (api *API) validateCreateChatWorkspaceSelection( + ctx context.Context, + req codersdk.CreateChatRequest, +) ( + createChatWorkspaceSelection, + int, + *codersdk.Response, +) { + selection := createChatWorkspaceSelection{} + if req.WorkspaceID == nil { + return selection, 0, nil + } + + workspace, err := api.Database.GetWorkspaceByID(ctx, *req.WorkspaceID) + if err != nil { + if httpapi.Is404Error(err) { + return selection, http.StatusBadRequest, &codersdk.Response{ + Message: "Workspace not found or you do not have access to this resource", + } + } + return selection, http.StatusInternalServerError, &codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + } + } + selection.WorkspaceID = uuid.NullUUID{ + UUID: workspace.ID, + Valid: true, + } + + workspaceAgents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID( + ctx, + workspace.ID, + ) + if err != nil { + return selection, http.StatusInternalServerError, &codersdk.Response{ + Message: "Failed to get workspace agents.", + Detail: err.Error(), + } + } + if len(workspaceAgents) > 0 { + selection.WorkspaceAgentID = uuid.NullUUID{ + UUID: workspaceAgents[0].ID, + Valid: true, + } + } + + return selection, 0, nil +} + +func (api *API) resolveCreateChatModelConfigID( + ctx context.Context, + req codersdk.CreateChatRequest, +) (uuid.UUID, int, *codersdk.Response) { + if req.ModelConfigID != nil { + if *req.ModelConfigID == uuid.Nil { + return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid model config ID.", + } + } + return *req.ModelConfigID, 0, nil + } + + defaultModelConfig, err := api.Database.GetDefaultChatModelConfig(ctx) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ + Message: "No default chat model config is configured.", + } + } + return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ + Message: "Failed to resolve chat model config.", + Detail: err.Error(), + } + } + + return defaultModelConfig.ID, 0, nil +} + +func normalizeChatCompressionThreshold( + requested *int32, + fallback int32, +) (int32, error) { + threshold := fallback + if requested != nil { + threshold = *requested + } + + if threshold < minChatContextCompressionThreshold || + threshold > maxChatContextCompressionThreshold { + return 0, xerrors.Errorf( + "context_compression_threshold must be between %d and %d", + minChatContextCompressionThreshold, + maxChatContextCompressionThreshold, + ) + } + + return threshold, nil +} + +func defaultChatSystemPrompt() string { + return chatd.DefaultSystemPrompt +} + +func createChatInputFromRequest(req codersdk.CreateChatRequest) ( + []fantasy.Content, + string, + *codersdk.Response, +) { + return createChatInputFromParts(req.Content, "content") +} + +func createChatInputFromParts( + parts []codersdk.ChatInputPart, + fieldName string, +) ([]fantasy.Content, string, *codersdk.Response) { + if len(parts) == 0 { + return nil, "", &codersdk.Response{ + Message: "Content is required.", + Detail: "Content cannot be empty.", + } + } + + content := make([]fantasy.Content, 0, len(parts)) + textParts := make([]string, 0, len(parts)) + for i, part := range parts { + switch strings.ToLower(strings.TrimSpace(string(part.Type))) { + case string(codersdk.ChatInputPartTypeText): + text := strings.TrimSpace(part.Text) + if text == "" { + return nil, "", &codersdk.Response{ + Message: "Invalid input part.", + Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i), + } + } + content = append(content, fantasy.TextContent{Text: text}) + textParts = append(textParts, text) + default: + return nil, "", &codersdk.Response{ + Message: "Invalid input part.", + Detail: fmt.Sprintf( + "%s[%d].type %q is not supported.", + fieldName, + i, + part.Type, + ), + } + } + } + + titleSource := strings.TrimSpace(strings.Join(textParts, " ")) + if titleSource == "" { + return nil, "", &codersdk.Response{ + Message: "Content is required.", + Detail: "Content must include at least one text part.", + } + } + return content, titleSource, nil +} + +func chatTitleFromMessage(message string) string { + const maxWords = 6 + const maxRunes = 80 + words := strings.Fields(message) + if len(words) == 0 { + return "New Chat" + } + truncated := false + if len(words) > maxWords { + words = words[:maxWords] + truncated = true + } + title := strings.Join(words, " ") + if truncated { + title += "…" + } + return truncateRunes(title, maxRunes) +} + +func truncateRunes(value string, maxLen int) string { + if maxLen <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + + return string(runes[:maxLen]) +} + +func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat { + chat := codersdk.Chat{ + ID: c.ID, + OwnerID: c.OwnerID, + LastModelConfigID: c.LastModelConfigID, + Title: c.Title, + Status: codersdk.ChatStatus(c.Status), + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if c.ParentChatID.Valid { + parentChatID := c.ParentChatID.UUID + chat.ParentChatID = &parentChatID + } + switch { + case c.RootChatID.Valid: + rootChatID := c.RootChatID.UUID + chat.RootChatID = &rootChatID + case c.ParentChatID.Valid: + rootChatID := c.ParentChatID.UUID + chat.RootChatID = &rootChatID + default: + rootChatID := c.ID + chat.RootChatID = &rootChatID + } + if c.WorkspaceID.Valid { + chat.WorkspaceID = &c.WorkspaceID.UUID + } + if c.WorkspaceAgentID.Valid { + chat.WorkspaceAgentID = &c.WorkspaceAgentID.UUID + } + if diffStatus != nil { + convertedDiffStatus := convertChatDiffStatus(c.ID, diffStatus) + chat.DiffStatus = &convertedDiffStatus + } + return chat +} + +func convertChats(chats []database.Chat, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat { + result := make([]codersdk.Chat, len(chats)) + for i, c := range chats { + diffStatus, ok := diffStatusesByChatID[c.ID] + if ok { + result[i] = convertChat(c, &diffStatus) + continue + } + + result[i] = convertChat(c, nil) + if diffStatusesByChatID != nil { + emptyDiffStatus := convertChatDiffStatus(c.ID, nil) + result[i].DiffStatus = &emptyDiffStatus + } + } + return result +} + +func convertChatQueuedMessage(m database.ChatQueuedMessage) codersdk.ChatQueuedMessage { + return db2sdk.ChatQueuedMessage(m) +} + +func convertChatQueuedMessagePtr(m database.ChatQueuedMessage) *codersdk.ChatQueuedMessage { + qm := convertChatQueuedMessage(m) + return &qm +} + +func convertChatQueuedMessages(msgs []database.ChatQueuedMessage) []codersdk.ChatQueuedMessage { + result := make([]codersdk.ChatQueuedMessage, 0, len(msgs)) + for _, m := range msgs { + result = append(result, convertChatQueuedMessage(m)) + } + return result +} + +func convertChatMessage(m database.ChatMessage) codersdk.ChatMessage { + return db2sdk.ChatMessage(m) +} + +func convertChatMessages(messages []database.ChatMessage) []codersdk.ChatMessage { + result := make([]codersdk.ChatMessage, 0, len(messages)) + for _, m := range messages { + result = append(result, convertChatMessage(m)) + } + return result +} + +func convertChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) codersdk.ChatDiffStatus { + result := codersdk.ChatDiffStatus{ + ChatID: chatID, + } + if status == nil { + return result + } + + result.ChatID = status.ChatID + if status.Url.Valid { + u := strings.TrimSpace(status.Url.String) + if u != "" { + result.URL = &u + } + } + if result.URL == nil { + owner, repo, _, ok := parseGitHubRepositoryOrigin(status.GitRemoteOrigin) + if ok { + branchURL := buildGitHubBranchURL(owner, repo, status.GitBranch) + if branchURL != "" { + result.URL = &branchURL + } + } + } + if status.PullRequestState.Valid { + pullRequestState := strings.TrimSpace(status.PullRequestState.String) + if pullRequestState != "" { + result.PullRequestState = &pullRequestState + } + } + result.ChangesRequested = status.ChangesRequested + result.Additions = status.Additions + result.Deletions = status.Deletions + result.ChangedFiles = status.ChangedFiles + if status.RefreshedAt.Valid { + refreshedAt := status.RefreshedAt.Time + result.RefreshedAt = &refreshedAt + } + staleAt := status.StaleAt + result.StaleAt = &staleAt + + return result +} + +func (api *API) listChatProviders(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + //nolint:gocritic // System context required to read enabled chat providers. + systemCtx := dbauthz.AsSystemRestricted(ctx) + if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + providers, err := api.Database.GetChatProviders(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chat providers.", + Detail: err.Error(), + }) + return + } + + providersByName := make(map[string]database.ChatProvider, len(providers)) + configuredProviders := make([]chatprovider.ConfiguredProvider, 0, len(providers)) + for _, provider := range providers { + normalizedProvider := normalizeChatProvider(provider.Provider) + if normalizedProvider == "" { + continue + } + provider.Provider = normalizedProvider + providersByName[normalizedProvider] = provider + configuredProviders = append(configuredProviders, chatprovider.ConfiguredProvider{ + Provider: normalizedProvider, + APIKey: provider.APIKey, + BaseURL: provider.BaseUrl, + }) + } + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Chat processor is unavailable.", + Detail: "Chat processor is not configured.", + }) + return + } + + enabledProviders, err := api.Database.GetEnabledChatProviders( + systemCtx, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to resolve provider API keys.", + Detail: err.Error(), + }) + return + } + + enabledConfiguredProviders := make( + []chatprovider.ConfiguredProvider, 0, len(enabledProviders), + ) + for _, provider := range enabledProviders { + enabledConfiguredProviders = append( + enabledConfiguredProviders, chatprovider.ConfiguredProvider{ + Provider: provider.Provider, + APIKey: provider.APIKey, + BaseURL: provider.BaseUrl, + }, + ) + } + + effectiveKeys := chatprovider.MergeProviderAPIKeys( + chatProviderAPIKeysFromDeploymentValues(api.DeploymentValues), + enabledConfiguredProviders, + ) + effectiveKeys = chatprovider.MergeProviderAPIKeys( + effectiveKeys, configuredProviders, + ) + + supportedProviders := chatprovider.SupportedProviders() + resp := make([]codersdk.ChatProviderConfig, 0, len(supportedProviders)) + for _, provider := range supportedProviders { + configured, ok := providersByName[provider] + if ok { + resp = append( + resp, + convertChatProviderConfig( + configured, + effectiveKeys.APIKey(provider) != "", + codersdk.ChatProviderConfigSourceDatabase, + ), + ) + continue + } + + source := codersdk.ChatProviderConfigSourceSupported + hasAPIKey := effectiveKeys.APIKey(provider) != "" + enabled := false + if chatprovider.IsEnvPresetProvider(provider) && hasAPIKey { + source = codersdk.ChatProviderConfigSourceEnvPreset + enabled = true + } + + resp = append(resp, codersdk.ChatProviderConfig{ + ID: uuid.Nil, + Provider: provider, + DisplayName: chatprovider.ProviderDisplayName(provider), + Enabled: enabled, + HasAPIKey: hasAPIKey, + BaseURL: effectiveKeys.BaseURL(provider), + Source: source, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +func (api *API) createChatProvider(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.CreateChatProviderConfigRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + provider := normalizeChatProvider(req.Provider) + if provider == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider.", + Detail: chatProviderValidationDetail(), + }) + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + baseURL, err := normalizeChatProviderBaseURL(req.BaseURL) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider base URL.", + Detail: err.Error(), + }) + return + } + + inserted, err := api.Database.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: provider, + DisplayName: strings.TrimSpace(req.DisplayName), + APIKey: strings.TrimSpace(req.APIKey), + BaseUrl: baseURL, + ApiKeyKeyID: sql.NullString{}, + CreatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + Enabled: enabled, + }) + if err != nil { + switch { + case database.IsUniqueViolation(err): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Chat provider already exists.", + Detail: err.Error(), + }) + return + case database.IsCheckViolation(err): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider.", + Detail: err.Error(), + }) + return + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat provider.", + Detail: err.Error(), + }) + return + } + } + + httpapi.Write( + ctx, + rw, + http.StatusCreated, + convertChatProviderConfig( + inserted, + api.hasEffectiveProviderAPIKey(ctx, inserted), + codersdk.ChatProviderConfigSourceDatabase, + ), + ) +} + +func (api *API) updateChatProvider(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + providerID, ok := parseChatProviderID(rw, r) + if !ok { + return + } + + existing, err := api.Database.GetChatProviderByID(ctx, providerID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat provider.", + Detail: err.Error(), + }) + return + } + + var req codersdk.UpdateChatProviderConfigRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + displayName := existing.DisplayName + if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" { + displayName = trimmed + } + + enabled := existing.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + + apiKey := existing.APIKey + apiKeyKeyID := existing.ApiKeyKeyID + if req.APIKey != nil { + apiKey = strings.TrimSpace(*req.APIKey) + apiKeyKeyID = sql.NullString{} + } + baseURL := existing.BaseUrl + if req.BaseURL != nil { + baseURL, err = normalizeChatProviderBaseURL(*req.BaseURL) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider base URL.", + Detail: err.Error(), + }) + return + } + } + + updated, err := api.Database.UpdateChatProvider(ctx, database.UpdateChatProviderParams{ + DisplayName: displayName, + APIKey: apiKey, + BaseUrl: baseURL, + ApiKeyKeyID: apiKeyKeyID, + Enabled: enabled, + ID: existing.ID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat provider.", + Detail: err.Error(), + }) + return + } + + httpapi.Write( + ctx, + rw, + http.StatusOK, + convertChatProviderConfig( + updated, + api.hasEffectiveProviderAPIKey(ctx, updated), + codersdk.ChatProviderConfigSourceDatabase, + ), + ) +} + +func (api *API) deleteChatProvider(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + providerID, ok := parseChatProviderID(rw, r) + if !ok { + return + } + + if _, err := api.Database.GetChatProviderByID(ctx, providerID); err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat provider.", + Detail: err.Error(), + }) + return + } + + if err := api.Database.DeleteChatProviderByID(ctx, providerID); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete chat provider.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + configs, err := api.Database.GetChatModelConfigs(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chat model configs.", + Detail: err.Error(), + }) + return + } + + resp := make([]codersdk.ChatModelConfig, 0, len(configs)) + for _, config := range configs { + resp = append(resp, convertChatModelConfig(config)) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.CreateChatModelConfigRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + provider := normalizeChatProvider(req.Provider) + if provider == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider.", + Detail: chatProviderValidationDetail(), + }) + return + } + + model := strings.TrimSpace(req.Model) + if model == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Model is required.", + }) + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + isDefault := false + if req.IsDefault != nil { + isDefault = *req.IsDefault + } + + if req.ContextLimit == nil || *req.ContextLimit <= 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Context limit is required.", + Detail: "context_limit must be greater than zero.", + }) + return + } + contextLimit := *req.ContextLimit + + compressionThreshold, thresholdErr := normalizeChatCompressionThreshold( + req.CompressionThreshold, + defaultChatContextCompressionThreshold, + ) + if thresholdErr != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid compression threshold.", + Detail: thresholdErr.Error(), + }) + return + } + + modelConfigRaw, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig) + if modelConfigErr != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid model config.", + Detail: modelConfigErr.Error(), + }) + return + } + + insertParams := database.InsertChatModelConfigParams{ + Provider: provider, + Model: model, + DisplayName: strings.TrimSpace(req.DisplayName), + Enabled: enabled, + IsDefault: isDefault, + ContextLimit: contextLimit, + CompressionThreshold: compressionThreshold, + Options: modelConfigRaw, + CreatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + } + + var inserted database.ChatModelConfig + err := api.Database.InTx(func(tx database.Store) error { + insertAsDefault := isDefault + if !insertAsDefault { + _, err := tx.GetDefaultChatModelConfig(ctx) + switch { + case err == nil: + // A default already exists. + case xerrors.Is(err, sql.ErrNoRows): + insertAsDefault = true + default: + return xerrors.Errorf("get default model config: %w", err) + } + } + + if insertAsDefault { + if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil { + return xerrors.Errorf("unset default model configs: %w", err) + } + } + insertParams.IsDefault = insertAsDefault + + config, err := tx.InsertChatModelConfig(ctx, insertParams) + if err != nil { + return err + } + inserted = config + + if err := ensureDefaultChatModelConfig(ctx, tx); err != nil { + return err + } + + refreshedConfig, err := tx.GetChatModelConfigByID(ctx, inserted.ID) + if err != nil { + return xerrors.Errorf("refresh inserted chat model config: %w", err) + } + inserted = refreshedConfig + return nil + }, nil) + if err != nil { + switch { + case database.IsUniqueViolation(err): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Chat model config already exists.", + Detail: err.Error(), + }) + return + case database.IsForeignKeyViolation(err): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat provider is not configured.", + Detail: err.Error(), + }) + return + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat model config.", + Detail: err.Error(), + }) + return + } + } + + httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted)) +} + +func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + modelConfigID, ok := parseChatModelConfigID(rw, r) + if !ok { + return + } + + existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat model config.", + Detail: err.Error(), + }) + return + } + + var req codersdk.UpdateChatModelConfigRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + provider := existing.Provider + if strings.TrimSpace(req.Provider) != "" { + provider = normalizeChatProvider(req.Provider) + if provider == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid provider.", + Detail: chatProviderValidationDetail(), + }) + return + } + } + + model := existing.Model + if trimmed := strings.TrimSpace(req.Model); trimmed != "" { + model = trimmed + } + + displayName := existing.DisplayName + if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" { + displayName = trimmed + } + + enabled := existing.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + isDefault := existing.IsDefault + if req.IsDefault != nil { + isDefault = *req.IsDefault + } + + contextLimit := existing.ContextLimit + if req.ContextLimit != nil { + if *req.ContextLimit <= 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Context limit must be greater than zero.", + }) + return + } + contextLimit = *req.ContextLimit + } + + compressionThreshold, thresholdErr := normalizeChatCompressionThreshold( + req.CompressionThreshold, + existing.CompressionThreshold, + ) + if thresholdErr != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid compression threshold.", + Detail: thresholdErr.Error(), + }) + return + } + + modelConfigRaw := existing.Options + if req.ModelConfig != nil { + encodedModelConfig, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig) + if modelConfigErr != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid model config.", + Detail: modelConfigErr.Error(), + }) + return + } + modelConfigRaw = encodedModelConfig + } + + updateParams := database.UpdateChatModelConfigParams{ + Provider: provider, + Model: model, + DisplayName: displayName, + Enabled: enabled, + IsDefault: isDefault, + ContextLimit: contextLimit, + CompressionThreshold: compressionThreshold, + Options: modelConfigRaw, + UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + ID: existing.ID, + } + + var updated database.ChatModelConfig + err = api.Database.InTx(func(tx database.Store) error { + setAsDefault := updateParams.IsDefault && !existing.IsDefault + if setAsDefault { + if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil { + return xerrors.Errorf("unset default model configs: %w", err) + } + } + + _, err := tx.UpdateChatModelConfig(ctx, updateParams) + if err != nil { + return err + } + + excludeConfigID := uuid.Nil + if existing.IsDefault && req.IsDefault != nil && !*req.IsDefault { + excludeConfigID = existing.ID + } + + if err := ensureDefaultChatModelConfig( + ctx, + tx, + excludeConfigID, + ); err != nil { + return err + } + + refreshedConfig, err := tx.GetChatModelConfigByID(ctx, existing.ID) + if err != nil { + return xerrors.Errorf("refresh updated chat model config: %w", err) + } + updated = refreshedConfig + return nil + }, nil) + if err != nil { + switch { + case database.IsUniqueViolation(err): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Chat model config already exists.", + Detail: err.Error(), + }) + return + case database.IsForeignKeyViolation(err): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat provider is not configured.", + Detail: err.Error(), + }) + return + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat model config.", + Detail: err.Error(), + }) + return + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated)) +} + +func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + modelConfigID, ok := parseChatModelConfigID(rw, r) + if !ok { + return + } + + if _, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID); err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat model config.", + Detail: err.Error(), + }) + return + } + + if err := api.Database.InTx(func(tx database.Store) error { + if err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { + return err + } + return ensureDefaultChatModelConfig(ctx, tx) + }, nil); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete chat model config.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +func ensureDefaultChatModelConfig( + ctx context.Context, + tx database.Store, + excludedConfigIDs ...uuid.UUID, +) error { + _, err := tx.GetDefaultChatModelConfig(ctx) + switch { + case err == nil: + return nil + case !xerrors.Is(err, sql.ErrNoRows): + return xerrors.Errorf("get default model config: %w", err) + } + + modelConfigs, err := tx.GetChatModelConfigs(ctx) + if err != nil { + return xerrors.Errorf("list chat model configs: %w", err) + } + if len(modelConfigs) == 0 { + return nil + } + + candidateConfig := modelConfigs[0] + excluded := make(map[uuid.UUID]struct{}, len(excludedConfigIDs)) + for _, configID := range excludedConfigIDs { + if configID == uuid.Nil { + continue + } + excluded[configID] = struct{}{} + } + for _, config := range modelConfigs { + if _, skip := excluded[config.ID]; skip { + continue + } + candidateConfig = config + break + } + + if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil { + return xerrors.Errorf("unset default model configs: %w", err) + } + + params := chatModelConfigToUpdateParams(candidateConfig) + params.IsDefault = true + if _, err := tx.UpdateChatModelConfig(ctx, params); err != nil { + return xerrors.Errorf("set default model config: %w", err) + } + return nil +} + +func chatModelConfigToUpdateParams( + config database.ChatModelConfig, +) database.UpdateChatModelConfigParams { + return database.UpdateChatModelConfigParams{ + Provider: config.Provider, + Model: config.Model, + DisplayName: config.DisplayName, + Enabled: config.Enabled, + IsDefault: config.IsDefault, + ContextLimit: config.ContextLimit, + CompressionThreshold: config.CompressionThreshold, + Options: config.Options, + UpdatedBy: uuid.NullUUID{}, + ID: config.ID, + } +} + +func parseChatProviderID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { + providerID, err := uuid.Parse(chi.URLParam(r, "providerConfig")) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat provider ID.", + Detail: err.Error(), + }) + return uuid.Nil, false + } + return providerID, true +} + +func parseChatModelConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { + modelConfigID, err := uuid.Parse(chi.URLParam(r, "modelConfig")) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat model config ID.", + Detail: err.Error(), + }) + return uuid.Nil, false + } + return modelConfigID, true +} + +func convertChatProviderConfig( + provider database.ChatProvider, + hasAPIKey bool, + source codersdk.ChatProviderConfigSource, +) codersdk.ChatProviderConfig { + displayName := strings.TrimSpace(provider.DisplayName) + if displayName == "" { + displayName = chatprovider.ProviderDisplayName(provider.Provider) + } + + return codersdk.ChatProviderConfig{ + ID: provider.ID, + Provider: provider.Provider, + DisplayName: displayName, + Enabled: provider.Enabled, + HasAPIKey: hasAPIKey, + BaseURL: strings.TrimSpace(provider.BaseUrl), + Source: source, + CreatedAt: provider.CreatedAt, + UpdatedAt: provider.UpdatedAt, + } +} + +func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModelConfig { + return codersdk.ChatModelConfig{ + ID: config.ID, + Provider: config.Provider, + Model: config.Model, + DisplayName: config.DisplayName, + Enabled: config.Enabled, + IsDefault: config.IsDefault, + ContextLimit: config.ContextLimit, + CompressionThreshold: config.CompressionThreshold, + ModelConfig: unmarshalChatModelCallConfig(config.Options), + CreatedAt: config.CreatedAt, + UpdatedAt: config.UpdatedAt, + } +} + +func marshalChatModelCallConfig( + modelConfig *codersdk.ChatModelCallConfig, +) (json.RawMessage, error) { + if modelConfig == nil { + return json.RawMessage("{}"), nil + } + + encoded, err := json.Marshal(modelConfig) + if err != nil { + return nil, xerrors.Errorf("encode model config: %w", err) + } + return encoded, nil +} + +func unmarshalChatModelCallConfig( + raw json.RawMessage, +) *codersdk.ChatModelCallConfig { + if len(raw) == 0 { + return nil + } + + decoded := &codersdk.ChatModelCallConfig{} + if err := json.Unmarshal(raw, decoded); err != nil { + return nil + } + if isZeroChatModelCallConfig(decoded) { + return nil + } + return decoded +} + +func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool { + if config == nil { + return true + } + + return config.MaxOutputTokens == nil && + config.Temperature == nil && + config.TopP == nil && + config.TopK == nil && + config.PresencePenalty == nil && + config.FrequencyPenalty == nil && + isZeroChatModelProviderOptions(config.ProviderOptions) +} + +func isZeroChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) bool { + if options == nil { + return true + } + + return options.OpenAI == nil && + options.Anthropic == nil && + options.Google == nil && + options.OpenAICompat == nil && + options.OpenRouter == nil && + options.Vercel == nil +} + +func normalizeChatProvider(provider string) string { + return chatprovider.NormalizeProvider(provider) +} + +func normalizeChatProviderBaseURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", err + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", xerrors.New("Base URL must be an absolute URL with scheme and host.") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", xerrors.New("Base URL scheme must be http or https.") + } + return parsed.String(), nil +} + +func chatProviderValidationDetail() string { + return "Provider must be one of: " + strings.Join(chatprovider.SupportedProviders(), ", ") + "." +} + +func chatProviderAPIKeysFromDeploymentValues( + deploymentValues *codersdk.DeploymentValues, +) chatprovider.ProviderAPIKeys { + return chatprovider.ProviderAPIKeys{ + OpenAI: deploymentValues.AI.BridgeConfig.OpenAI.Key.Value(), + Anthropic: deploymentValues.AI.BridgeConfig.Anthropic.Key.Value(), + BaseURLByProvider: map[string]string{ + "openai": deploymentValues.AI.BridgeConfig.OpenAI.BaseURL.Value(), + "anthropic": deploymentValues.AI.BridgeConfig.Anthropic.BaseURL.Value(), + }, + } +} + +func (api *API) hasEffectiveProviderAPIKey(ctx context.Context, provider database.ChatProvider) bool { + if strings.TrimSpace(provider.APIKey) != "" { + return true + } + if api.chatDaemon == nil { + return false + } + //nolint:gocritic // System context required to read enabled chat providers. + systemCtx := dbauthz.AsSystemRestricted(ctx) + + enabledProviders, err := api.Database.GetEnabledChatProviders( + systemCtx, + ) + if err != nil { + api.Logger.Warn(ctx, "failed to resolve provider API keys", + slog.F("provider", provider.Provider), + slog.Error(err), + ) + return false + } + + enabledConfiguredProviders := make( + []chatprovider.ConfiguredProvider, 0, len(enabledProviders), + ) + for _, configured := range enabledProviders { + enabledConfiguredProviders = append( + enabledConfiguredProviders, chatprovider.ConfiguredProvider{ + Provider: configured.Provider, + APIKey: configured.APIKey, + BaseURL: configured.BaseUrl, + }, + ) + } + + effectiveKeys := chatprovider.MergeProviderAPIKeys( + chatProviderAPIKeysFromDeploymentValues(api.DeploymentValues), + enabledConfiguredProviders, + ) + return effectiveKeys.APIKey(provider.Provider) != "" +} diff --git a/coderd/chats_test.go b/coderd/chats_test.go new file mode 100644 index 0000000000..34b34b5176 --- /dev/null +++ b/coderd/chats_test.go @@ -0,0 +1,2067 @@ +package coderd_test + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "regexp" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/externalauth" + coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +func chatDeploymentValues(t testing.TB) *codersdk.DeploymentValues { + t.Helper() + + values := coderdtest.DeploymentValues(t) + values.Experiments = []string{string(codersdk.ExperimentAgents)} + return values +} + +func newChatClient(t testing.TB) *codersdk.Client { + t.Helper() + + return coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: chatDeploymentValues(t), + }) +} + +func newChatClientWithDatabase(t testing.TB) (*codersdk.Client, database.Store) { + t.Helper() + + return coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: chatDeploymentValues(t), + }) +} + +func TestPostChats(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello from chats route tests", + }, + }, + }) + require.NoError(t, err) + + require.NotEqual(t, uuid.Nil, chat.ID) + require.Equal(t, user.UserID, chat.OwnerID) + require.Equal(t, modelConfig.ID, chat.LastModelConfigID) + require.Equal(t, "hello from chats route tests", chat.Title) + require.Equal(t, codersdk.ChatStatusPending, chat.Status) + require.NotZero(t, chat.CreatedAt) + require.NotZero(t, chat.UpdatedAt) + require.Nil(t, chat.WorkspaceID) + require.Nil(t, chat.WorkspaceAgentID) + require.NotNil(t, chat.RootChatID) + require.Equal(t, chat.ID, *chat.RootChatID) + + chatWithMessages, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, chatWithMessages.Chat.ID) + + foundUserMessage := false + for _, message := range chatWithMessages.Messages { + if message.Role != "user" { + continue + } + for _, part := range message.Content { + if part.Type == codersdk.ChatMessagePartTypeText && + part.Text == "hello from chats route tests" { + foundUserMessage = true + break + } + } + } + require.True(t, foundUserMessage) + }) + + t.Run("HidesSystemPromptMessages", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "verify hidden system prompt", + }, + }, + }) + require.NoError(t, err) + + chatWithMessages, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + for _, message := range chatWithMessages.Messages { + require.NotEqual(t, "system", message.Role) + } + }) + + t.Run("WorkspaceNotAccessible", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + }).WithAgent().Do() + + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + WorkspaceID: &workspaceBuild.Workspace.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal( + t, + "Workspace not found or you do not have access to this resource", + sdkErr.Message, + ) + }) + + t.Run("WorkspaceNotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + workspaceID := uuid.New() + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + WorkspaceID: &workspaceID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal( + t, + "Workspace not found or you do not have access to this resource", + sdkErr.Message, + ) + }) + + t.Run("WorkspaceSelectsFirstAgent", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + WorkspaceID: &workspaceBuild.Workspace.ID, + }) + require.NoError(t, err) + require.NotNil(t, chat.WorkspaceID) + require.Equal(t, workspaceBuild.Workspace.ID, *chat.WorkspaceID) + require.NotNil(t, chat.WorkspaceAgentID) + require.Equal(t, workspaceBuild.Agents[0].ID, *chat.WorkspaceAgentID) + require.Equal(t, modelConfig.ID, chat.LastModelConfigID) + }) + + t.Run("MissingDefaultModelConfig", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + }) + + t.Run("EmptyContent", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: nil, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Content is required.", sdkErr.Message) + require.Equal(t, "Content cannot be empty.", sdkErr.Detail) + }) + + t.Run("EmptyText", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: " ", + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid input part.", sdkErr.Message) + require.Equal(t, "content[0].text cannot be empty.", sdkErr.Detail) + }) + + t.Run("UnsupportedPartType", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartType("image"), + Text: "hello", + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid input part.", sdkErr.Message) + require.Equal(t, `content[0].type "image" is not supported.`, sdkErr.Detail) + }) +} + +func TestListChats(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + firstChatA, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "first owner chat", + }, + }, + }) + require.NoError(t, err) + + firstChatB, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "second owner chat", + }, + }, + }) + require.NoError(t, err) + + memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: member.ID, + LastModelConfigID: modelConfig.ID, + Title: "member chat only", + }) + require.NoError(t, err) + + chats, err := client.ListChats(ctx) + require.NoError(t, err) + require.Len(t, chats, 2) + + chatIndexes := make(map[uuid.UUID]int, len(chats)) + chatsByID := make(map[uuid.UUID]codersdk.Chat, len(chats)) + for i, chat := range chats { + chatIndexes[chat.ID] = i + chatsByID[chat.ID] = chat + + require.Equal(t, firstUser.UserID, chat.OwnerID) + require.Equal(t, modelConfig.ID, chat.LastModelConfigID) + require.Equal(t, codersdk.ChatStatusPending, chat.Status) + require.NotZero(t, chat.CreatedAt) + require.NotZero(t, chat.UpdatedAt) + require.Nil(t, chat.ParentChatID) + require.Nil(t, chat.WorkspaceID) + require.Nil(t, chat.WorkspaceAgentID) + require.NotNil(t, chat.RootChatID) + require.Equal(t, chat.ID, *chat.RootChatID) + require.NotNil(t, chat.DiffStatus) + require.Equal(t, chat.ID, chat.DiffStatus.ChatID) + } + + require.Contains(t, chatsByID, firstChatA.ID) + require.Contains(t, chatsByID, firstChatB.ID) + require.NotContains(t, chatsByID, memberDBChat.ID) + require.Equal(t, "first owner chat", chatsByID[firstChatA.ID].Title) + require.Equal(t, "second owner chat", chatsByID[firstChatB.ID].Title) + + for i := 1; i < len(chats); i++ { + require.False(t, chats[i-1].UpdatedAt.Before(chats[i].UpdatedAt)) + } + if firstChatA.UpdatedAt.After(firstChatB.UpdatedAt) { + require.Less(t, chatIndexes[firstChatA.ID], chatIndexes[firstChatB.ID]) + } + if firstChatB.UpdatedAt.After(firstChatA.UpdatedAt) { + require.Less(t, chatIndexes[firstChatB.ID], chatIndexes[firstChatA.ID]) + } + + memberChats, err := memberClient.ListChats(ctx) + require.NoError(t, err) + require.Len(t, memberChats, 1) + require.Equal(t, memberDBChat.ID, memberChats[0].ID) + require.Equal(t, member.ID, memberChats[0].OwnerID) + require.Equal(t, "member chat only", memberChats[0].Title) + require.NotNil(t, memberChats[0].RootChatID) + require.Equal(t, memberChats[0].ID, *memberChats[0].RootChatID) + require.NotNil(t, memberChats[0].DiffStatus) + require.Equal(t, memberChats[0].ID, memberChats[0].DiffStatus.ChatID) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + unauthenticatedClient := codersdk.New(client.URL) + _, err := unauthenticatedClient.ListChats(ctx) + requireSDKError(t, err, http.StatusUnauthorized) + }) +} + +func TestListChatModels(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + models, err := client.ListChatModels(ctx) + require.NoError(t, err) + + var openAIProvider *codersdk.ChatModelProvider + for i := range models.Providers { + if models.Providers[i].Provider == "openai" { + openAIProvider = &models.Providers[i] + break + } + } + require.NotNil(t, openAIProvider) + require.True(t, openAIProvider.Available) + + foundModel := false + for _, model := range openAIProvider.Models { + if model.Provider == "openai" && model.Model == "gpt-4o-mini" { + foundModel = true + break + } + } + require.True(t, foundModel) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + unauthenticatedClient := codersdk.New(client.URL) + _, err := unauthenticatedClient.ListChatModels(ctx) + requireSDKError(t, err, http.StatusUnauthorized) + }) +} + +func TestWatchChats(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) + require.NoError(t, err) + defer conn.Close(websocket.StatusNormalClosure, "done") + + type watchEvent struct { + Type codersdk.ServerSentEventType `json:"type"` + Data json.RawMessage `json:"data,omitempty"` + } + + var event watchEvent + err = wsjson.Read(ctx, conn, &event) + require.NoError(t, err) + require.Equal(t, codersdk.ServerSentEventTypePing, event.Type) + require.True(t, len(event.Data) == 0 || string(event.Data) == "null") + + createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "watch route created event", + }, + }, + }) + require.NoError(t, err) + + for { + var update watchEvent + err = wsjson.Read(ctx, conn, &update) + require.NoError(t, err) + + if update.Type == codersdk.ServerSentEventTypePing { + continue + } + require.Equal(t, codersdk.ServerSentEventTypeData, update.Type) + + var payload coderdpubsub.ChatEvent + err = json.Unmarshal(update.Data, &payload) + require.NoError(t, err) + if payload.Kind == coderdpubsub.ChatEventKindCreated && + payload.Chat.ID == createdChat.ID { + break + } + } + }) + + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + unauthenticatedClient := codersdk.New(client.URL) + res, err := unauthenticatedClient.Request( + ctx, + http.MethodGet, + "/api/experimental/chats/watch", + nil, + ) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} + +func TestListChatProviders(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + providers, err := client.ListChatProviders(ctx) + require.NoError(t, err) + + var openAIProvider *codersdk.ChatProviderConfig + for i := range providers { + if providers[i].Provider == "openai" { + openAIProvider = &providers[i] + break + } + } + require.NotNil(t, openAIProvider) + require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, openAIProvider.Source) + require.True(t, openAIProvider.Enabled) + require.True(t, openAIProvider.HasAPIKey) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + _, err := memberClient.ListChatProviders(ctx) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestCreateChatProvider(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + DisplayName: "OpenAI Primary", + APIKey: "test-api-key", + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, provider.ID) + require.Equal(t, "openai", provider.Provider) + require.Equal(t, "OpenAI Primary", provider.DisplayName) + require.True(t, provider.Enabled) + require.True(t, provider.HasAPIKey) + require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, provider.Source) + }) + + t.Run("InvalidProvider", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "not-a-provider", + APIKey: "test-api-key", + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid provider.", sdkErr.Message) + }) + + t.Run("Conflict", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + _, err = client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "other-api-key", + }) + sdkErr := requireSDKError(t, err, http.StatusConflict) + require.Equal(t, "Chat provider already exists.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + _, err := memberClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "member-key", + }) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestUpdateChatProvider(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + enabled := false + baseURL := "https://example.com/v1" + updated, err := client.UpdateChatProvider(ctx, provider.ID, codersdk.UpdateChatProviderConfigRequest{ + DisplayName: "OpenAI Updated", + Enabled: &enabled, + BaseURL: &baseURL, + }) + require.NoError(t, err) + require.Equal(t, provider.ID, updated.ID) + require.Equal(t, "OpenAI Updated", updated.DisplayName) + require.False(t, updated.Enabled) + require.Equal(t, baseURL, updated.BaseURL) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.UpdateChatProvider(ctx, uuid.New(), codersdk.UpdateChatProviderConfigRequest{ + DisplayName: "missing", + }) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("InvalidProviderID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.Request( + ctx, + http.MethodPatch, + "/api/experimental/chats/providers/not-a-uuid", + codersdk.UpdateChatProviderConfigRequest{DisplayName: "ignored"}, + ) + require.NoError(t, err) + defer res.Body.Close() + + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid chat provider ID.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + provider, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + _, err = memberClient.UpdateChatProvider(ctx, provider.ID, codersdk.UpdateChatProviderConfigRequest{ + DisplayName: "member update", + }) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestDeleteChatProvider(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + err = client.DeleteChatProvider(ctx, provider.ID) + require.NoError(t, err) + + providers, err := client.ListChatProviders(ctx) + require.NoError(t, err) + for _, listed := range providers { + require.NotEqual(t, provider.ID, listed.ID) + } + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + err := client.DeleteChatProvider(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("InvalidProviderID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.Request( + ctx, + http.MethodDelete, + "/api/experimental/chats/providers/not-a-uuid", + nil, + ) + require.NoError(t, err) + defer res.Body.Close() + + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid chat provider ID.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + provider, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + err = memberClient.DeleteChatProvider(ctx, provider.ID) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestListChatModelConfigs(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + require.NotEmpty(t, configs) + + found := false + for _, config := range configs { + if config.ID == modelConfig.ID { + found = true + require.Equal(t, "openai", config.Provider) + require.Equal(t, "gpt-4o-mini", config.Model) + require.True(t, config.IsDefault) + } + } + require.True(t, found) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + _, err := memberClient.ListChatModelConfigs(ctx) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestCreateChatModelConfig(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + contextLimit := int64(4096) + isDefault := true + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + IsDefault: &isDefault, + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, modelConfig.ID) + require.Equal(t, "openai", modelConfig.Provider) + require.Equal(t, "gpt-4o-mini", modelConfig.Model) + require.EqualValues(t, 4096, modelConfig.ContextLimit) + require.True(t, modelConfig.IsDefault) + }) + + t.Run("MissingContextLimit", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Context limit is required.", sdkErr.Message) + }) + + t.Run("ProviderNotConfigured", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + contextLimit := int64(4096) + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Chat provider is not configured.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + _, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + contextLimit := int64(4096) + _, err = memberClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestUpdateChatModelConfig(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + contextLimit := int64(8192) + updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "GPT-4o Mini Updated", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + require.Equal(t, modelConfig.ID, updated.ID) + require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName) + require.EqualValues(t, 8192, updated.ContextLimit) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.UpdateChatModelConfig(ctx, uuid.New(), codersdk.UpdateChatModelConfigRequest{ + DisplayName: "missing", + }) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("InvalidContextLimit", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + contextLimit := int64(0) + _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Context limit must be greater than zero.", sdkErr.Message) + }) + + t.Run("InvalidModelConfigID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.Request( + ctx, + http.MethodPatch, + "/api/experimental/chats/model-configs/not-a-uuid", + codersdk.UpdateChatModelConfigRequest{DisplayName: "ignored"}, + ) + require.NoError(t, err) + defer res.Body.Close() + + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + modelConfig := createChatModelConfig(t, adminClient) + _, err := memberClient.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "member update", + }) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestDeleteChatModelConfig(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + err := client.DeleteChatModelConfig(ctx, modelConfig.ID) + require.NoError(t, err) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + for _, config := range configs { + require.NotEqual(t, modelConfig.ID, config.ID) + } + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + err := client.DeleteChatModelConfig(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("InvalidModelConfigID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.Request( + ctx, + http.MethodDelete, + "/api/experimental/chats/model-configs/not-a-uuid", + nil, + ) + require.NoError(t, err) + defer res.Body.Close() + + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) + }) + + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + modelConfig := createChatModelConfig(t, adminClient) + err := memberClient.DeleteChatModelConfig(ctx, modelConfig.ID) + requireSDKError(t, err, http.StatusForbidden) + }) +} + +func TestGetChat(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "get chat route payload", + }, + }, + }) + require.NoError(t, err) + + chatWithMessages, err := client.GetChat(ctx, createdChat.ID) + require.NoError(t, err) + require.Equal(t, createdChat.ID, chatWithMessages.Chat.ID) + require.Equal(t, firstUser.UserID, chatWithMessages.Chat.OwnerID) + require.Equal(t, modelConfig.ID, chatWithMessages.Chat.LastModelConfigID) + require.Equal(t, "get chat route payload", chatWithMessages.Chat.Title) + require.NotZero(t, chatWithMessages.Chat.CreatedAt) + require.NotZero(t, chatWithMessages.Chat.UpdatedAt) + require.NotEmpty(t, chatWithMessages.Messages) + require.Empty(t, chatWithMessages.QueuedMessages) + + foundUserMessage := false + for _, message := range chatWithMessages.Messages { + require.Equal(t, createdChat.ID, message.ChatID) + require.NotEqual(t, "system", message.Role) + for _, part := range message.Content { + if message.Role == "user" && + part.Type == codersdk.ChatMessagePartTypeText && + part.Text == "get chat route payload" { + foundUserMessage = true + } + } + } + require.True(t, foundUserMessage) + }) + + t.Run("NotFoundForDifferentUser", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "private chat", + }, + }, + }) + require.NoError(t, err) + + otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err = otherClient.GetChat(ctx, createdChat.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestDeleteChat(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chatToDelete, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "delete me", + }, + }, + }) + require.NoError(t, err) + + chatToKeep, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "keep me", + }, + }, + }) + require.NoError(t, err) + + chatsBeforeDelete, err := client.ListChats(ctx) + require.NoError(t, err) + require.Len(t, chatsBeforeDelete, 2) + + err = client.DeleteChat(ctx, chatToDelete.ID) + require.NoError(t, err) + + _, err = client.GetChat(ctx, chatToDelete.ID) + requireSDKError(t, err, http.StatusNotFound) + + chatsAfterDelete, err := client.ListChats(ctx) + require.NoError(t, err) + require.Len(t, chatsAfterDelete, 1) + require.Equal(t, chatToKeep.ID, chatsAfterDelete[0].ID) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + err := client.DeleteChat(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestPostChatMessages(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "initial message for post route test", + }, + }, + }) + require.NoError(t, err) + + hasTextPart := func(parts []codersdk.ChatMessagePart, want string) bool { + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == want { + return true + } + } + return false + } + + messageText := "post message route success " + uuid.NewString() + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: messageText, + }, + }, + }) + require.NoError(t, err) + + if created.Queued { + require.Nil(t, created.Message) + require.NotNil(t, created.QueuedMessage) + require.Equal(t, chat.ID, created.QueuedMessage.ChatID) + require.NotZero(t, created.QueuedMessage.ID) + require.True(t, hasTextPart(created.QueuedMessage.Content, messageText)) + + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + + for _, queued := range chatWithMessages.QueuedMessages { + if queued.ID == created.QueuedMessage.ID && + queued.ChatID == chat.ID && + hasTextPart(queued.Content, messageText) { + return true + } + } + for _, message := range chatWithMessages.Messages { + if message.Role == "user" && hasTextPart(message.Content, messageText) { + return true + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + } else { + require.Nil(t, created.QueuedMessage) + require.NotNil(t, created.Message) + require.Equal(t, chat.ID, created.Message.ChatID) + require.Equal(t, "user", created.Message.Role) + require.NotZero(t, created.Message.ID) + require.True(t, hasTextPart(created.Message.Content, messageText)) + + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + for _, message := range chatWithMessages.Messages { + if message.ID == created.Message.ID && + message.Role == "user" && + hasTextPart(message.Content, messageText) { + return true + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + } + }) + + t.Run("EmptyText", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "initial message for validation test", + }, + }, + }) + require.NoError(t, err) + + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: " ", + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid input part.", sdkErr.Message) + require.Equal(t, "content[0].text cannot be empty.", sdkErr.Detail) + }) + + t.Run("ChatNotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + _, err := client.CreateChatMessage(ctx, uuid.New(), codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + }) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestPatchChatMessage(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello before edit", + }, + }, + }) + require.NoError(t, err) + + chatWithMessages, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + + var userMessageID int64 + for _, message := range chatWithMessages.Messages { + if message.Role == "user" { + userMessageID = message.ID + break + } + } + require.NotZero(t, userMessageID) + + edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello after edit", + }, + }, + }) + require.NoError(t, err) + require.Equal(t, userMessageID, edited.ID) + require.Equal(t, "user", edited.Role) + + foundEditedText := false + for _, part := range edited.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "hello after edit" { + foundEditedText = true + } + } + require.True(t, foundEditedText) + + updatedChat, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + foundEditedInChat := false + foundOriginalInChat := false + for _, message := range updatedChat.Messages { + if message.Role != "user" { + continue + } + for _, part := range message.Content { + if part.Type != codersdk.ChatMessagePartTypeText { + continue + } + if part.Text == "hello after edit" { + foundEditedInChat = true + } + if part.Text == "hello before edit" { + foundOriginalInChat = true + } + } + } + require.True(t, foundEditedInChat) + require.False(t, foundOriginalInChat) + }) + + t.Run("MessageNotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + }) + require.NoError(t, err) + + _, err = client.EditChatMessage(ctx, chat.ID, 999999, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "edited", + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusNotFound) + require.Equal(t, "Chat message not found.", sdkErr.Message) + }) + + t.Run("InvalidMessageID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + }) + require.NoError(t, err) + + res, err := client.Request( + ctx, + http.MethodPatch, + fmt.Sprintf("/api/experimental/chats/%s/messages/not-an-int", chat.ID), + codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "ignored", + }, + }, + }, + ) + require.NoError(t, err) + defer res.Body.Close() + + err = codersdk.ReadBodyAsError(res) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid chat message ID.", sdkErr.Message) + }) +} + +func TestStreamChat(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + const initialMessage = "stream chat route initial message" + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: initialMessage, + }, + }, + }) + require.NoError(t, err) + + events, closer, err := client.StreamChat(ctx, chat.ID) + require.NoError(t, err) + defer closer.Close() + + hasTextPart := func(parts []codersdk.ChatMessagePart, want string) bool { + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == want { + return true + } + } + return false + } + + foundInitialUserMessage := false + for !foundInitialUserMessage { + select { + case <-ctx.Done(): + require.FailNow(t, "timed out waiting for expected stream chat event") + case event, ok := <-events: + require.True(t, ok, "stream closed before expected event") + require.Equal(t, chat.ID, event.ChatID) + require.NotEqual(t, codersdk.ChatStreamEventTypeError, event.Type) + + if event.Type == codersdk.ChatStreamEventTypeMessage && + event.Message != nil && + event.Message.Role == "user" && + hasTextPart(event.Message.Content, initialMessage) { + foundInitialUserMessage = true + } + } + } + }) + + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + unauthenticatedClient := codersdk.New(client.URL) + res, err := unauthenticatedClient.Request( + ctx, + http.MethodGet, + fmt.Sprintf("/api/experimental/chats/%s/stream", uuid.New()), + nil, + ) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} + +func TestInterruptChat(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "interrupt route test", + }) + require.NoError(t, err) + + runningWorkerID := uuid.New() + chat, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: runningWorkerID, Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRunning, chat.Status) + require.True(t, chat.WorkerID.Valid) + require.True(t, chat.StartedAt.Valid) + require.True(t, chat.HeartbeatAt.Valid) + + interrupted, err := client.InterruptChat(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, interrupted.ID) + require.Equal(t, codersdk.ChatStatusWaiting, interrupted.Status) + + persisted, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, persisted.Status) + require.False(t, persisted.WorkerID.Valid) + require.False(t, persisted.StartedAt.Valid) + require.False(t, persisted.HeartbeatAt.Valid) + }) + + t.Run("ChatNotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.InterruptChat(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestGetChatDiffStatus(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: chatDeploymentValues(t), + ExternalAuthConfigs: []*externalauth.Config{ + { + ID: "gitlab-test", + Type: "gitlab", + Regex: regexp.MustCompile(`github\.com`), + }, + }, + }) + db := api.Database + + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + noCachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "get diff status route no cache", + }) + require.NoError(t, err) + + noCachedStatus, err := client.GetChatDiffStatus(ctx, noCachedStatusChat.ID) + require.NoError(t, err) + require.Equal(t, noCachedStatusChat.ID, noCachedStatus.ChatID) + require.Nil(t, noCachedStatus.URL) + require.Nil(t, noCachedStatus.PullRequestState) + require.False(t, noCachedStatus.ChangesRequested) + require.Zero(t, noCachedStatus.Additions) + require.Zero(t, noCachedStatus.Deletions) + require.Zero(t, noCachedStatus.ChangedFiles) + require.Nil(t, noCachedStatus.RefreshedAt) + require.Nil(t, noCachedStatus.StaleAt) + + cachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "get diff status route cached", + }) + require.NoError(t, err) + + refreshedAt := time.Date(2026, time.January, 15, 12, 0, 0, 0, time.UTC) + staleAt := time.Date(2026, time.January, 15, 13, 0, 0, 0, time.UTC) + + _, err = db.UpsertChatDiffStatusReference( + dbauthz.AsSystemRestricted(ctx), + database.UpsertChatDiffStatusReferenceParams{ + ChatID: cachedStatusChat.ID, + Url: sql.NullString{}, + GitBranch: "feature/diff-status", + GitRemoteOrigin: "git@github.com:coder/coder.git", + StaleAt: staleAt, + }, + ) + require.NoError(t, err) + + _, err = db.UpsertChatDiffStatus( + dbauthz.AsSystemRestricted(ctx), + database.UpsertChatDiffStatusParams{ + ChatID: cachedStatusChat.ID, + Url: sql.NullString{}, + PullRequestState: sql.NullString{ + String: " open ", + Valid: true, + }, + ChangesRequested: true, + Additions: 11, + Deletions: 4, + ChangedFiles: 3, + RefreshedAt: refreshedAt, + StaleAt: staleAt, + }, + ) + require.NoError(t, err) + + cachedStatus, err := client.GetChatDiffStatus(ctx, cachedStatusChat.ID) + require.NoError(t, err) + require.Equal(t, cachedStatusChat.ID, cachedStatus.ChatID) + require.NotNil(t, cachedStatus.URL) + require.Equal(t, "https://github.com/coder/coder/tree/feature%2Fdiff-status", *cachedStatus.URL) + require.NotNil(t, cachedStatus.PullRequestState) + require.Equal(t, "open", *cachedStatus.PullRequestState) + require.True(t, cachedStatus.ChangesRequested) + require.EqualValues(t, 11, cachedStatus.Additions) + require.EqualValues(t, 4, cachedStatus.Deletions) + require.EqualValues(t, 3, cachedStatus.ChangedFiles) + require.NotNil(t, cachedStatus.RefreshedAt) + require.WithinDuration(t, refreshedAt, *cachedStatus.RefreshedAt, time.Second) + require.NotNil(t, cachedStatus.StaleAt) + require.WithinDuration(t, staleAt, *cachedStatus.StaleAt, time.Second) + }) + + t.Run("NotFoundForDifferentUser", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "private chat", + }, + }, + }) + require.NoError(t, err) + + otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err = otherClient.GetChatDiffStatus(ctx, createdChat.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestGetChatDiffContents(t *testing.T) { + t.Parallel() + + t.Run("SuccessWithCachedRepositoryReference", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: chatDeploymentValues(t), + ExternalAuthConfigs: []*externalauth.Config{ + { + ID: "gitlab-test", + Type: "gitlab", + Regex: regexp.MustCompile(`gitlab\.example\.com`), + }, + }, + }) + db := api.Database + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "diff contents with cached repository reference", + }) + require.NoError(t, err) + + _, err = db.UpsertChatDiffStatusReference( + dbauthz.AsSystemRestricted(ctx), + database.UpsertChatDiffStatusReferenceParams{ + ChatID: chat.ID, + Url: sql.NullString{}, + GitBranch: "feature/cached-diff", + GitRemoteOrigin: "https://gitlab.example.com/acme/project.git", + StaleAt: time.Now().UTC().Add(time.Hour), + }, + ) + require.NoError(t, err) + + diffContents, err := client.GetChatDiffContents(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, diffContents.ChatID) + require.NotNil(t, diffContents.Provider) + require.Equal(t, "gitlab", *diffContents.Provider) + require.NotNil(t, diffContents.RemoteOrigin) + require.Equal(t, "https://gitlab.example.com/acme/project.git", *diffContents.RemoteOrigin) + require.NotNil(t, diffContents.Branch) + require.Equal(t, "feature/cached-diff", *diffContents.Branch) + require.Nil(t, diffContents.PullRequestURL) + require.Empty(t, diffContents.Diff) + }) + + t.Run("SuccessWithoutCachedReference", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "diff contents test", + }, + }, + }) + require.NoError(t, err) + + diffContents, err := client.GetChatDiffContents(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, diffContents.ChatID) + require.Nil(t, diffContents.Provider) + require.Nil(t, diffContents.RemoteOrigin) + require.Nil(t, diffContents.Branch) + require.Nil(t, diffContents.PullRequestURL) + require.Empty(t, diffContents.Diff) + }) + + t.Run("NotFoundForDifferentUser", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "private chat", + }, + }, + }) + require.NoError(t, err) + + otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err = otherClient.GetChatDiffContents(ctx, createdChat.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestDeleteChatQueuedMessage(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "delete queued message route test", + }) + require.NoError(t, err) + + queuedMessage, err := db.InsertChatQueuedMessage( + dbauthz.AsSystemRestricted(ctx), + database.InsertChatQueuedMessageParams{ + ChatID: chat.ID, + Content: []byte(`"queued message for delete route"`), + }, + ) + require.NoError(t, err) + + res, err := client.Request( + ctx, + http.MethodDelete, + fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID), + nil, + ) + require.NoError(t, err) + res.Body.Close() + require.Equal(t, http.StatusNoContent, res.StatusCode) + + chatWithMessages, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + for _, queued := range chatWithMessages.QueuedMessages { + require.NotEqual(t, queuedMessage.ID, queued.ID) + } + + queuedMessages, err := db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + for _, queued := range queuedMessages { + require.NotEqual(t, queuedMessage.ID, queued.ID) + } + }) + + t.Run("InvalidQueuedMessageID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "delete queued invalid id", + }) + require.NoError(t, err) + + invalidRes, err := client.Request( + ctx, + http.MethodDelete, + fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int", chat.ID), + nil, + ) + require.NoError(t, err) + defer invalidRes.Body.Close() + + err = codersdk.ReadBodyAsError(invalidRes) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid queued message ID.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "invalid syntax") + }) +} + +func TestPromoteChatQueuedMessage(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "promote queued message route test", + }) + require.NoError(t, err) + + const queuedText = "queued message for promote route" + queuedMessage, err := db.InsertChatQueuedMessage( + dbauthz.AsSystemRestricted(ctx), + database.InsertChatQueuedMessageParams{ + ChatID: chat.ID, + Content: []byte(fmt.Sprintf("%q", queuedText)), + }, + ) + require.NoError(t, err) + + promoteRes, err := client.Request( + ctx, + http.MethodPost, + fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID), + nil, + ) + require.NoError(t, err) + defer promoteRes.Body.Close() + require.Equal(t, http.StatusOK, promoteRes.StatusCode) + + var promoted codersdk.ChatMessage + err = json.NewDecoder(promoteRes.Body).Decode(&promoted) + require.NoError(t, err) + require.NotZero(t, promoted.ID) + require.Equal(t, chat.ID, promoted.ChatID) + require.Equal(t, "user", promoted.Role) + + foundPromotedText := false + for _, part := range promoted.Content { + if part.Type == codersdk.ChatMessagePartTypeText && + part.Text == queuedText { + foundPromotedText = true + break + } + } + require.True(t, foundPromotedText) + + chatWithMessages, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + for _, queued := range chatWithMessages.QueuedMessages { + require.NotEqual(t, queuedMessage.ID, queued.ID) + } + + queuedMessages, err := db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + for _, queued := range queuedMessages { + require.NotEqual(t, queuedMessage.ID, queued.ID) + } + }) + + t.Run("InvalidQueuedMessageID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "promote queued invalid id", + }) + require.NoError(t, err) + + invalidRes, err := client.Request( + ctx, + http.MethodPost, + fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int/promote", chat.ID), + nil, + ) + require.NoError(t, err) + defer invalidRes.Body.Close() + + err = codersdk.ReadBodyAsError(invalidRes) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid queued message ID.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "invalid syntax") + }) +} + +func createChatModelConfig(t *testing.T, client *codersdk.Client) codersdk.ChatModelConfig { + t.Helper() + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + contextLimit := int64(4096) + isDefault := true + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + IsDefault: &isDefault, + }) + require.NoError(t, err) + return modelConfig +} + +func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error { + t.Helper() + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, expectedStatus, sdkErr.StatusCode()) + return sdkErr +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 99a3073ad3..6f5377819b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -49,6 +49,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/awsidentity" "github.com/coder/coder/v2/coderd/boundaryusage" + "github.com/coder/coder/v2/coderd/chatd" "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/database" @@ -238,6 +239,9 @@ type Options struct { SSHConfig codersdk.SSHConfigResponse HTTPClient *http.Client + // ChatRemotePartsProvider provides cross-replica message_part streaming. + // Set by enterprise for HA deployments. Nil in AGPL single-replica. + ChatRemotePartsProvider chatd.RemotePartsProvider UpdateAgentMetrics func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) StatsBatcher workspacestats.Batcher @@ -588,7 +592,6 @@ func New(options *Options) *API { var buildUsageChecker atomic.Pointer[wsbuilder.UsageChecker] var noopUsageChecker wsbuilder.UsageChecker = wsbuilder.NoopUsageChecker{} buildUsageChecker.Store(&noopUsageChecker) - api := &API{ ctx: ctx, cancel: cancel, @@ -754,6 +757,17 @@ func New(options *Options) *API { panic("failed to setup server tailnet: " + err.Error()) } api.agentProvider = stn + + api.chatDaemon = chatd.New(chatd.Config{ + Logger: options.Logger.Named("chats"), + Database: options.Database, + ReplicaID: api.ID, + RemotePartsProvider: options.ChatRemotePartsProvider, + ProviderAPIKeys: chatProviderAPIKeysFromDeploymentValues(options.DeploymentValues), + AgentConn: api.agentProvider.AgentConn, + CreateWorkspace: api.chatCreateWorkspace, + Pubsub: options.Pubsub, + }) if options.DeploymentValues.Prometheus.Enable { options.PrometheusRegistry.MustRegister(stn) api.lifecycleMetrics = agentapi.NewLifecycleMetrics(options.PrometheusRegistry) @@ -1085,6 +1099,48 @@ func New(options *Options) *API { }) }) }) + r.Route("/chats", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentAgents), + ) + r.Get("/", api.listChats) + r.Post("/", api.postChats) + r.Get("/models", api.listChatModels) + r.Get("/watch", api.watchChats) + r.Route("/providers", func(r chi.Router) { + r.Get("/", api.listChatProviders) + r.Post("/", api.createChatProvider) + r.Route("/{providerConfig}", func(r chi.Router) { + r.Patch("/", api.updateChatProvider) + r.Delete("/", api.deleteChatProvider) + }) + }) + r.Route("/model-configs", func(r chi.Router) { + r.Get("/", api.listChatModelConfigs) + r.Post("/", api.createChatModelConfig) + r.Route("/{modelConfig}", func(r chi.Router) { + r.Patch("/", api.updateChatModelConfig) + r.Delete("/", api.deleteChatModelConfig) + }) + }) + r.Route("/{chat}", func(r chi.Router) { + r.Use(httpmw.ExtractChatParam(options.Database)) + r.Get("/", api.getChat) + r.Delete("/", api.deleteChat) + r.Post("/messages", api.postChatMessages) + r.Patch("/messages/{message}", api.patchChatMessage) + r.Get("/stream", api.streamChat) + r.Post("/interrupt", api.interruptChat) + r.Get("/diff-status", api.getChatDiffStatus) + r.Get("/diff", api.getChatDiffContents) + r.Route("/queue/{queuedMessage}", func(r chi.Router) { + r.Delete("/", api.deleteChatQueuedMessage) + r.Post("/promote", api.promoteChatQueuedMessage) + }) + }) + }) + r.Route("/mcp", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1902,6 +1958,8 @@ type API struct { // dbRolluper rolls up template usage stats from raw agent and app // stats. This is used to provide insights in the WebUI. dbRolluper *dbrollup.Rolluper + // chatDaemon handles background processing of pending chats. + chatDaemon *chatd.Server } // Close waits for all WebSocket connections to drain before returning. @@ -1930,8 +1988,10 @@ func (api *API) Close() error { case <-timer.C: api.Logger.Warn(api.ctx, "websocket shutdown timed out after 10 seconds") } - api.dbRolluper.Close() + if err := api.chatDaemon.Close(); err != nil { + api.Logger.Warn(api.ctx, "close chat processor", slog.Error(err)) + } api.metricsCache.Close() if api.updateChecker != nil { api.updateChecker.Close() diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 8a261b8603..8d0b7189e7 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -6,17 +6,20 @@ type CheckConstraint string // CheckConstraint enums. const ( - CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys - CheckOrganizationIDNotZero CheckConstraint = "organization_id_not_zero" // custom_roles - CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users - CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users - CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs - CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents - CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents - CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds - CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks - CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters - CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events - CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces - CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces + CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys + CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs + CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs + CheckChatProvidersProviderCheck CheckConstraint = "chat_providers_provider_check" // chat_providers + CheckOrganizationIDNotZero CheckConstraint = "organization_id_not_zero" // custom_roles + CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users + CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users + CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs + CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents + CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents + CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds + CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks + CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters + CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events + CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces + CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces ) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 98bb61bdfc..9cfb4a344a 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -2,6 +2,7 @@ package db2sdk import ( + "database/sql" "encoding/json" "fmt" "net/url" @@ -11,6 +12,7 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/google/uuid" "github.com/hashicorp/hcl/v2" "github.com/sqlc-dev/pqtype" @@ -18,6 +20,7 @@ import ( "tailscale.com/tailcfg" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/chatd/chatprompt" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -1050,3 +1053,345 @@ func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any { } return m } + +func ChatMessage(m database.ChatMessage) codersdk.ChatMessage { + modelConfigID := &m.ModelConfigID.UUID + if !m.ModelConfigID.Valid { + modelConfigID = nil + } + msg := codersdk.ChatMessage{ + ID: m.ID, + ChatID: m.ChatID, + ModelConfigID: modelConfigID, + CreatedAt: m.CreatedAt, + Role: m.Role, + } + if m.Content.Valid { + parts, err := chatMessageParts(m.Role, m.Content) + if err == nil { + msg.Content = parts + } + } + usage := chatMessageUsage(m) + if usage != nil { + msg.Usage = usage + } + return msg +} + +// chatMessageUsage builds a ChatMessageUsage from the database row, +// returning nil when no token fields are populated. +func chatMessageUsage(m database.ChatMessage) *codersdk.ChatMessageUsage { + inputTokens := nullInt64Ptr(m.InputTokens) + outputTokens := nullInt64Ptr(m.OutputTokens) + totalTokens := nullInt64Ptr(m.TotalTokens) + reasoningTokens := nullInt64Ptr(m.ReasoningTokens) + cacheCreationTokens := nullInt64Ptr(m.CacheCreationTokens) + cacheReadTokens := nullInt64Ptr(m.CacheReadTokens) + contextLimit := nullInt64Ptr(m.ContextLimit) + + if inputTokens == nil && outputTokens == nil && totalTokens == nil && + reasoningTokens == nil && cacheCreationTokens == nil && + cacheReadTokens == nil && contextLimit == nil { + return nil + } + + return &codersdk.ChatMessageUsage{ + InputTokens: inputTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, + ReasoningTokens: reasoningTokens, + CacheCreationTokens: cacheCreationTokens, + CacheReadTokens: cacheReadTokens, + ContextLimit: contextLimit, + } +} + +// ChatQueuedMessage converts a queued message to its SDK representation. +func ChatQueuedMessage(message database.ChatQueuedMessage) codersdk.ChatQueuedMessage { + parts, err := chatMessageParts(string(fantasy.MessageRoleUser), pqtype.NullRawMessage{ + RawMessage: message.Content, + Valid: len(message.Content) > 0, + }) + if err != nil { + parts = nil + } + + return codersdk.ChatQueuedMessage{ + ID: message.ID, + ChatID: message.ChatID, + Content: parts, + CreatedAt: message.CreatedAt, + } +} + +// ChatQueuedMessages converts a slice of database queued messages +// to their SDK representation. +func ChatQueuedMessages(messages []database.ChatQueuedMessage) []codersdk.ChatQueuedMessage { + out := make([]codersdk.ChatQueuedMessage, 0, len(messages)) + for _, message := range messages { + out = append(out, ChatQueuedMessage(message)) + } + return out +} + +func chatMessageParts(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) { + switch role { + case string(fantasy.MessageRoleSystem): + content, err := parseSystemContent(raw) + if err != nil { + return nil, err + } + if strings.TrimSpace(content) == "" { + return nil, nil + } + return []codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeText, + Text: content, + }}, nil + case string(fantasy.MessageRoleUser), string(fantasy.MessageRoleAssistant): + content, err := parseContentBlocks(role, raw) + if err != nil { + return nil, err + } + + var rawBlocks []json.RawMessage + if role == string(fantasy.MessageRoleAssistant) { + _ = json.Unmarshal(raw.RawMessage, &rawBlocks) + } + + parts := make([]codersdk.ChatMessagePart, 0, len(content)) + for i, block := range content { + part := contentBlockToPart(block) + if part.Type == "" { + continue + } + if part.Type == codersdk.ChatMessagePartTypeReasoning { + part.Title = "" + if i < len(rawBlocks) { + part.Title = reasoningStoredTitle(rawBlocks[i]) + } + } + parts = append(parts, part) + } + return parts, nil + case string(fantasy.MessageRoleTool): + results, err := parseToolResults(raw) + if err != nil { + return nil, err + } + parts := make([]codersdk.ChatMessagePart, 0, len(results)) + for _, result := range results { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolResult, + ToolCallID: result.ToolCallID, + ToolName: result.ToolName, + Result: result.Result, + IsError: result.IsError, + }) + } + return parts, nil + default: + return nil, nil + } +} + +func parseSystemContent(raw pqtype.NullRawMessage) (string, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return "", nil + } + var content string + if err := json.Unmarshal(raw.RawMessage, &content); err != nil { + return "", xerrors.Errorf("parse system content: %w", err) + } + return content, nil +} + +func parseContentBlocks(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return nil, nil + } + + if role == string(fantasy.MessageRoleUser) { + var text string + if err := json.Unmarshal(raw.RawMessage, &text); err == nil { + return []fantasy.Content{ + fantasy.TextContent{Text: text}, + }, nil + } + } + + var blocks []json.RawMessage + if err := json.Unmarshal(raw.RawMessage, &blocks); err != nil { + return nil, xerrors.Errorf("parse content blocks: %w", err) + } + + content := make([]fantasy.Content, 0, len(blocks)) + for _, block := range blocks { + decoded, err := fantasy.UnmarshalContent(block) + if err != nil { + return nil, xerrors.Errorf("parse content block: %w", err) + } + content = append(content, decoded) + } + + return content, nil +} + +// toolResultRow is used only for extracting top-level fields from +// persisted tool result JSON. The result payload is kept as raw JSON. +type toolResultRow struct { + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Result json.RawMessage `json:"result"` + IsError bool `json:"is_error,omitempty"` +} + +func parseToolResults(raw pqtype.NullRawMessage) ([]toolResultRow, error) { + if !raw.Valid || len(raw.RawMessage) == 0 { + return nil, nil + } + + var results []toolResultRow + if err := json.Unmarshal(raw.RawMessage, &results); err != nil { + return nil, xerrors.Errorf("parse tool results: %w", err) + } + return results, nil +} + +func reasoningStoredTitle(raw json.RawMessage) string { + var envelope struct { + Type string `json:"type"` + Data struct { + Title string `json:"title"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return "" + } + if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) { + return "" + } + return strings.TrimSpace(envelope.Data.Title) +} + +func contentBlockToPart(block fantasy.Content) codersdk.ChatMessagePart { + switch value := block.(type) { + case fantasy.TextContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: value.Text, + } + case *fantasy.TextContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: value.Text, + } + case fantasy.ReasoningContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: value.Text, + } + case *fantasy.ReasoningContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: value.Text, + } + case fantasy.ToolCallContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + } + case *fantasy.ToolCallContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + } + case fantasy.SourceContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSource, + SourceID: value.ID, + URL: value.URL, + Title: value.Title, + } + case *fantasy.SourceContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSource, + SourceID: value.ID, + URL: value.URL, + Title: value.Title, + } + case fantasy.FileContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeFile, + MediaType: value.MediaType, + Data: value.Data, + } + case *fantasy.FileContent: + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeFile, + MediaType: value.MediaType, + Data: value.Data, + } + case fantasy.ToolResultContent: + return chatprompt.ToolResultToPart( + value.ToolCallID, + value.ToolName, + toolResultOutputToRawJSON(value.Result), + toolResultOutputIsError(value.Result), + ) + case *fantasy.ToolResultContent: + return chatprompt.ToolResultToPart( + value.ToolCallID, + value.ToolName, + toolResultOutputToRawJSON(value.Result), + toolResultOutputIsError(value.Result), + ) + default: + return codersdk.ChatMessagePart{} + } +} + +func toolResultOutputToRawJSON(output fantasy.ToolResultOutputContent) json.RawMessage { + switch v := output.(type) { + case fantasy.ToolResultOutputContentError: + if v.Error != nil { + data, _ := json.Marshal(map[string]any{"error": v.Error.Error()}) + return data + } + return json.RawMessage(`{"error":""}`) + case fantasy.ToolResultOutputContentText: + raw := json.RawMessage(v.Text) + if json.Valid(raw) { + return raw + } + data, _ := json.Marshal(map[string]any{"output": v.Text}) + return data + case fantasy.ToolResultOutputContentMedia: + data, _ := json.Marshal(map[string]any{ + "data": v.Data, + "mime_type": v.MediaType, + "text": v.Text, + }) + return data + default: + return json.RawMessage(`{}`) + } +} + +func toolResultOutputIsError(output fantasy.ToolResultOutputContent) bool { + _, ok := output.(fantasy.ToolResultOutputContentError) + return ok +} + +func nullInt64Ptr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + value := v.Int64 + return &value +} diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 4686e0fa07..a9a9527370 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" @@ -435,3 +437,132 @@ func TestAIBridgeInterception(t *testing.T) { }) } } + +func TestChatMessage_ReasoningPartWithoutPersistedTitleIsEmpty(t *testing.T) { + t.Parallel() + + assistantContent, err := json.Marshal([]fantasy.Content{ + fantasy.ReasoningContent{ + Text: "Plan migration", + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyopenai.Name: &fantasyopenai.ResponsesReasoningMetadata{ + ItemID: "reasoning-1", + Summary: []string{"Plan migration"}, + }, + }, + }, + }) + require.NoError(t, err) + + message := db2sdk.ChatMessage(database.ChatMessage{ + ID: 1, + ChatID: uuid.New(), + CreatedAt: time.Now(), + Role: string(fantasy.MessageRoleAssistant), + Content: pqtype.NullRawMessage{ + RawMessage: assistantContent, + Valid: true, + }, + }) + + require.Len(t, message.Content, 1) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type) + require.Equal(t, "Plan migration", message.Content[0].Text) + require.Empty(t, message.Content[0].Title) +} + +func TestChatMessage_ReasoningPartPrefersPersistedTitle(t *testing.T) { + t.Parallel() + + reasoningContent, err := json.Marshal(fantasy.ReasoningContent{ + Text: "Verify schema updates, then apply changes in order.", + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyopenai.Name: &fantasyopenai.ResponsesReasoningMetadata{ + ItemID: "reasoning-1", + Summary: []string{ + "**Metadata-derived title**\n\nLonger explanation.", + }, + }, + }, + }) + require.NoError(t, err) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(reasoningContent, &envelope)) + dataValue, ok := envelope["data"].(map[string]any) + require.True(t, ok) + dataValue["title"] = "Persisted stream title" + + encodedReasoning, err := json.Marshal(envelope) + require.NoError(t, err) + assistantContent, err := json.Marshal([]json.RawMessage{encodedReasoning}) + require.NoError(t, err) + + message := db2sdk.ChatMessage(database.ChatMessage{ + ID: 1, + ChatID: uuid.New(), + CreatedAt: time.Now(), + Role: string(fantasy.MessageRoleAssistant), + Content: pqtype.NullRawMessage{ + RawMessage: assistantContent, + Valid: true, + }, + }) + + require.Len(t, message.Content, 1) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type) + require.Equal(t, "Persisted stream title", message.Content[0].Title) +} + +func TestChatQueuedMessage_ParsesUserContentParts(t *testing.T) { + t.Parallel() + + rawContent, err := json.Marshal([]fantasy.Content{ + fantasy.TextContent{Text: "queued text"}, + }) + require.NoError(t, err) + + queued := db2sdk.ChatQueuedMessage(database.ChatQueuedMessage{ + ID: 1, + ChatID: uuid.New(), + Content: rawContent, + CreatedAt: time.Now(), + }) + + require.Len(t, queued.Content, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, queued.Content[0].Type) + require.Equal(t, "queued text", queued.Content[0].Text) +} + +func TestChatQueuedMessage_FallsBackToTextForLegacyContent(t *testing.T) { + t.Parallel() + + t.Run("legacy_string", func(t *testing.T) { + t.Parallel() + + queued := db2sdk.ChatQueuedMessage(database.ChatQueuedMessage{ + ID: 1, + ChatID: uuid.New(), + Content: json.RawMessage(`"legacy queued text"`), + CreatedAt: time.Now(), + }) + + require.Len(t, queued.Content, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, queued.Content[0].Type) + require.Equal(t, "legacy queued text", queued.Content[0].Text) + }) + + t.Run("malformed_payload", func(t *testing.T) { + t.Parallel() + + raw := json.RawMessage(`{"unexpected":"shape"}`) + queued := db2sdk.ChatQueuedMessage(database.ChatQueuedMessage{ + ID: 1, + ChatID: uuid.New(), + Content: raw, + CreatedAt: time.Now(), + }) + + require.Empty(t, queued.Content) + }) +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 08e87edfe7..efb5fb61b2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -453,6 +453,7 @@ var ( rbac.ResourceProvisionerJobs.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreate}, rbac.ResourceOauth2App.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -1484,6 +1485,15 @@ func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.Prov return nil } +func (q *querier) AcquireChat(ctx context.Context, arg database.AcquireChatParams) (database.Chat, error) { + // AcquireChat is a system-level operation used by the chat processor. + // Authorization is done at the system level, not per-user. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil { + return database.Chat{}, err + } + return q.db.AcquireChat(ctx, arg) +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -1712,6 +1722,17 @@ func (q *querier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) e return q.db.DeleteAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, chatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.DeleteAllChatQueuedMessages(ctx, chatID) +} + func (q *querier) DeleteAllTailnetTunnels(ctx context.Context, arg database.DeleteAllTailnetTunnelsParams) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1736,6 +1757,66 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteChatByID(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionDelete, chat); err != nil { + return err + } + return q.db.DeleteChatByID(ctx, id) +} + +func (q *querier) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { + // Authorize update on the parent chat. + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.DeleteChatMessagesAfterID(ctx, arg) +} + +func (q *querier) DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error { + // Authorize delete on the parent chat. + chat, err := q.db.GetChatByID(ctx, chatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionDelete, chat); err != nil { + return err + } + return q.db.DeleteChatMessagesByChatID(ctx, chatID) +} + +func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.DeleteChatModelConfigByID(ctx, id) +} + +func (q *querier) DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.DeleteChatProviderByID(ctx, id) +} + +func (q *querier) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.DeleteChatQueuedMessage(ctx, arg) +} + func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -2304,6 +2385,131 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + return fetch(q.log, q.auth, q.db.GetChatByID)(ctx, id) +} + +func (q *querier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (database.Chat, error) { + return fetch(q.log, q.auth, q.db.GetChatByIDForUpdate)(ctx, id) +} + +func (q *querier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (database.ChatDiffStatus, error) { + // Authorize read on the parent chat. + _, err := q.GetChatByID(ctx, chatID) + if err != nil { + return database.ChatDiffStatus{}, err + } + return q.db.GetChatDiffStatusByChatID(ctx, chatID) +} + +func (q *querier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uuid.UUID) ([]database.ChatDiffStatus, error) { + if len(chatIDs) == 0 { + return []database.ChatDiffStatus{}, nil + } + + actor, ok := ActorFromContext(ctx) + if ok && actor.Type == rbac.SubjectTypeSystemRestricted { + return q.db.GetChatDiffStatusesByChatIDs(ctx, chatIDs) + } + + for _, chatID := range chatIDs { + // Authorize read on each parent chat. + _, err := q.GetChatByID(ctx, chatID) + if err != nil { + return nil, err + } + } + + return q.db.GetChatDiffStatusesByChatIDs(ctx, chatIDs) +} + +func (q *querier) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { + // ChatMessages are authorized through their parent Chat. + // We need to fetch the message first to get its chat_id. + msg, err := q.db.GetChatMessageByID(ctx, id) + if err != nil { + return database.ChatMessage{}, err + } + // Authorize read on the parent chat. + _, err = q.GetChatByID(ctx, msg.ChatID) + if err != nil { + return database.ChatMessage{}, err + } + return msg, nil +} + +func (q *querier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + // Authorize read on the parent chat. + _, err := q.GetChatByID(ctx, chatID) + if err != nil { + return nil, err + } + return q.db.GetChatMessagesByChatID(ctx, chatID) +} + +func (q *querier) GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + // Authorize read on the parent chat. + _, err := q.GetChatByID(ctx, chatID) + if err != nil { + return nil, err + } + return q.db.GetChatMessagesForPromptByChatID(ctx, chatID) +} + +func (q *querier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.GetChatModelConfigByID(ctx, id) +} + +func (q *querier) GetChatModelConfigByProviderAndModel(ctx context.Context, arg database.GetChatModelConfigByProviderAndModelParams) (database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.GetChatModelConfigByProviderAndModel(ctx, arg) +} + +func (q *querier) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetChatModelConfigs(ctx) +} + +func (q *querier) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatProvider{}, err + } + return q.db.GetChatProviderByID(ctx, id) +} + +func (q *querier) GetChatProviderByProvider(ctx context.Context, provider string) (database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatProvider{}, err + } + return q.db.GetChatProviderByProvider(ctx, provider) +} + +func (q *querier) GetChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetChatProviders(ctx) +} + +func (q *querier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]database.ChatQueuedMessage, error) { + _, err := q.GetChatByID(ctx, chatID) + if err != nil { + return nil, err + } + return q.db.GetChatQueuedMessages(ctx, chatID) +} + +func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID) +} + func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { // Just like with the audit logs query, shortcut if the user is an owner. err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog) @@ -2361,6 +2567,13 @@ func (q *querier) GetDERPMeshKey(ctx context.Context) (string, error) { return q.db.GetDERPMeshKey(ctx) } +func (q *querier) GetDefaultChatModelConfig(ctx context.Context) (database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.GetDefaultChatModelConfig(ctx) +} + func (q *querier) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { return fetch(q.log, q.auth, func(ctx context.Context, _ any) (database.Organization, error) { return q.db.GetDefaultOrganization(ctx) @@ -2401,6 +2614,20 @@ func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.C return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEligibleProvisionerDaemonsByProvisionerJobIDs)(ctx, provisionerJobIDs) } +func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetEnabledChatModelConfigs(ctx) +} + +func (q *querier) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetEnabledChatProviders(ctx) +} + func (q *querier) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetExternalAuthLink)(ctx, arg) } @@ -3100,6 +3327,14 @@ func (q *querier) GetRuntimeConfig(ctx context.Context, key string) (string, err return q.db.GetRuntimeConfig(ctx, key) } +func (q *querier) GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]database.Chat, error) { + // GetStaleChats is a system-level operation used by the chat processor for recovery. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil { + return nil, err + } + return q.db.GetStaleChats(ctx, staleThreshold) +} + func (q *querier) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]database.TailnetPeer, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil { return nil, err @@ -4223,6 +4458,47 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } +func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg) +} + +func (q *querier) InsertChatMessage(ctx context.Context, arg database.InsertChatMessageParams) (database.ChatMessage, error) { + // Authorize create on the parent chat (using update permission). + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatMessage{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatMessage{}, err + } + return q.db.InsertChatMessage(ctx, arg) +} + +func (q *querier) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.InsertChatModelConfig(ctx, arg) +} + +func (q *querier) InsertChatProvider(ctx context.Context, arg database.InsertChatProviderParams) (database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatProvider{}, err + } + return q.db.InsertChatProvider(ctx, arg) +} + +func (q *querier) InsertChatQueuedMessage(ctx context.Context, arg database.InsertChatQueuedMessageParams) (database.ChatQueuedMessage, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatQueuedMessage{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatQueuedMessage{}, err + } + return q.db.InsertChatQueuedMessage(ctx, arg) +} + func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -4829,6 +5105,14 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs) } +func (q *querier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID) ([]database.Chat, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListChatsByRootID)(ctx, rootChatID) +} + +func (q *querier) ListChildChatsByParentID(ctx context.Context, parentChatID uuid.UUID) ([]database.Chat, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListChildChatsByParentID)(ctx, parentChatID) +} + func (q *querier) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganization)(ctx, organizationID) } @@ -4909,6 +5193,17 @@ func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database return q.db.PaginatedOrganizationMembers(ctx, arg) } +func (q *querier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) { + chat, err := q.db.GetChatByID(ctx, chatID) + if err != nil { + return database.ChatQueuedMessage{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatQueuedMessage{}, err + } + return q.db.PopNextQueuedMessage(ctx, chatID) +} + func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { template, err := q.db.GetTemplateByID(ctx, templateID) if err != nil { @@ -4987,6 +5282,13 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error { return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id) } +func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.UnsetDefaultChatModelConfigs(ctx) +} + func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) { if err := q.authorizeAIBridgeInterceptionAction(ctx, policy.ActionUpdate, params.ID); err != nil { return database.AIBridgeInterception{}, err @@ -5001,6 +5303,91 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } +func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return database.Chat{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.Chat{}, err + } + return q.db.UpdateChatByID(ctx, arg) +} + +func (q *querier) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return 0, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return 0, err + } + return q.db.UpdateChatHeartbeat(ctx, arg) +} + +func (q *querier) UpdateChatMessageByID(ctx context.Context, arg database.UpdateChatMessageByIDParams) (database.ChatMessage, error) { + // Authorize update on the parent chat of the edited message. + msg, err := q.db.GetChatMessageByID(ctx, arg.ID) + if err != nil { + return database.ChatMessage{}, err + } + chat, err := q.db.GetChatByID(ctx, msg.ChatID) + if err != nil { + return database.ChatMessage{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatMessage{}, err + } + return q.db.UpdateChatMessageByID(ctx, arg) +} + +func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.UpdateChatModelConfig(ctx, arg) +} + +func (q *querier) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return database.ChatProvider{}, err + } + return q.db.UpdateChatProvider(ctx, arg) +} + +func (q *querier) UpdateChatStatus(ctx context.Context, arg database.UpdateChatStatusParams) (database.Chat, error) { + // UpdateChatStatus is used by the chat processor to change chat status. + // It should be called with system context. + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return database.Chat{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.Chat{}, err + } + return q.db.UpdateChatStatus(ctx, arg) +} + +func (q *querier) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return database.Chat{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.Chat{}, err + } + + // UpdateChatWorkspace is manually implemented for chat tables and may not be + // present on every wrapped store interface yet. + chatWorkspaceUpdater, ok := q.db.(interface { + UpdateChatWorkspace(context.Context, database.UpdateChatWorkspaceParams) (database.Chat, error) + }) + if !ok { + return database.Chat{}, xerrors.New("update chat workspace is not implemented by wrapped store") + } + return chatWorkspaceUpdater.UpdateChatWorkspace(ctx, arg) +} + func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -6056,6 +6443,30 @@ func (q *querier) UpsertBoundaryUsageStats(ctx context.Context, arg database.Ups return q.db.UpsertBoundaryUsageStats(ctx, arg) } +func (q *querier) UpsertChatDiffStatus(ctx context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) { + // Authorize update on the parent chat. + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDiffStatus{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDiffStatus{}, err + } + return q.db.UpsertChatDiffStatus(ctx, arg) +} + +func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) { + // Authorize update on the parent chat. + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDiffStatus{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDiffStatus{}, err + } + return q.db.UpsertChatDiffStatusReference(ctx, arg) +} + func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil { return database.ConnectionLog{}, err @@ -6347,18 +6758,12 @@ func (q *querier) CountAuthorizedConnectionLogs(ctx context.Context, arg databas return q.CountConnectionLogs(ctx, arg) } -func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) { - // TODO: Delete this function, all ListAIBridgeInterceptions should be authorized. For now just call ListAIBridgeInterceptions on the authz querier. - // This cannot be deleted for now because it's included in the - // database.Store interface, so dbauthz needs to implement it. - return q.ListAIBridgeInterceptions(ctx, arg) +func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) { + return q.db.ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared) } -func (q *querier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) (int64, error) { - // TODO: Delete this function, all CountAIBridgeInterceptions should be authorized. For now just call CountAIBridgeInterceptions on the authz querier. - // This cannot be deleted for now because it's included in the - // database.Store interface, so dbauthz needs to implement it. - return q.CountAIBridgeInterceptions(ctx, arg) +func (q *querier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) { + return q.db.CountAuthorizedAIBridgeInterceptions(ctx, arg, prepared) } func (q *querier) ListAuthorizedAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams, _ rbac.PreparedAuthorized) ([]string, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7f03e65166..0d96bfea5c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -170,6 +170,7 @@ func TestDBAuthzRecursive(t *testing.T) { Groups: []string{}, Scope: rbac.ScopeAll, } + preparedAuthorizedType := reflect.TypeOf((*rbac.PreparedAuthorized)(nil)).Elem() for i := 0; i < reflect.TypeOf(q).NumMethod(); i++ { var ins []reflect.Value ctx := dbauthz.As(context.Background(), actor) @@ -177,7 +178,13 @@ func TestDBAuthzRecursive(t *testing.T) { ins = append(ins, reflect.ValueOf(ctx)) method := reflect.TypeOf(q).Method(i) for i := 2; i < method.Type.NumIn(); i++ { - ins = append(ins, reflect.New(method.Type.In(i)).Elem()) + inType := method.Type.In(i) + if inType.Implements(preparedAuthorizedType) { + ins = append(ins, reflect.ValueOf(emptyPreparedAuthorized{})) + continue + } + + ins = append(ins, reflect.New(inType).Elem()) } if method.Name == "InTx" || method.Name == "Ping" || @@ -364,6 +371,371 @@ func (s *MethodTestSuite) TestConnectionLogs() { })) } +func (s *MethodTestSuite) TestChats() { + s.Run("AcquireChat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.AcquireChatParams{ + StartedAt: dbtime.Now(), + WorkerID: uuid.New(), + } + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().AcquireChat(gomock.Any(), arg).Return(chat, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(chat) + })) + s.Run("DeleteAllChatQueuedMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteAllChatQueuedMessages(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("DeleteChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionDelete).Returns() + })) + s.Run("DeleteChatMessagesByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatMessagesByChatID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionDelete).Returns() + })) + s.Run("DeleteChatMessagesAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.DeleteChatMessagesAfterIDParams{ + ChatID: chat.ID, + AfterID: 123, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatMessagesAfterID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), id).Return(nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) + s.Run("DeleteChatProviderByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().DeleteChatProviderByID(gomock.Any(), id).Return(nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) + s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + args := database.DeleteChatQueuedMessageParams{ID: 123, ChatID: chat.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatQueuedMessage(gomock.Any(), args).Return(nil).AnyTimes() + check.Args(args).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("GetChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(chat) + })) + s.Run("GetChatByIDForUpdate", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByIDForUpdate(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(chat) + })) + s.Run("GetChatDiffStatusByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + diffStatus := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatDiffStatusByChatID(gomock.Any(), chat.ID).Return(diffStatus, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(diffStatus) + })) + s.Run("GetChatDiffStatusesByChatIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chatA := testutil.Fake(s.T(), faker, database.Chat{}) + chatB := testutil.Fake(s.T(), faker, database.Chat{}) + ids := []uuid.UUID{chatA.ID, chatB.ID} + diffStatusA := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chatA.ID}) + diffStatusB := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chatB.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chatA.ID).Return(chatA, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chatB.ID).Return(chatB, nil).AnyTimes() + dbm.EXPECT().GetChatDiffStatusesByChatIDs(gomock.Any(), ids).Return([]database.ChatDiffStatus{diffStatusA, diffStatusB}, nil).AnyTimes() + check.Args(ids). + Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead). + Returns([]database.ChatDiffStatus{diffStatusA, diffStatusB}) + })) + s.Run("GetChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + dbm.EXPECT().GetChatMessageByID(gomock.Any(), msg.ID).Return(msg, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + check.Args(msg.ID).Asserts(chat, policy.ActionRead).Returns(msg) + })) + s.Run("GetChatMessagesByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatMessagesByChatID(gomock.Any(), chat.ID).Return(msgs, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(msgs) + })) + s.Run("GetChatMessagesForPromptByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatMessagesForPromptByChatID(gomock.Any(), chat.ID).Return(msgs, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(msgs) + })) + s.Run("GetChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + })) + s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any()).Return(config, nil).AnyTimes() + check.Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + })) + s.Run("GetChatModelConfigByProviderAndModel", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + args := database.GetChatModelConfigByProviderAndModelParams{ + Provider: config.Provider, + Model: config.Model, + } + dbm.EXPECT().GetChatModelConfigByProviderAndModel(gomock.Any(), args).Return(config, nil).AnyTimes() + check.Args(args).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + })) + s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + dbm.EXPECT().GetChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB}) + })) + s.Run("GetChatProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.ChatProvider{}) + dbm.EXPECT().GetChatProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes() + check.Args(provider.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(provider) + })) + s.Run("GetChatProviderByProvider", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + providerName := "test-provider" + provider := testutil.Fake(s.T(), faker, database.ChatProvider{Provider: providerName}) + dbm.EXPECT().GetChatProviderByProvider(gomock.Any(), providerName).Return(provider, nil).AnyTimes() + check.Args(providerName).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(provider) + })) + s.Run("GetChatProviders", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + providerA := testutil.Fake(s.T(), faker, database.ChatProvider{}) + providerB := testutil.Fake(s.T(), faker, database.ChatProvider{}) + dbm.EXPECT().GetChatProviders(gomock.Any()).Return([]database.ChatProvider{providerA, providerB}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatProvider{providerA, providerB}) + })) + s.Run("GetChatsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + c1 := testutil.Fake(s.T(), faker, database.Chat{}) + c2 := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatsByOwnerID(gomock.Any(), c1.OwnerID).Return([]database.Chat{c1, c2}, nil).AnyTimes() + check.Args(c1.OwnerID).Asserts(c1, policy.ActionRead, c2, policy.ActionRead).Returns([]database.Chat{c1, c2}) + })) + s.Run("GetChatQueuedMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + qms := []database.ChatQueuedMessage{testutil.Fake(s.T(), faker, database.ChatQueuedMessage{})} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatQueuedMessages(gomock.Any(), chat.ID).Return(qms, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(qms) + })) + s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + dbm.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB}) + })) + s.Run("GetEnabledChatProviders", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + providerA := testutil.Fake(s.T(), faker, database.ChatProvider{}) + providerB := testutil.Fake(s.T(), faker, database.ChatProvider{}) + dbm.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{providerA, providerB}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatProvider{providerA, providerB}) + })) + s.Run("ListChatsByRootID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + rootChatID := uuid.New() + chatA := testutil.Fake(s.T(), faker, database.Chat{RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}}) + chatB := testutil.Fake(s.T(), faker, database.Chat{RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}}) + dbm.EXPECT().ListChatsByRootID(gomock.Any(), rootChatID).Return([]database.Chat{chatA, chatB}, nil).AnyTimes() + check.Args(rootChatID).Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead).Returns([]database.Chat{chatA, chatB}) + })) + s.Run("ListChildChatsByParentID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + parentChatID := uuid.New() + chatA := testutil.Fake(s.T(), faker, database.Chat{ParentChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}}) + chatB := testutil.Fake(s.T(), faker, database.Chat{ParentChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}}) + dbm.EXPECT().ListChildChatsByParentID(gomock.Any(), parentChatID).Return([]database.Chat{chatA, chatB}, nil).AnyTimes() + check.Args(parentChatID).Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead).Returns([]database.Chat{chatA, chatB}) + })) + s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + threshold := dbtime.Now() + chats := []database.Chat{testutil.Fake(s.T(), faker, database.Chat{})} + dbm.EXPECT().GetStaleChats(gomock.Any(), threshold).Return(chats, nil).AnyTimes() + check.Args(threshold).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(chats) + })) + s.Run("InsertChat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := testutil.Fake(s.T(), faker, database.InsertChatParams{}) + chat := testutil.Fake(s.T(), faker, database.Chat{OwnerID: arg.OwnerID}) + dbm.EXPECT().InsertChat(gomock.Any(), arg).Return(chat, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()), policy.ActionCreate).Returns(chat) + })) + s.Run("InsertChatMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := testutil.Fake(s.T(), faker, database.InsertChatMessageParams{ChatID: chat.ID}) + msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertChatMessage(gomock.Any(), arg).Return(msg, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(msg) + })) + s.Run("InsertChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := testutil.Fake(s.T(), faker, database.InsertChatQueuedMessageParams{ChatID: chat.ID}) + qm := testutil.Fake(s.T(), faker, database.ChatQueuedMessage{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertChatQueuedMessage(gomock.Any(), arg).Return(qm, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(qm) + })) + s.Run("InsertChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.InsertChatModelConfigParams{ + Provider: "test-provider", + Model: "test-model", + DisplayName: "Test Model", + Enabled: true, + } + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{Provider: arg.Provider, Model: arg.Model, DisplayName: arg.DisplayName, Enabled: arg.Enabled}) + dbm.EXPECT().InsertChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + })) + s.Run("InsertChatProvider", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.InsertChatProviderParams{ + Provider: "test-provider", + DisplayName: "Test Provider", + APIKey: "test-api-key", + Enabled: true, + } + provider := testutil.Fake(s.T(), faker, database.ChatProvider{Provider: arg.Provider, DisplayName: arg.DisplayName, APIKey: arg.APIKey, Enabled: arg.Enabled}) + dbm.EXPECT().InsertChatProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(provider) + })) + s.Run("PopNextQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + qm := testutil.Fake(s.T(), faker, database.ChatQueuedMessage{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().PopNextQueuedMessage(gomock.Any(), chat.ID).Return(qm, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(qm) + })) + s.Run("UpdateChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatByIDParams{ + ID: chat.ID, + Title: "Updated title", + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatByID(gomock.Any(), arg).Return(chat, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) + })) + s.Run("UpdateChatHeartbeat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatHeartbeatParams{ + ID: chat.ID, + WorkerID: uuid.New(), + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatHeartbeat(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) + })) + s.Run("UpdateChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + arg := database.UpdateChatMessageByIDParams{ + ID: msg.ID, + ModelConfigID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + Content: pqtype.NullRawMessage{ + RawMessage: json.RawMessage(`{"blocks":[{"type":"text","text":"updated"}]}`), + Valid: true, + }, + } + updated := testutil.Fake(s.T(), faker, database.ChatMessage{ID: msg.ID, ChatID: chat.ID}) + dbm.EXPECT().GetChatMessageByID(gomock.Any(), msg.ID).Return(msg, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatMessageByID(gomock.Any(), arg).Return(updated, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updated) + })) + s.Run("UpdateChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + arg := database.UpdateChatModelConfigParams{ + ID: config.ID, + Provider: "updated-provider", + Model: "updated-model", + DisplayName: "Updated Model", + Enabled: true, + } + dbm.EXPECT().UpdateChatModelConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + })) + s.Run("UpdateChatProvider", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.ChatProvider{}) + arg := database.UpdateChatProviderParams{ + ID: provider.ID, + DisplayName: "Updated Provider", + APIKey: "updated-api-key", + Enabled: true, + } + dbm.EXPECT().UpdateChatProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(provider) + })) + s.Run("UpdateChatStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatStatus(gomock.Any(), arg).Return(chat, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) + })) + s.Run("UpdateChatWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatWorkspaceParams{ + ID: chat.ID, + WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + WorkspaceAgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + updatedChat := testutil.Fake(s.T(), faker, database.Chat{ID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatWorkspace(gomock.Any(), arg).Return(updatedChat, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updatedChat) + })) + s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UnsetDefaultChatModelConfigs(gomock.Any()).Return(nil).AnyTimes() + check.Args().Asserts(rbac.ResourceSystem, policy.ActionUpdate) + })) + s.Run("UpsertChatDiffStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + now := dbtime.Now() + arg := database.UpsertChatDiffStatusParams{ + ChatID: chat.ID, + Url: sql.NullString{String: "https://example.com/pr/123", Valid: true}, + PullRequestState: sql.NullString{String: "open", Valid: true}, + ChangesRequested: false, + Additions: 10, + Deletions: 5, + ChangedFiles: 2, + RefreshedAt: now, + StaleAt: now.Add(time.Hour), + } + diffStatus := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpsertChatDiffStatus(gomock.Any(), arg).Return(diffStatus, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(diffStatus) + })) + s.Run("UpsertChatDiffStatusReference", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpsertChatDiffStatusReferenceParams{ + ChatID: chat.ID, + Url: sql.NullString{String: "https://example.com/pr/123", Valid: true}, + GitBranch: "feature/test", + GitRemoteOrigin: "origin", + StaleAt: dbtime.Now().Add(time.Hour), + } + diffStatus := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), arg).Return(diffStatus, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(diffStatus) + })) +} + func (s *MethodTestSuite) TestFile() { s.Run("GetFileByHashAndCreator", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { f := testutil.Fake(s.T(), faker, database.File{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ffe79f0c8b..7550944ff4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -104,6 +104,14 @@ func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) return r0 } +func (m queryMetricsStore) AcquireChat(ctx context.Context, arg database.AcquireChatParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.AcquireChat(ctx, arg) + m.queryLatencies.WithLabelValues("AcquireChat").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "AcquireChat").Inc() + return r0, r1 +} + func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() r0 := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -156,6 +164,7 @@ func (m queryMetricsStore) BatchUpdateWorkspaceAgentMetadata(ctx context.Context start := time.Now() r0 := m.s.BatchUpdateWorkspaceAgentMetadata(ctx, arg) m.queryLatencies.WithLabelValues("BatchUpdateWorkspaceAgentMetadata").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "BatchUpdateWorkspaceAgentMetadata").Inc() return r0 } @@ -311,6 +320,14 @@ func (m queryMetricsStore) DeleteAPIKeysByUserID(ctx context.Context, userID uui return r0 } +func (m queryMetricsStore) DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteAllChatQueuedMessages(ctx, chatID) + m.queryLatencies.WithLabelValues("DeleteAllChatQueuedMessages").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteAllChatQueuedMessages").Inc() + return r0 +} + func (m queryMetricsStore) DeleteAllTailnetTunnels(ctx context.Context, arg database.DeleteAllTailnetTunnelsParams) error { start := time.Now() r0 := m.s.DeleteAllTailnetTunnels(ctx, arg) @@ -335,6 +352,54 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } +func (m queryMetricsStore) DeleteChatByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChatByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatByID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { + start := time.Now() + r0 := m.s.DeleteChatMessagesAfterID(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteChatMessagesAfterID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatMessagesAfterID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChatMessagesByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("DeleteChatMessagesByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatMessagesByChatID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChatModelConfigByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteChatModelConfigByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatModelConfigByID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChatProviderByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteChatProviderByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatProviderByID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { + start := time.Now() + r0 := m.s.DeleteChatQueuedMessage(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteChatQueuedMessage").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatQueuedMessage").Inc() + return r0 +} + func (m queryMetricsStore) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() r0, r1 := m.s.DeleteCryptoKey(ctx, arg) @@ -902,6 +967,126 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return r0, r1 } +func (m queryMetricsStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatByIDForUpdate(ctx, id) + m.queryLatencies.WithLabelValues("GetChatByIDForUpdate").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatByIDForUpdate").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (database.ChatDiffStatus, error) { + start := time.Now() + r0, r1 := m.s.GetChatDiffStatusByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatDiffStatusByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDiffStatusByChatID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uuid.UUID) ([]database.ChatDiffStatus, error) { + start := time.Now() + r0, r1 := m.s.GetChatDiffStatusesByChatIDs(ctx, chatIDs) + m.queryLatencies.WithLabelValues("GetChatDiffStatusesByChatIDs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDiffStatusesByChatIDs").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatMessageByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatMessageByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatMessageByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatMessagesByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatMessagesByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatMessagesByChatID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatMessagesForPromptByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatMessagesForPromptByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatMessagesForPromptByChatID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetChatModelConfigByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatModelConfigByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatModelConfigByProviderAndModel(ctx context.Context, arg database.GetChatModelConfigByProviderAndModelParams) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetChatModelConfigByProviderAndModel(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatModelConfigByProviderAndModel").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigByProviderAndModel").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetChatModelConfigs(ctx) + m.queryLatencies.WithLabelValues("GetChatModelConfigs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelConfigs").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.GetChatProviderByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatProviderByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatProviderByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatProviderByProvider(ctx context.Context, provider string) (database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.GetChatProviderByProvider(ctx, provider) + m.queryLatencies.WithLabelValues("GetChatProviderByProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatProviderByProvider").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.GetChatProviders(ctx) + m.queryLatencies.WithLabelValues("GetChatProviders").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatProviders").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]database.ChatQueuedMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatQueuedMessages(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatQueuedMessages").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatQueuedMessages").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID) + m.queryLatencies.WithLabelValues("GetChatsByOwnerID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatsByOwnerID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { start := time.Now() r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg) @@ -958,6 +1143,14 @@ func (m queryMetricsStore) GetDERPMeshKey(ctx context.Context) (string, error) { return r0, r1 } +func (m queryMetricsStore) GetDefaultChatModelConfig(ctx context.Context) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetDefaultChatModelConfig(ctx) + m.queryLatencies.WithLabelValues("GetDefaultChatModelConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetDefaultChatModelConfig").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { start := time.Now() r0, r1 := m.s.GetDefaultOrganization(ctx) @@ -1022,6 +1215,22 @@ func (m queryMetricsStore) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx return r0, r1 } +func (m queryMetricsStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetEnabledChatModelConfigs(ctx) + m.queryLatencies.WithLabelValues("GetEnabledChatModelConfigs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledChatModelConfigs").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.GetEnabledChatProviders(ctx) + m.queryLatencies.WithLabelValues("GetEnabledChatProviders").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledChatProviders").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { start := time.Now() r0, r1 := m.s.GetExternalAuthLink(ctx, arg) @@ -1718,6 +1927,14 @@ func (m queryMetricsStore) GetRuntimeConfig(ctx context.Context, key string) (st return r0, r1 } +func (m queryMetricsStore) GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetStaleChats(ctx, staleThreshold) + m.queryLatencies.WithLabelValues("GetStaleChats").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetStaleChats").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]database.TailnetPeer, error) { start := time.Now() r0, r1 := m.s.GetTailnetPeers(ctx, id) @@ -2710,6 +2927,46 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return r0, r1 } +func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.InsertChat(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChat").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChat").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertChatMessage(ctx context.Context, arg database.InsertChatMessageParams) (database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.InsertChatMessage(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatMessage").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatMessage").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.InsertChatModelConfig(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatModelConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatModelConfig").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertChatProvider(ctx context.Context, arg database.InsertChatProviderParams) (database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.InsertChatProvider(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatProvider").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertChatQueuedMessage(ctx context.Context, arg database.InsertChatQueuedMessageParams) (database.ChatQueuedMessage, error) { + start := time.Now() + r0, r1 := m.s.InsertChatQueuedMessage(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatQueuedMessage").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatQueuedMessage").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() r0, r1 := m.s.InsertCryptoKey(ctx, arg) @@ -3246,6 +3503,22 @@ func (m queryMetricsStore) ListAIBridgeUserPromptsByInterceptionIDs(ctx context. return r0, r1 } +func (m queryMetricsStore) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.ListChatsByRootID(ctx, rootChatID) + m.queryLatencies.WithLabelValues("ListChatsByRootID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListChatsByRootID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) ListChildChatsByParentID(ctx context.Context, parentChatID uuid.UUID) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.ListChildChatsByParentID(ctx, parentChatID) + m.queryLatencies.WithLabelValues("ListChildChatsByParentID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListChildChatsByParentID").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { start := time.Now() r0, r1 := m.s.ListProvisionerKeysByOrganization(ctx, organizationID) @@ -3326,6 +3599,14 @@ func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) { + start := time.Now() + r0, r1 := m.s.PopNextQueuedMessage(ctx, chatID) + m.queryLatencies.WithLabelValues("PopNextQueuedMessage").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PopNextQueuedMessage").Inc() + return r0, r1 +} + func (m queryMetricsStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { start := time.Now() r0 := m.s.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID) @@ -3398,6 +3679,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID return r0 } +func (m queryMetricsStore) UnsetDefaultChatModelConfigs(ctx context.Context) error { + start := time.Now() + r0 := m.s.UnsetDefaultChatModelConfigs(ctx) + m.queryLatencies.WithLabelValues("UnsetDefaultChatModelConfigs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnsetDefaultChatModelConfigs").Inc() + return r0 +} + func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) { start := time.Now() r0, r1 := m.s.UpdateAIBridgeInterceptionEnded(ctx, arg) @@ -3414,6 +3703,62 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up return r0 } +func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatHeartbeat(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatHeartbeat").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatHeartbeat").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatMessageByID(ctx context.Context, arg database.UpdateChatMessageByIDParams) (database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatMessageByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatMessageByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatMessageByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatModelConfig(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatModelConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatModelConfig").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatProvider(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatProvider").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatStatus(ctx context.Context, arg database.UpdateChatStatusParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatStatus(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatStatus").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatStatus").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatWorkspace").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatWorkspace").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { start := time.Now() r0, r1 := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) @@ -4125,6 +4470,22 @@ func (m queryMetricsStore) UpsertBoundaryUsageStats(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) UpsertChatDiffStatus(ctx context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) { + start := time.Now() + r0, r1 := m.s.UpsertChatDiffStatus(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertChatDiffStatus").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatDiffStatus").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) { + start := time.Now() + r0, r1 := m.s.UpsertChatDiffStatusReference(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertChatDiffStatusReference").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatDiffStatusReference").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { start := time.Now() r0, r1 := m.s.UpsertConnectionLog(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index cb5451293e..e69addee1b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -44,6 +44,21 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } +// AcquireChat mocks base method. +func (m *MockStore) AcquireChat(ctx context.Context, arg database.AcquireChatParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcquireChat", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AcquireChat indicates an expected call of AcquireChat. +func (mr *MockStoreMockRecorder) AcquireChat(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireChat", reflect.TypeOf((*MockStore)(nil).AcquireChat), ctx, arg) +} + // AcquireLock mocks base method. func (m *MockStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { m.ctrl.T.Helper() @@ -469,6 +484,20 @@ func (mr *MockStoreMockRecorder) DeleteAPIKeysByUserID(ctx, userID any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteAPIKeysByUserID), ctx, userID) } +// DeleteAllChatQueuedMessages mocks base method. +func (m *MockStore) DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllChatQueuedMessages", ctx, chatID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllChatQueuedMessages indicates an expected call of DeleteAllChatQueuedMessages. +func (mr *MockStoreMockRecorder) DeleteAllChatQueuedMessages(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllChatQueuedMessages", reflect.TypeOf((*MockStore)(nil).DeleteAllChatQueuedMessages), ctx, chatID) +} + // DeleteAllTailnetTunnels mocks base method. func (m *MockStore) DeleteAllTailnetTunnels(ctx context.Context, arg database.DeleteAllTailnetTunnelsParams) error { m.ctrl.T.Helper() @@ -511,6 +540,90 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteChatByID mocks base method. +func (m *MockStore) DeleteChatByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatByID indicates an expected call of DeleteChatByID. +func (mr *MockStoreMockRecorder) DeleteChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatByID", reflect.TypeOf((*MockStore)(nil).DeleteChatByID), ctx, id) +} + +// DeleteChatMessagesAfterID mocks base method. +func (m *MockStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatMessagesAfterID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatMessagesAfterID indicates an expected call of DeleteChatMessagesAfterID. +func (mr *MockStoreMockRecorder) DeleteChatMessagesAfterID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatMessagesAfterID", reflect.TypeOf((*MockStore)(nil).DeleteChatMessagesAfterID), ctx, arg) +} + +// DeleteChatMessagesByChatID mocks base method. +func (m *MockStore) DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatMessagesByChatID", ctx, chatID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatMessagesByChatID indicates an expected call of DeleteChatMessagesByChatID. +func (mr *MockStoreMockRecorder) DeleteChatMessagesByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).DeleteChatMessagesByChatID), ctx, chatID) +} + +// DeleteChatModelConfigByID mocks base method. +func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatModelConfigByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatModelConfigByID indicates an expected call of DeleteChatModelConfigByID. +func (mr *MockStoreMockRecorder) DeleteChatModelConfigByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).DeleteChatModelConfigByID), ctx, id) +} + +// DeleteChatProviderByID mocks base method. +func (m *MockStore) DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatProviderByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatProviderByID indicates an expected call of DeleteChatProviderByID. +func (mr *MockStoreMockRecorder) DeleteChatProviderByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatProviderByID", reflect.TypeOf((*MockStore)(nil).DeleteChatProviderByID), ctx, id) +} + +// DeleteChatQueuedMessage mocks base method. +func (m *MockStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatQueuedMessage", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatQueuedMessage indicates an expected call of DeleteChatQueuedMessage. +func (mr *MockStoreMockRecorder) DeleteChatQueuedMessage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatQueuedMessage", reflect.TypeOf((*MockStore)(nil).DeleteChatQueuedMessage), ctx, arg) +} + // DeleteCryptoKey mocks base method. func (m *MockStore) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -1649,6 +1762,231 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } +// GetChatByID mocks base method. +func (m *MockStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatByID", ctx, id) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatByID indicates an expected call of GetChatByID. +func (mr *MockStoreMockRecorder) GetChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByID", reflect.TypeOf((*MockStore)(nil).GetChatByID), ctx, id) +} + +// GetChatByIDForUpdate mocks base method. +func (m *MockStore) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatByIDForUpdate", ctx, id) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatByIDForUpdate indicates an expected call of GetChatByIDForUpdate. +func (mr *MockStoreMockRecorder) GetChatByIDForUpdate(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByIDForUpdate", reflect.TypeOf((*MockStore)(nil).GetChatByIDForUpdate), ctx, id) +} + +// GetChatDiffStatusByChatID mocks base method. +func (m *MockStore) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (database.ChatDiffStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDiffStatusByChatID", ctx, chatID) + ret0, _ := ret[0].(database.ChatDiffStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDiffStatusByChatID indicates an expected call of GetChatDiffStatusByChatID. +func (mr *MockStoreMockRecorder) GetChatDiffStatusByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusByChatID", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusByChatID), ctx, chatID) +} + +// GetChatDiffStatusesByChatIDs mocks base method. +func (m *MockStore) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]database.ChatDiffStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDiffStatusesByChatIDs", ctx, chatIds) + ret0, _ := ret[0].([]database.ChatDiffStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDiffStatusesByChatIDs indicates an expected call of GetChatDiffStatusesByChatIDs. +func (mr *MockStoreMockRecorder) GetChatDiffStatusesByChatIDs(ctx, chatIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusesByChatIDs", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusesByChatIDs), ctx, chatIds) +} + +// GetChatMessageByID mocks base method. +func (m *MockStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessageByID", ctx, id) + ret0, _ := ret[0].(database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessageByID indicates an expected call of GetChatMessageByID. +func (mr *MockStoreMockRecorder) GetChatMessageByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessageByID", reflect.TypeOf((*MockStore)(nil).GetChatMessageByID), ctx, id) +} + +// GetChatMessagesByChatID mocks base method. +func (m *MockStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessagesByChatID", ctx, chatID) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessagesByChatID indicates an expected call of GetChatMessagesByChatID. +func (mr *MockStoreMockRecorder) GetChatMessagesByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatID), ctx, chatID) +} + +// GetChatMessagesForPromptByChatID mocks base method. +func (m *MockStore) GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessagesForPromptByChatID", ctx, chatID) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessagesForPromptByChatID indicates an expected call of GetChatMessagesForPromptByChatID. +func (mr *MockStoreMockRecorder) GetChatMessagesForPromptByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesForPromptByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesForPromptByChatID), ctx, chatID) +} + +// GetChatModelConfigByID mocks base method. +func (m *MockStore) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatModelConfigByID", ctx, id) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatModelConfigByID indicates an expected call of GetChatModelConfigByID. +func (mr *MockStoreMockRecorder) GetChatModelConfigByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigByID), ctx, id) +} + +// GetChatModelConfigByProviderAndModel mocks base method. +func (m *MockStore) GetChatModelConfigByProviderAndModel(ctx context.Context, arg database.GetChatModelConfigByProviderAndModelParams) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatModelConfigByProviderAndModel", ctx, arg) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatModelConfigByProviderAndModel indicates an expected call of GetChatModelConfigByProviderAndModel. +func (mr *MockStoreMockRecorder) GetChatModelConfigByProviderAndModel(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigByProviderAndModel", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigByProviderAndModel), ctx, arg) +} + +// GetChatModelConfigs mocks base method. +func (m *MockStore) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatModelConfigs", ctx) + ret0, _ := ret[0].([]database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatModelConfigs indicates an expected call of GetChatModelConfigs. +func (mr *MockStoreMockRecorder) GetChatModelConfigs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigs), ctx) +} + +// GetChatProviderByID mocks base method. +func (m *MockStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatProviderByID", ctx, id) + ret0, _ := ret[0].(database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatProviderByID indicates an expected call of GetChatProviderByID. +func (mr *MockStoreMockRecorder) GetChatProviderByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatProviderByID", reflect.TypeOf((*MockStore)(nil).GetChatProviderByID), ctx, id) +} + +// GetChatProviderByProvider mocks base method. +func (m *MockStore) GetChatProviderByProvider(ctx context.Context, provider string) (database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatProviderByProvider", ctx, provider) + ret0, _ := ret[0].(database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatProviderByProvider indicates an expected call of GetChatProviderByProvider. +func (mr *MockStoreMockRecorder) GetChatProviderByProvider(ctx, provider any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatProviderByProvider", reflect.TypeOf((*MockStore)(nil).GetChatProviderByProvider), ctx, provider) +} + +// GetChatProviders mocks base method. +func (m *MockStore) GetChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatProviders", ctx) + ret0, _ := ret[0].([]database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatProviders indicates an expected call of GetChatProviders. +func (mr *MockStoreMockRecorder) GetChatProviders(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatProviders", reflect.TypeOf((*MockStore)(nil).GetChatProviders), ctx) +} + +// GetChatQueuedMessages mocks base method. +func (m *MockStore) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]database.ChatQueuedMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatQueuedMessages", ctx, chatID) + ret0, _ := ret[0].([]database.ChatQueuedMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatQueuedMessages indicates an expected call of GetChatQueuedMessages. +func (mr *MockStoreMockRecorder) GetChatQueuedMessages(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatQueuedMessages", reflect.TypeOf((*MockStore)(nil).GetChatQueuedMessages), ctx, chatID) +} + +// GetChatsByOwnerID mocks base method. +func (m *MockStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatsByOwnerID", ctx, ownerID) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatsByOwnerID indicates an expected call of GetChatsByOwnerID. +func (mr *MockStoreMockRecorder) GetChatsByOwnerID(ctx, ownerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetChatsByOwnerID), ctx, ownerID) +} + // GetConnectionLogsOffset mocks base method. func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { m.ctrl.T.Helper() @@ -1754,6 +2092,21 @@ func (mr *MockStoreMockRecorder) GetDERPMeshKey(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDERPMeshKey", reflect.TypeOf((*MockStore)(nil).GetDERPMeshKey), ctx) } +// GetDefaultChatModelConfig mocks base method. +func (m *MockStore) GetDefaultChatModelConfig(ctx context.Context) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultChatModelConfig", ctx) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDefaultChatModelConfig indicates an expected call of GetDefaultChatModelConfig. +func (mr *MockStoreMockRecorder) GetDefaultChatModelConfig(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfig", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfig), ctx) +} + // GetDefaultOrganization mocks base method. func (m *MockStore) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { m.ctrl.T.Helper() @@ -1874,6 +2227,36 @@ func (mr *MockStoreMockRecorder) GetEligibleProvisionerDaemonsByProvisionerJobID return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibleProvisionerDaemonsByProvisionerJobIDs", reflect.TypeOf((*MockStore)(nil).GetEligibleProvisionerDaemonsByProvisionerJobIDs), ctx, provisionerJobIds) } +// GetEnabledChatModelConfigs mocks base method. +func (m *MockStore) GetEnabledChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEnabledChatModelConfigs", ctx) + ret0, _ := ret[0].([]database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEnabledChatModelConfigs indicates an expected call of GetEnabledChatModelConfigs. +func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigs), ctx) +} + +// GetEnabledChatProviders mocks base method. +func (m *MockStore) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEnabledChatProviders", ctx) + ret0, _ := ret[0].([]database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEnabledChatProviders indicates an expected call of GetEnabledChatProviders. +func (mr *MockStoreMockRecorder) GetEnabledChatProviders(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatProviders", reflect.TypeOf((*MockStore)(nil).GetEnabledChatProviders), ctx) +} + // GetExternalAuthLink mocks base method. func (m *MockStore) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { m.ctrl.T.Helper() @@ -3179,6 +3562,21 @@ func (mr *MockStoreMockRecorder) GetRuntimeConfig(ctx, key any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntimeConfig", reflect.TypeOf((*MockStore)(nil).GetRuntimeConfig), ctx, key) } +// GetStaleChats mocks base method. +func (m *MockStore) GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStaleChats", ctx, staleThreshold) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetStaleChats indicates an expected call of GetStaleChats. +func (mr *MockStoreMockRecorder) GetStaleChats(ctx, staleThreshold any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStaleChats", reflect.TypeOf((*MockStore)(nil).GetStaleChats), ctx, staleThreshold) +} + // GetTailnetPeers mocks base method. func (m *MockStore) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]database.TailnetPeer, error) { m.ctrl.T.Helper() @@ -5083,6 +5481,81 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } +// InsertChat mocks base method. +func (m *MockStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChat", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChat indicates an expected call of InsertChat. +func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) +} + +// InsertChatMessage mocks base method. +func (m *MockStore) InsertChatMessage(ctx context.Context, arg database.InsertChatMessageParams) (database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatMessage", ctx, arg) + ret0, _ := ret[0].(database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatMessage indicates an expected call of InsertChatMessage. +func (mr *MockStoreMockRecorder) InsertChatMessage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessage", reflect.TypeOf((*MockStore)(nil).InsertChatMessage), ctx, arg) +} + +// InsertChatModelConfig mocks base method. +func (m *MockStore) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatModelConfig", ctx, arg) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatModelConfig indicates an expected call of InsertChatModelConfig. +func (mr *MockStoreMockRecorder) InsertChatModelConfig(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatModelConfig", reflect.TypeOf((*MockStore)(nil).InsertChatModelConfig), ctx, arg) +} + +// InsertChatProvider mocks base method. +func (m *MockStore) InsertChatProvider(ctx context.Context, arg database.InsertChatProviderParams) (database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatProvider", ctx, arg) + ret0, _ := ret[0].(database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatProvider indicates an expected call of InsertChatProvider. +func (mr *MockStoreMockRecorder) InsertChatProvider(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatProvider", reflect.TypeOf((*MockStore)(nil).InsertChatProvider), ctx, arg) +} + +// InsertChatQueuedMessage mocks base method. +func (m *MockStore) InsertChatQueuedMessage(ctx context.Context, arg database.InsertChatQueuedMessageParams) (database.ChatQueuedMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatQueuedMessage", ctx, arg) + ret0, _ := ret[0].(database.ChatQueuedMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatQueuedMessage indicates an expected call of InsertChatQueuedMessage. +func (mr *MockStoreMockRecorder) InsertChatQueuedMessage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatQueuedMessage", reflect.TypeOf((*MockStore)(nil).InsertChatQueuedMessage), ctx, arg) +} + // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -6102,6 +6575,36 @@ func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeModels(ctx, arg, prepared return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeModels), ctx, arg, prepared) } +// ListChatsByRootID mocks base method. +func (m *MockStore) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChatsByRootID", ctx, rootChatID) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListChatsByRootID indicates an expected call of ListChatsByRootID. +func (mr *MockStoreMockRecorder) ListChatsByRootID(ctx, rootChatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChatsByRootID", reflect.TypeOf((*MockStore)(nil).ListChatsByRootID), ctx, rootChatID) +} + +// ListChildChatsByParentID mocks base method. +func (m *MockStore) ListChildChatsByParentID(ctx context.Context, parentChatID uuid.UUID) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChildChatsByParentID", ctx, parentChatID) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListChildChatsByParentID indicates an expected call of ListChildChatsByParentID. +func (mr *MockStoreMockRecorder) ListChildChatsByParentID(ctx, parentChatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChildChatsByParentID", reflect.TypeOf((*MockStore)(nil).ListChildChatsByParentID), ctx, parentChatID) +} + // ListProvisionerKeysByOrganization mocks base method. func (m *MockStore) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { m.ctrl.T.Helper() @@ -6281,6 +6784,21 @@ func (mr *MockStoreMockRecorder) Ping(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockStore)(nil).Ping), ctx) } +// PopNextQueuedMessage mocks base method. +func (m *MockStore) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PopNextQueuedMessage", ctx, chatID) + ret0, _ := ret[0].(database.ChatQueuedMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PopNextQueuedMessage indicates an expected call of PopNextQueuedMessage. +func (mr *MockStoreMockRecorder) PopNextQueuedMessage(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PopNextQueuedMessage", reflect.TypeOf((*MockStore)(nil).PopNextQueuedMessage), ctx, chatID) +} + // ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate mocks base method. func (m *MockStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { m.ctrl.T.Helper() @@ -6411,6 +6929,20 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id) } +// UnsetDefaultChatModelConfigs mocks base method. +func (m *MockStore) UnsetDefaultChatModelConfigs(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnsetDefaultChatModelConfigs", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnsetDefaultChatModelConfigs indicates an expected call of UnsetDefaultChatModelConfigs. +func (mr *MockStoreMockRecorder) UnsetDefaultChatModelConfigs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnsetDefaultChatModelConfigs", reflect.TypeOf((*MockStore)(nil).UnsetDefaultChatModelConfigs), ctx) +} + // UpdateAIBridgeInterceptionEnded mocks base method. func (m *MockStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) { m.ctrl.T.Helper() @@ -6440,6 +6972,111 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg) } +// UpdateChatByID mocks base method. +func (m *MockStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatByID", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatByID indicates an expected call of UpdateChatByID. +func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg) +} + +// UpdateChatHeartbeat mocks base method. +func (m *MockStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatHeartbeat", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatHeartbeat indicates an expected call of UpdateChatHeartbeat. +func (mr *MockStoreMockRecorder) UpdateChatHeartbeat(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateChatHeartbeat), ctx, arg) +} + +// UpdateChatMessageByID mocks base method. +func (m *MockStore) UpdateChatMessageByID(ctx context.Context, arg database.UpdateChatMessageByIDParams) (database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatMessageByID", ctx, arg) + ret0, _ := ret[0].(database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatMessageByID indicates an expected call of UpdateChatMessageByID. +func (mr *MockStoreMockRecorder) UpdateChatMessageByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageByID", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageByID), ctx, arg) +} + +// UpdateChatModelConfig mocks base method. +func (m *MockStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatModelConfig", ctx, arg) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatModelConfig indicates an expected call of UpdateChatModelConfig. +func (mr *MockStoreMockRecorder) UpdateChatModelConfig(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatModelConfig", reflect.TypeOf((*MockStore)(nil).UpdateChatModelConfig), ctx, arg) +} + +// UpdateChatProvider mocks base method. +func (m *MockStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatProvider", ctx, arg) + ret0, _ := ret[0].(database.ChatProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatProvider indicates an expected call of UpdateChatProvider. +func (mr *MockStoreMockRecorder) UpdateChatProvider(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatProvider", reflect.TypeOf((*MockStore)(nil).UpdateChatProvider), ctx, arg) +} + +// UpdateChatStatus mocks base method. +func (m *MockStore) UpdateChatStatus(ctx context.Context, arg database.UpdateChatStatusParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatStatus", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatStatus indicates an expected call of UpdateChatStatus. +func (mr *MockStoreMockRecorder) UpdateChatStatus(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatus", reflect.TypeOf((*MockStore)(nil).UpdateChatStatus), ctx, arg) +} + +// UpdateChatWorkspace mocks base method. +func (m *MockStore) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatWorkspace", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatWorkspace indicates an expected call of UpdateChatWorkspace. +func (mr *MockStoreMockRecorder) UpdateChatWorkspace(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatWorkspace", reflect.TypeOf((*MockStore)(nil).UpdateChatWorkspace), ctx, arg) +} + // UpdateCryptoKeyDeletesAt mocks base method. func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -7722,6 +8359,36 @@ func (mr *MockStoreMockRecorder) UpsertBoundaryUsageStats(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertBoundaryUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertBoundaryUsageStats), ctx, arg) } +// UpsertChatDiffStatus mocks base method. +func (m *MockStore) UpsertChatDiffStatus(ctx context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatDiffStatus", ctx, arg) + ret0, _ := ret[0].(database.ChatDiffStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertChatDiffStatus indicates an expected call of UpsertChatDiffStatus. +func (mr *MockStoreMockRecorder) UpsertChatDiffStatus(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatus", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatus), ctx, arg) +} + +// UpsertChatDiffStatusReference mocks base method. +func (m *MockStore) UpsertChatDiffStatusReference(ctx context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatDiffStatusReference", ctx, arg) + ret0, _ := ret[0].(database.ChatDiffStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertChatDiffStatusReference indicates an expected call of UpsertChatDiffStatusReference. +func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg) +} + // UpsertConnectionLog mocks base method. func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2ff7f878c0..23816111d2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -210,7 +210,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'boundary_usage:read', 'boundary_usage:update', 'workspace:update_agent', - 'workspace_dormant:update_agent' + 'workspace_dormant:update_agent', + 'chat:create', + 'chat:read', + 'chat:update', + 'chat:delete', + 'chat:*' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -260,6 +265,21 @@ CREATE TYPE build_reason AS ENUM ( 'task_resume' ); +CREATE TYPE chat_message_visibility AS ENUM ( + 'user', + 'model', + 'both' +); + +CREATE TYPE chat_status AS ENUM ( + 'waiting', + 'pending', + 'running', + 'paused', + 'completed', + 'error' +); + CREATE TYPE connection_status AS ENUM ( 'connected', 'disconnected' @@ -1144,6 +1164,118 @@ COMMENT ON COLUMN boundary_usage_stats.window_start IS 'Start of the time window COMMENT ON COLUMN boundary_usage_stats.updated_at IS 'Timestamp of the last update to this row.'; +CREATE TABLE chat_diff_statuses ( + chat_id uuid NOT NULL, + url text, + pull_request_state text, + changes_requested boolean DEFAULT false NOT NULL, + additions integer DEFAULT 0 NOT NULL, + deletions integer DEFAULT 0 NOT NULL, + changed_files integer DEFAULT 0 NOT NULL, + refreshed_at timestamp with time zone, + stale_at timestamp with time zone DEFAULT now() NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + git_branch text DEFAULT ''::text NOT NULL, + git_remote_origin text DEFAULT ''::text NOT NULL +); + +CREATE TABLE chat_messages ( + id bigint NOT NULL, + chat_id uuid NOT NULL, + model_config_id uuid, + created_at timestamp with time zone DEFAULT now() NOT NULL, + role text NOT NULL, + content jsonb, + visibility chat_message_visibility DEFAULT 'both'::chat_message_visibility NOT NULL, + input_tokens bigint, + output_tokens bigint, + total_tokens bigint, + reasoning_tokens bigint, + cache_creation_tokens bigint, + cache_read_tokens bigint, + context_limit bigint, + compressed boolean DEFAULT false NOT NULL +); + +CREATE SEQUENCE chat_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE chat_messages_id_seq OWNED BY chat_messages.id; + +CREATE TABLE chat_model_configs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + provider text NOT NULL, + model text NOT NULL, + display_name text DEFAULT ''::text NOT NULL, + created_by uuid, + updated_by uuid, + enabled boolean DEFAULT true NOT NULL, + is_default boolean DEFAULT false NOT NULL, + deleted boolean DEFAULT false NOT NULL, + deleted_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + context_limit bigint NOT NULL, + compression_threshold integer NOT NULL, + options jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT chat_model_configs_compression_threshold_check CHECK (((compression_threshold >= 0) AND (compression_threshold <= 100))), + CONSTRAINT chat_model_configs_context_limit_check CHECK ((context_limit > 0)) +); + +CREATE TABLE chat_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + provider text NOT NULL, + display_name text DEFAULT ''::text NOT NULL, + api_key text DEFAULT ''::text NOT NULL, + api_key_key_id text, + created_by uuid, + enabled boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + base_url text DEFAULT ''::text NOT NULL, + CONSTRAINT chat_providers_provider_check CHECK ((provider = ANY (ARRAY['anthropic'::text, 'azure'::text, 'bedrock'::text, 'google'::text, 'openai'::text, 'openai-compat'::text, 'openrouter'::text, 'vercel'::text]))) +); + +COMMENT ON COLUMN chat_providers.api_key_key_id IS 'The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted'; + +CREATE TABLE chat_queued_messages ( + id bigint NOT NULL, + chat_id uuid NOT NULL, + content jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE SEQUENCE chat_queued_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE chat_queued_messages_id_seq OWNED BY chat_queued_messages.id; + +CREATE TABLE chats ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + owner_id uuid NOT NULL, + workspace_id uuid, + workspace_agent_id uuid, + title text DEFAULT 'New Chat'::text NOT NULL, + status chat_status DEFAULT 'waiting'::chat_status NOT NULL, + worker_id uuid, + started_at timestamp with time zone, + heartbeat_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + parent_chat_id uuid, + root_chat_id uuid, + last_model_config_id uuid NOT NULL +); + CREATE TABLE connection_logs ( id uuid NOT NULL, connect_time timestamp with time zone NOT NULL, @@ -2951,6 +3083,10 @@ CREATE VIEW workspaces_expanded AS COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; +ALTER TABLE ONLY chat_messages ALTER COLUMN id SET DEFAULT nextval('chat_messages_id_seq'::regclass); + +ALTER TABLE ONLY chat_queued_messages ALTER COLUMN id SET DEFAULT nextval('chat_queued_messages_id_seq'::regclass); + ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); @@ -2987,6 +3123,27 @@ ALTER TABLE ONLY audit_logs ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); +ALTER TABLE ONLY chat_diff_statuses + ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); + +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chat_model_configs + ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chat_providers + ADD CONSTRAINT chat_providers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chat_providers + ADD CONSTRAINT chat_providers_provider_key UNIQUE (provider); + +ALTER TABLE ONLY chat_queued_messages + ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_pkey PRIMARY KEY (id); + ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); @@ -3314,6 +3471,38 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at); + +CREATE INDEX idx_chat_messages_chat ON chat_messages USING btree (chat_id); + +CREATE INDEX idx_chat_messages_chat_created ON chat_messages USING btree (chat_id, created_at); + +CREATE INDEX idx_chat_messages_compressed_summary_boundary ON chat_messages USING btree (chat_id, created_at DESC, id DESC) WHERE ((compressed = true) AND (role = 'system'::text) AND (visibility = ANY (ARRAY['model'::chat_message_visibility, 'both'::chat_message_visibility]))); + +CREATE INDEX idx_chat_model_configs_enabled ON chat_model_configs USING btree (enabled); + +CREATE INDEX idx_chat_model_configs_provider ON chat_model_configs USING btree (provider); + +CREATE INDEX idx_chat_model_configs_provider_model ON chat_model_configs USING btree (provider, model); + +CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree ((1)) WHERE ((is_default = true) AND (deleted = false)); + +CREATE INDEX idx_chat_providers_enabled ON chat_providers USING btree (enabled); + +CREATE INDEX idx_chat_queued_messages_chat_id ON chat_queued_messages USING btree (chat_id); + +CREATE INDEX idx_chats_last_model_config_id ON chats USING btree (last_model_config_id); + +CREATE INDEX idx_chats_owner ON chats USING btree (owner_id); + +CREATE INDEX idx_chats_parent_chat_id ON chats USING btree (parent_chat_id); + +CREATE INDEX idx_chats_pending ON chats USING btree (status) WHERE (status = 'pending'::chat_status); + +CREATE INDEX idx_chats_root_chat_id ON chats USING btree (root_chat_id); + +CREATE INDEX idx_chats_workspace ON chats USING btree (workspace_id); + CREATE INDEX idx_connection_logs_connect_time_desc ON connection_logs USING btree (connect_time DESC); CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); @@ -3560,6 +3749,51 @@ ALTER TABLE ONLY aibridge_interceptions ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_diff_statuses + ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); + +ALTER TABLE ONLY chat_model_configs + ADD CONSTRAINT chat_model_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); + +ALTER TABLE ONLY chat_model_configs + ADD CONSTRAINT chat_model_configs_provider_fkey FOREIGN KEY (provider) REFERENCES chat_providers(provider) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_model_configs + ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id); + +ALTER TABLE ONLY chat_providers + ADD CONSTRAINT chat_providers_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + +ALTER TABLE ONLY chat_providers + ADD CONSTRAINT chat_providers_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); + +ALTER TABLE ONLY chat_queued_messages + ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id); + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_parent_chat_id_fkey FOREIGN KEY (parent_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 81f1fda004..fa712737e4 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -8,6 +8,21 @@ type ForeignKeyConstraint string const ( ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatMessagesModelConfigID ForeignKeyConstraint = "chat_messages_model_config_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); + ForeignKeyChatModelConfigsCreatedBy ForeignKeyConstraint = "chat_model_configs_created_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); + ForeignKeyChatModelConfigsProvider ForeignKeyConstraint = "chat_model_configs_provider_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_provider_fkey FOREIGN KEY (provider) REFERENCES chat_providers(provider) ON DELETE CASCADE; + ForeignKeyChatModelConfigsUpdatedBy ForeignKeyConstraint = "chat_model_configs_updated_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id); + ForeignKeyChatProvidersAPIKeyKeyID ForeignKeyConstraint = "chat_providers_api_key_key_id_fkey" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyChatProvidersCreatedBy ForeignKeyConstraint = "chat_providers_created_by_fkey" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); + ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatsLastModelConfigID ForeignKeyConstraint = "chats_last_model_config_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id); + ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatsParentChatID ForeignKeyConstraint = "chats_parent_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_parent_chat_id_fkey FOREIGN KEY (parent_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + ForeignKeyChatsRootChatID ForeignKeyConstraint = "chats_root_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + ForeignKeyChatsWorkspaceAgentID ForeignKeyConstraint = "chats_workspace_agent_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL; + ForeignKeyChatsWorkspaceID ForeignKeyConstraint = "chats_workspace_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000422_chats.down.sql b/coderd/database/migrations/000422_chats.down.sql new file mode 100644 index 0000000000..b59a04bbf3 --- /dev/null +++ b/coderd/database/migrations/000422_chats.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS chat_queued_messages; +DROP TABLE IF EXISTS chat_diff_statuses; +DROP TABLE IF EXISTS chat_messages; +DROP TABLE IF EXISTS chats; +DROP TABLE IF EXISTS chat_model_configs; +DROP TABLE IF EXISTS chat_providers; +DROP TYPE IF EXISTS chat_message_visibility; +DROP TYPE IF EXISTS chat_status; diff --git a/coderd/database/migrations/000422_chats.up.sql b/coderd/database/migrations/000422_chats.up.sql new file mode 100644 index 0000000000..01b94fe747 --- /dev/null +++ b/coderd/database/migrations/000422_chats.up.sql @@ -0,0 +1,167 @@ +CREATE TYPE chat_status AS ENUM ( + 'waiting', + 'pending', + 'running', + 'paused', + 'completed', + 'error' +); + +CREATE TYPE chat_message_visibility AS ENUM ( + 'user', + 'model', + 'both' +); + +CREATE TABLE chats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL, + workspace_agent_id UUID REFERENCES workspace_agents(id) ON DELETE SET NULL, + title TEXT NOT NULL DEFAULT 'New Chat', + status chat_status NOT NULL DEFAULT 'waiting', + worker_id UUID, + started_at TIMESTAMPTZ, + heartbeat_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + parent_chat_id UUID REFERENCES chats(id) ON DELETE SET NULL, + root_chat_id UUID REFERENCES chats(id) ON DELETE SET NULL, + last_model_config_id UUID NOT NULL +); + +CREATE INDEX idx_chats_owner ON chats(owner_id); +CREATE INDEX idx_chats_workspace ON chats(workspace_id); +CREATE INDEX idx_chats_pending ON chats(status) WHERE status = 'pending'; +CREATE INDEX idx_chats_parent_chat_id ON chats(parent_chat_id); +CREATE INDEX idx_chats_root_chat_id ON chats(root_chat_id); +CREATE INDEX idx_chats_last_model_config_id ON chats(last_model_config_id); + +CREATE TABLE chat_messages ( + id BIGSERIAL PRIMARY KEY, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + model_config_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + role TEXT NOT NULL, + content JSONB, + visibility chat_message_visibility NOT NULL DEFAULT 'both', + input_tokens BIGINT, + output_tokens BIGINT, + total_tokens BIGINT, + reasoning_tokens BIGINT, + cache_creation_tokens BIGINT, + cache_read_tokens BIGINT, + context_limit BIGINT, + compressed BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_chat_messages_chat ON chat_messages(chat_id); +CREATE INDEX idx_chat_messages_chat_created ON chat_messages(chat_id, created_at); +CREATE INDEX idx_chat_messages_compressed_summary_boundary + ON chat_messages(chat_id, created_at DESC, id DESC) + WHERE compressed = TRUE + AND role = 'system' + AND visibility IN ('model', 'both'); + +CREATE TABLE chat_diff_statuses ( + chat_id UUID PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE, + url TEXT, + pull_request_state TEXT, + changes_requested BOOLEAN NOT NULL DEFAULT FALSE, + additions INTEGER NOT NULL DEFAULT 0, + deletions INTEGER NOT NULL DEFAULT 0, + changed_files INTEGER NOT NULL DEFAULT 0, + refreshed_at TIMESTAMPTZ, + stale_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + git_branch TEXT NOT NULL DEFAULT '', + git_remote_origin TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses(stale_at); + +CREATE TABLE chat_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', + api_key_key_id TEXT REFERENCES dbcrypt_keys(active_key_digest), + created_by UUID REFERENCES users(id), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + base_url TEXT NOT NULL DEFAULT '', + CONSTRAINT chat_providers_provider_check CHECK ( + provider = ANY ( + ARRAY[ + 'anthropic'::text, + 'azure'::text, + 'bedrock'::text, + 'google'::text, + 'openai'::text, + 'openai-compat'::text, + 'openrouter'::text, + 'vercel'::text + ] + ) + ) +); + +COMMENT ON COLUMN chat_providers.api_key_key_id IS 'The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted'; + +CREATE INDEX idx_chat_providers_enabled ON chat_providers(enabled); + +CREATE TABLE chat_model_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL REFERENCES chat_providers(provider) ON DELETE CASCADE, + model TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context_limit BIGINT NOT NULL, + compression_threshold INTEGER NOT NULL, + options JSONB NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT chat_model_configs_context_limit_check + CHECK (context_limit > 0), + CONSTRAINT chat_model_configs_compression_threshold_check + CHECK (compression_threshold >= 0 AND compression_threshold <= 100) +); + +CREATE INDEX idx_chat_model_configs_enabled ON chat_model_configs(enabled); +CREATE INDEX idx_chat_model_configs_provider ON chat_model_configs(provider); +CREATE INDEX idx_chat_model_configs_provider_model + ON chat_model_configs(provider, model); +CREATE UNIQUE INDEX idx_chat_model_configs_single_default + ON chat_model_configs ((1)) + WHERE is_default = TRUE + AND deleted = FALSE; + +ALTER TABLE chat_messages + ADD CONSTRAINT chat_messages_model_config_id_fkey + FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); + +ALTER TABLE chats + ADD CONSTRAINT chats_last_model_config_id_fkey + FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id); + +CREATE TABLE chat_queued_messages ( + id BIGSERIAL PRIMARY KEY, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + content JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chat_queued_messages_chat_id ON chat_queued_messages(chat_id); + +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:*'; diff --git a/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql b/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql new file mode 100644 index 0000000000..0da5c47df7 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql @@ -0,0 +1,114 @@ +INSERT INTO chat_providers ( + id, + provider, + display_name, + api_key, + api_key_key_id, + enabled, + created_at, + updated_at +) VALUES ( + '0a8b2f84-b5a8-4c44-8c9f-e58c44a534a7', + 'openai', + 'OpenAI', + '', + NULL, + TRUE, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +); + +INSERT INTO chat_model_configs ( + id, + provider, + model, + display_name, + enabled, + context_limit, + compression_threshold, + created_at, + updated_at +) VALUES ( + '9af5f8d5-6a57-4505-8a69-3d6c787b95fd', + 'openai', + 'gpt-5.2', + 'GPT 5.2', + TRUE, + 200000, + 70, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +); + +INSERT INTO chats ( + id, + owner_id, + last_model_config_id, + title, + status, + created_at, + updated_at +) +SELECT + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + id, + '9af5f8d5-6a57-4505-8a69-3d6c787b95fd', + 'Fixture Chat', + 'completed', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +FROM users +ORDER BY created_at, id +LIMIT 1; + +INSERT INTO chat_messages ( + chat_id, + created_at, + role, + content +) VALUES ( + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + '2024-01-01 00:00:00+00', + 'assistant', + '{"type":"text","text":"fixture"}'::jsonb +); + +INSERT INTO chat_diff_statuses ( + chat_id, + url, + pull_request_state, + changes_requested, + additions, + deletions, + changed_files, + refreshed_at, + stale_at, + created_at, + updated_at, + git_branch, + git_remote_origin +) VALUES ( + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + 'https://example.com/pr/1', + 'open', + FALSE, + 1, + 0, + 1, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + 'main', + 'origin' +); + +INSERT INTO chat_queued_messages ( + chat_id, + content, + created_at +) VALUES ( + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + '{"type":"text","text":"queued fixture"}'::jsonb, + '2024-01-01 00:00:00+00' +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 072737f4a8..8c6d7e0bd1 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -165,6 +165,10 @@ func (t TaskTable) RBACObject() rbac.Object { InOrg(t.OrganizationID) } +func (c Chat) RBACObject() rbac.Object { + return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()) +} + func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { case ApiKeyScopeCoderAll: diff --git a/coderd/database/models.go b/coderd/database/models.go index 8a05c655da..30868c2d1d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -219,6 +219,11 @@ const ( ApiKeyScopeBoundaryUsageUpdate APIKeyScope = "boundary_usage:update" ApiKeyScopeWorkspaceUpdateAgent APIKeyScope = "workspace:update_agent" ApiKeyScopeWorkspaceDormantUpdateAgent APIKeyScope = "workspace_dormant:update_agent" + ApiKeyScopeChatCreate APIKeyScope = "chat:create" + ApiKeyScopeChatRead APIKeyScope = "chat:read" + ApiKeyScopeChatUpdate APIKeyScope = "chat:update" + ApiKeyScopeChatDelete APIKeyScope = "chat:delete" + ApiKeyScopeChat APIKeyScope = "chat:*" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -457,7 +462,12 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeBoundaryUsageRead, ApiKeyScopeBoundaryUsageUpdate, ApiKeyScopeWorkspaceUpdateAgent, - ApiKeyScopeWorkspaceDormantUpdateAgent: + ApiKeyScopeWorkspaceDormantUpdateAgent, + ApiKeyScopeChatCreate, + ApiKeyScopeChatRead, + ApiKeyScopeChatUpdate, + ApiKeyScopeChatDelete, + ApiKeyScopeChat: return true } return false @@ -665,6 +675,11 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeBoundaryUsageUpdate, ApiKeyScopeWorkspaceUpdateAgent, ApiKeyScopeWorkspaceDormantUpdateAgent, + ApiKeyScopeChatCreate, + ApiKeyScopeChatRead, + ApiKeyScopeChatUpdate, + ApiKeyScopeChatDelete, + ApiKeyScopeChat, } } @@ -1034,6 +1049,137 @@ func AllBuildReasonValues() []BuildReason { } } +type ChatMessageVisibility string + +const ( + ChatMessageVisibilityUser ChatMessageVisibility = "user" + ChatMessageVisibilityModel ChatMessageVisibility = "model" + ChatMessageVisibilityBoth ChatMessageVisibility = "both" +) + +func (e *ChatMessageVisibility) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ChatMessageVisibility(s) + case string: + *e = ChatMessageVisibility(s) + default: + return fmt.Errorf("unsupported scan type for ChatMessageVisibility: %T", src) + } + return nil +} + +type NullChatMessageVisibility struct { + ChatMessageVisibility ChatMessageVisibility `json:"chat_message_visibility"` + Valid bool `json:"valid"` // Valid is true if ChatMessageVisibility is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullChatMessageVisibility) Scan(value interface{}) error { + if value == nil { + ns.ChatMessageVisibility, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ChatMessageVisibility.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullChatMessageVisibility) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ChatMessageVisibility), nil +} + +func (e ChatMessageVisibility) Valid() bool { + switch e { + case ChatMessageVisibilityUser, + ChatMessageVisibilityModel, + ChatMessageVisibilityBoth: + return true + } + return false +} + +func AllChatMessageVisibilityValues() []ChatMessageVisibility { + return []ChatMessageVisibility{ + ChatMessageVisibilityUser, + ChatMessageVisibilityModel, + ChatMessageVisibilityBoth, + } +} + +type ChatStatus string + +const ( + ChatStatusWaiting ChatStatus = "waiting" + ChatStatusPending ChatStatus = "pending" + ChatStatusRunning ChatStatus = "running" + ChatStatusPaused ChatStatus = "paused" + ChatStatusCompleted ChatStatus = "completed" + ChatStatusError ChatStatus = "error" +) + +func (e *ChatStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ChatStatus(s) + case string: + *e = ChatStatus(s) + default: + return fmt.Errorf("unsupported scan type for ChatStatus: %T", src) + } + return nil +} + +type NullChatStatus struct { + ChatStatus ChatStatus `json:"chat_status"` + Valid bool `json:"valid"` // Valid is true if ChatStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullChatStatus) Scan(value interface{}) error { + if value == nil { + ns.ChatStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ChatStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullChatStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ChatStatus), nil +} + +func (e ChatStatus) Valid() bool { + switch e { + case ChatStatusWaiting, + ChatStatusPending, + ChatStatusRunning, + ChatStatusPaused, + ChatStatusCompleted, + ChatStatusError: + return true + } + return false +} + +func AllChatStatusValues() []ChatStatus { + return []ChatStatus{ + ChatStatusWaiting, + ChatStatusPending, + ChatStatusRunning, + ChatStatusPaused, + ChatStatusCompleted, + ChatStatusError, + } +} + type ConnectionStatus string const ( @@ -3739,6 +3885,96 @@ type BoundaryUsageStat struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +type Chat struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` +} + +type ChatDiffStatus struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Url sql.NullString `db:"url" json:"url"` + PullRequestState sql.NullString `db:"pull_request_state" json:"pull_request_state"` + ChangesRequested bool `db:"changes_requested" json:"changes_requested"` + Additions int32 `db:"additions" json:"additions"` + Deletions int32 `db:"deletions" json:"deletions"` + ChangedFiles int32 `db:"changed_files" json:"changed_files"` + RefreshedAt sql.NullTime `db:"refreshed_at" json:"refreshed_at"` + StaleAt time.Time `db:"stale_at" json:"stale_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + GitBranch string `db:"git_branch" json:"git_branch"` + GitRemoteOrigin string `db:"git_remote_origin" json:"git_remote_origin"` +} + +type ChatMessage struct { + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Role string `db:"role" json:"role"` + Content pqtype.NullRawMessage `db:"content" json:"content"` + Visibility ChatMessageVisibility `db:"visibility" json:"visibility"` + InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"` + OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"` + TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"` + ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"` + CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"` + CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"` + ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"` + Compressed bool `db:"compressed" json:"compressed"` +} + +type ChatModelConfig struct { + ID uuid.UUID `db:"id" json:"id"` + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + DisplayName string `db:"display_name" json:"display_name"` + CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` + UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"` + Enabled bool `db:"enabled" json:"enabled"` + IsDefault bool `db:"is_default" json:"is_default"` + Deleted bool `db:"deleted" json:"deleted"` + DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ContextLimit int64 `db:"context_limit" json:"context_limit"` + CompressionThreshold int32 `db:"compression_threshold" json:"compression_threshold"` + Options json.RawMessage `db:"options" json:"options"` +} + +type ChatProvider struct { + ID uuid.UUID `db:"id" json:"id"` + Provider string `db:"provider" json:"provider"` + DisplayName string `db:"display_name" json:"display_name"` + APIKey string `db:"api_key" json:"api_key"` + // The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` + Enabled bool `db:"enabled" json:"enabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + BaseUrl string `db:"base_url" json:"base_url"` +} + +type ChatQueuedMessage struct { + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Content json.RawMessage `db:"content" json:"content"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + type ConnectionLog struct { ID uuid.UUID `db:"id" json:"id"` ConnectTime time.Time `db:"connect_time" json:"connect_time"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5d19d4813c..11fbbd544e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -12,6 +12,9 @@ import ( ) type sqlcQuerier interface { + // Acquires a pending chat for processing. Uses SKIP LOCKED to prevent + // multiple replicas from acquiring the same chat. + AcquireChat(ctx context.Context, arg AcquireChatParams) (Chat, error) // Blocks until the lock is acquired. // // This must be called from within a transaction. The lock will be automatically @@ -81,6 +84,7 @@ type sqlcQuerier interface { CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error // Deletes all existing webpush subscriptions. // This should be called when the VAPID keypair is regenerated, as the old @@ -88,6 +92,12 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteChatByID(ctx context.Context, id uuid.UUID) error + DeleteChatMessagesAfterID(ctx context.Context, arg DeleteChatMessagesAfterIDParams) error + DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error + DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error + DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error + DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error DeleteExpiredAPIKeys(ctx context.Context, arg DeleteExpiredAPIKeysParams) (int64, error) @@ -199,6 +209,21 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) + GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) + GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error) + GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error) + GetChatMessageByID(ctx context.Context, id int64) (ChatMessage, error) + GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) + GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) + GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) + GetChatModelConfigByProviderAndModel(ctx context.Context, arg GetChatModelConfigByProviderAndModelParams) (ChatModelConfig, error) + GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) + GetChatProviderByID(ctx context.Context, id uuid.UUID) (ChatProvider, error) + GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error) + GetChatProviders(ctx context.Context) ([]ChatProvider, error) + GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error) + GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) @@ -206,6 +231,7 @@ type sqlcQuerier interface { GetCryptoKeysByFeature(ctx context.Context, feature CryptoKeyFeature) ([]CryptoKey, error) GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error) GetDERPMeshKey(ctx context.Context) (string, error) + GetDefaultChatModelConfig(ctx context.Context) (ChatModelConfig, error) GetDefaultOrganization(ctx context.Context) (Organization, error) GetDefaultProxyConfig(ctx context.Context) (GetDefaultProxyConfigRow, error) GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) @@ -214,6 +240,8 @@ type sqlcQuerier interface { GetDeploymentWorkspaceAgentUsageStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentUsageStatsRow, error) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) + GetEnabledChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) + GetEnabledChatProviders(ctx context.Context) ([]ChatProvider, error) GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, error) @@ -352,6 +380,9 @@ type sqlcQuerier interface { GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]GetRunningPrebuiltWorkspacesRow, error) GetRuntimeConfig(ctx context.Context, key string) (string, error) + // Find chats that appear stuck (running but heartbeat has expired). + // Used for recovery after coderd crashes or long hangs. + GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]Chat, error) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error) @@ -574,6 +605,11 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + InsertChatMessage(ctx context.Context, arg InsertChatMessageParams) (ChatMessage, error) + InsertChatModelConfig(ctx context.Context, arg InsertChatModelConfigParams) (ChatModelConfig, error) + InsertChatProvider(ctx context.Context, arg InsertChatProviderParams) (ChatProvider, error) + InsertChatQueuedMessage(ctx context.Context, arg InsertChatQueuedMessageParams) (ChatQueuedMessage, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error @@ -657,6 +693,8 @@ type sqlcQuerier interface { ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, error) + ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID) ([]Chat, error) + ListChildChatsByParentID(ctx context.Context, parentChatID uuid.UUID) ([]Chat, error) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error) @@ -673,6 +711,7 @@ type sqlcQuerier interface { // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) + PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error @@ -691,8 +730,18 @@ type sqlcQuerier interface { // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error + UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error) + // Bumps the heartbeat timestamp for a running chat so that other + // replicas know the worker is still alive. + UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error) + UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) + UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) + UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error) + UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) + UpdateChatWorkspace(ctx context.Context, arg UpdateChatWorkspaceParams) (Chat, error) UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) @@ -790,6 +839,8 @@ type sqlcQuerier interface { // cumulative values for unique counts (accurate period totals). Request counts // are always deltas, accumulated in DB. Returns true if insert, false if update. UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error) + UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) + UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 820df89cae..19c3c5673b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7548,6 +7548,47 @@ func TestGetTaskByWorkspaceID(t *testing.T) { } } +func TestDeleteTaskDeletesTaskSnapshot(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + task := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + + err := db.UpsertTaskSnapshot(ctx, database.UpsertTaskSnapshotParams{ + TaskID: task.ID, + LogSnapshot: json.RawMessage(`{"messages":[]}`), + LogSnapshotCreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + _, err = db.DeleteTask(ctx, database.DeleteTaskParams{ + ID: task.ID, + DeletedAt: dbtime.Now(), + }) + require.NoError(t, err) + + _, err = db.GetTaskSnapshot(ctx, task.ID) + require.ErrorIs(t, err, sql.ErrNoRows) +} + func TestTaskNameUniqueness(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a4fc95d1ba..c33f1c06b6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2160,6 +2160,1880 @@ func (q *sqlQuerier) UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBou return new_period, err } +const deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :exec +UPDATE + chat_model_configs +SET + deleted = TRUE, + deleted_at = NOW(), + updated_at = NOW() +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChatModelConfigByID, id) + return err +} + +const getChatModelConfigByID = `-- name: GetChatModelConfigByID :one +SELECT + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +FROM + chat_model_configs +WHERE + id = $1::uuid + AND deleted = FALSE +` + +func (q *sqlQuerier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, getChatModelConfigByID, id) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ) + return i, err +} + +const getChatModelConfigByProviderAndModel = `-- name: GetChatModelConfigByProviderAndModel :one +SELECT + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +FROM + chat_model_configs +WHERE + provider = $1::text + AND model = $2::text + AND deleted = FALSE +ORDER BY + updated_at DESC, + created_at DESC, + id DESC +LIMIT 1 +` + +type GetChatModelConfigByProviderAndModelParams struct { + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` +} + +func (q *sqlQuerier) GetChatModelConfigByProviderAndModel(ctx context.Context, arg GetChatModelConfigByProviderAndModelParams) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, getChatModelConfigByProviderAndModel, arg.Provider, arg.Model) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ) + return i, err +} + +const getChatModelConfigs = `-- name: GetChatModelConfigs :many +SELECT + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +FROM + chat_model_configs +WHERE + deleted = FALSE +ORDER BY + provider ASC, + model ASC, + updated_at DESC, + id DESC +` + +func (q *sqlQuerier) GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) { + rows, err := q.db.QueryContext(ctx, getChatModelConfigs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatModelConfig + for rows.Next() { + var i ChatModelConfig + if err := rows.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDefaultChatModelConfig = `-- name: GetDefaultChatModelConfig :one +SELECT + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +FROM + chat_model_configs +WHERE + is_default = TRUE + AND deleted = FALSE +` + +func (q *sqlQuerier) GetDefaultChatModelConfig(ctx context.Context) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, getDefaultChatModelConfig) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ) + return i, err +} + +const getEnabledChatModelConfigs = `-- name: GetEnabledChatModelConfigs :many +SELECT + cmc.id, cmc.provider, cmc.model, cmc.display_name, cmc.created_by, cmc.updated_by, cmc.enabled, cmc.is_default, cmc.deleted, cmc.deleted_at, cmc.created_at, cmc.updated_at, cmc.context_limit, cmc.compression_threshold, cmc.options +FROM + chat_model_configs cmc +JOIN + chat_providers cp ON cp.provider = cmc.provider +WHERE + cmc.enabled = TRUE + AND cmc.deleted = FALSE + AND cp.enabled = TRUE +ORDER BY + cmc.provider ASC, + cmc.model ASC, + cmc.updated_at DESC, + cmc.id DESC +` + +func (q *sqlQuerier) GetEnabledChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) { + rows, err := q.db.QueryContext(ctx, getEnabledChatModelConfigs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatModelConfig + for rows.Next() { + var i ChatModelConfig + if err := rows.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertChatModelConfig = `-- name: InsertChatModelConfig :one +INSERT INTO chat_model_configs ( + provider, + model, + display_name, + created_by, + updated_by, + enabled, + is_default, + context_limit, + compression_threshold, + options +) VALUES ( + $1::text, + $2::text, + $3::text, + $4::uuid, + $5::uuid, + $6::boolean, + $7::boolean, + $8::bigint, + $9::integer, + $10::jsonb +) +RETURNING + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +` + +type InsertChatModelConfigParams struct { + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + DisplayName string `db:"display_name" json:"display_name"` + CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` + UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"` + Enabled bool `db:"enabled" json:"enabled"` + IsDefault bool `db:"is_default" json:"is_default"` + ContextLimit int64 `db:"context_limit" json:"context_limit"` + CompressionThreshold int32 `db:"compression_threshold" json:"compression_threshold"` + Options json.RawMessage `db:"options" json:"options"` +} + +func (q *sqlQuerier) InsertChatModelConfig(ctx context.Context, arg InsertChatModelConfigParams) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, insertChatModelConfig, + arg.Provider, + arg.Model, + arg.DisplayName, + arg.CreatedBy, + arg.UpdatedBy, + arg.Enabled, + arg.IsDefault, + arg.ContextLimit, + arg.CompressionThreshold, + arg.Options, + ) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ) + return i, err +} + +const unsetDefaultChatModelConfigs = `-- name: UnsetDefaultChatModelConfigs :exec +UPDATE + chat_model_configs +SET + is_default = FALSE, + updated_at = NOW() +WHERE + is_default = TRUE + AND deleted = FALSE +` + +func (q *sqlQuerier) UnsetDefaultChatModelConfigs(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, unsetDefaultChatModelConfigs) + return err +} + +const updateChatModelConfig = `-- name: UpdateChatModelConfig :one +UPDATE + chat_model_configs +SET + provider = $1::text, + model = $2::text, + display_name = $3::text, + updated_by = $4::uuid, + enabled = $5::boolean, + is_default = $6::boolean, + context_limit = $7::bigint, + compression_threshold = $8::integer, + options = $9::jsonb, + updated_at = NOW() +WHERE + id = $10::uuid + AND deleted = FALSE +RETURNING + id, provider, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options +` + +type UpdateChatModelConfigParams struct { + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + DisplayName string `db:"display_name" json:"display_name"` + UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"` + Enabled bool `db:"enabled" json:"enabled"` + IsDefault bool `db:"is_default" json:"is_default"` + ContextLimit int64 `db:"context_limit" json:"context_limit"` + CompressionThreshold int32 `db:"compression_threshold" json:"compression_threshold"` + Options json.RawMessage `db:"options" json:"options"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, updateChatModelConfig, + arg.Provider, + arg.Model, + arg.DisplayName, + arg.UpdatedBy, + arg.Enabled, + arg.IsDefault, + arg.ContextLimit, + arg.CompressionThreshold, + arg.Options, + arg.ID, + ) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Provider, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + ) + return i, err +} + +const deleteChatProviderByID = `-- name: DeleteChatProviderByID :exec +DELETE FROM + chat_providers +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChatProviderByID, id) + return err +} + +const getChatProviderByID = `-- name: GetChatProviderByID :one +SELECT + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +FROM + chat_providers +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) GetChatProviderByID(ctx context.Context, id uuid.UUID) (ChatProvider, error) { + row := q.db.QueryRowContext(ctx, getChatProviderByID, id) + var i ChatProvider + err := row.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ) + return i, err +} + +const getChatProviderByProvider = `-- name: GetChatProviderByProvider :one +SELECT + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +FROM + chat_providers +WHERE + provider = $1::text +` + +func (q *sqlQuerier) GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error) { + row := q.db.QueryRowContext(ctx, getChatProviderByProvider, provider) + var i ChatProvider + err := row.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ) + return i, err +} + +const getChatProviders = `-- name: GetChatProviders :many +SELECT + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +FROM + chat_providers +ORDER BY + provider ASC +` + +func (q *sqlQuerier) GetChatProviders(ctx context.Context) ([]ChatProvider, error) { + rows, err := q.db.QueryContext(ctx, getChatProviders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatProvider + for rows.Next() { + var i ChatProvider + if err := rows.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getEnabledChatProviders = `-- name: GetEnabledChatProviders :many +SELECT + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +FROM + chat_providers +WHERE + enabled = TRUE +ORDER BY + provider ASC +` + +func (q *sqlQuerier) GetEnabledChatProviders(ctx context.Context) ([]ChatProvider, error) { + rows, err := q.db.QueryContext(ctx, getEnabledChatProviders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatProvider + for rows.Next() { + var i ChatProvider + if err := rows.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertChatProvider = `-- name: InsertChatProvider :one +INSERT INTO chat_providers ( + provider, + display_name, + api_key, + base_url, + api_key_key_id, + created_by, + enabled +) VALUES ( + $1::text, + $2::text, + $3::text, + $4::text, + $5::text, + $6::uuid, + $7::boolean +) +RETURNING + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +` + +type InsertChatProviderParams struct { + Provider string `db:"provider" json:"provider"` + DisplayName string `db:"display_name" json:"display_name"` + APIKey string `db:"api_key" json:"api_key"` + BaseUrl string `db:"base_url" json:"base_url"` + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` + Enabled bool `db:"enabled" json:"enabled"` +} + +func (q *sqlQuerier) InsertChatProvider(ctx context.Context, arg InsertChatProviderParams) (ChatProvider, error) { + row := q.db.QueryRowContext(ctx, insertChatProvider, + arg.Provider, + arg.DisplayName, + arg.APIKey, + arg.BaseUrl, + arg.ApiKeyKeyID, + arg.CreatedBy, + arg.Enabled, + ) + var i ChatProvider + err := row.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ) + return i, err +} + +const updateChatProvider = `-- name: UpdateChatProvider :one +UPDATE + chat_providers +SET + display_name = $1::text, + api_key = $2::text, + base_url = $3::text, + api_key_key_id = $4::text, + enabled = $5::boolean, + updated_at = NOW() +WHERE + id = $6::uuid +RETURNING + id, provider, display_name, api_key, api_key_key_id, created_by, enabled, created_at, updated_at, base_url +` + +type UpdateChatProviderParams struct { + DisplayName string `db:"display_name" json:"display_name"` + APIKey string `db:"api_key" json:"api_key"` + BaseUrl string `db:"base_url" json:"base_url"` + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + Enabled bool `db:"enabled" json:"enabled"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error) { + row := q.db.QueryRowContext(ctx, updateChatProvider, + arg.DisplayName, + arg.APIKey, + arg.BaseUrl, + arg.ApiKeyKeyID, + arg.Enabled, + arg.ID, + ) + var i ChatProvider + err := row.Scan( + &i.ID, + &i.Provider, + &i.DisplayName, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedBy, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + &i.BaseUrl, + ) + return i, err +} + +const acquireChat = `-- name: AcquireChat :one +UPDATE + chats +SET + status = 'running'::chat_status, + started_at = $1::timestamptz, + heartbeat_at = $1::timestamptz, + updated_at = $1::timestamptz, + worker_id = $2::uuid +WHERE + id = ( + SELECT + id + FROM + chats + WHERE + status = 'pending'::chat_status + ORDER BY + updated_at ASC + FOR UPDATE + SKIP LOCKED + LIMIT + 1 + ) +RETURNING + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +` + +type AcquireChatParams struct { + StartedAt time.Time `db:"started_at" json:"started_at"` + WorkerID uuid.UUID `db:"worker_id" json:"worker_id"` +} + +// Acquires a pending chat for processing. Uses SKIP LOCKED to prevent +// multiple replicas from acquiring the same chat. +func (q *sqlQuerier) AcquireChat(ctx context.Context, arg AcquireChatParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, acquireChat, arg.StartedAt, arg.WorkerID) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const deleteAllChatQueuedMessages = `-- name: DeleteAllChatQueuedMessages :exec +DELETE FROM chat_queued_messages WHERE chat_id = $1 +` + +func (q *sqlQuerier) DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteAllChatQueuedMessages, chatID) + return err +} + +const deleteChatByID = `-- name: DeleteChatByID :exec +DELETE FROM + chats +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) DeleteChatByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChatByID, id) + return err +} + +const deleteChatMessagesAfterID = `-- name: DeleteChatMessagesAfterID :exec +DELETE FROM + chat_messages +WHERE + chat_id = $1::uuid + AND id > $2::bigint +` + +type DeleteChatMessagesAfterIDParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + AfterID int64 `db:"after_id" json:"after_id"` +} + +func (q *sqlQuerier) DeleteChatMessagesAfterID(ctx context.Context, arg DeleteChatMessagesAfterIDParams) error { + _, err := q.db.ExecContext(ctx, deleteChatMessagesAfterID, arg.ChatID, arg.AfterID) + return err +} + +const deleteChatMessagesByChatID = `-- name: DeleteChatMessagesByChatID :exec +DELETE FROM + chat_messages +WHERE + chat_id = $1::uuid +` + +func (q *sqlQuerier) DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChatMessagesByChatID, chatID) + return err +} + +const deleteChatQueuedMessage = `-- name: DeleteChatQueuedMessage :exec +DELETE FROM chat_queued_messages WHERE id = $1 AND chat_id = $2 +` + +type DeleteChatQueuedMessageParams struct { + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` +} + +func (q *sqlQuerier) DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error { + _, err := q.db.ExecContext(ctx, deleteChatQueuedMessage, arg.ID, arg.ChatID) + return err +} + +const getChatByID = `-- name: GetChatByID :one +SELECT + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +FROM + chats +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) { + row := q.db.QueryRowContext(ctx, getChatByID, id) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one +SELECT id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id FROM chats WHERE id = $1::uuid FOR UPDATE +` + +func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) { + row := q.db.QueryRowContext(ctx, getChatByIDForUpdate, id) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const getChatDiffStatusByChatID = `-- name: GetChatDiffStatusByChatID :one +SELECT + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin +FROM + chat_diff_statuses +WHERE + chat_id = $1::uuid +` + +func (q *sqlQuerier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error) { + row := q.db.QueryRowContext(ctx, getChatDiffStatusByChatID, chatID) + var i ChatDiffStatus + err := row.Scan( + &i.ChatID, + &i.Url, + &i.PullRequestState, + &i.ChangesRequested, + &i.Additions, + &i.Deletions, + &i.ChangedFiles, + &i.RefreshedAt, + &i.StaleAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.GitBranch, + &i.GitRemoteOrigin, + ) + return i, err +} + +const getChatDiffStatusesByChatIDs = `-- name: GetChatDiffStatusesByChatIDs :many +SELECT + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin +FROM + chat_diff_statuses +WHERE + chat_id = ANY($1::uuid[]) +` + +func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error) { + rows, err := q.db.QueryContext(ctx, getChatDiffStatusesByChatIDs, pq.Array(chatIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatDiffStatus + for rows.Next() { + var i ChatDiffStatus + if err := rows.Scan( + &i.ChatID, + &i.Url, + &i.PullRequestState, + &i.ChangesRequested, + &i.Additions, + &i.Deletions, + &i.ChangedFiles, + &i.RefreshedAt, + &i.StaleAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.GitBranch, + &i.GitRemoteOrigin, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatMessageByID = `-- name: GetChatMessageByID :one +SELECT + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed +FROM + chat_messages +WHERE + id = $1::bigint +` + +func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMessage, error) { + row := q.db.QueryRowContext(ctx, getChatMessageByID, id) + var i ChatMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + ) + return i, err +} + +const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many +SELECT + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed +FROM + chat_messages +WHERE + chat_id = $1::uuid + AND visibility IN ('user', 'both') +ORDER BY + created_at ASC +` + +func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, getChatMessagesByChatID, chatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatMessagesForPromptByChatID = `-- name: GetChatMessagesForPromptByChatID :many +WITH latest_compressed_summary AS ( + SELECT + id + FROM + chat_messages + WHERE + chat_id = $1::uuid + AND role = 'system' + AND visibility IN ('model', 'both') + AND compressed = TRUE + ORDER BY + created_at DESC, + id DESC + LIMIT + 1 +) +SELECT + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed +FROM + chat_messages +WHERE + chat_id = $1::uuid + AND visibility IN ('model', 'both') + AND ( + ( + role = 'system' + AND compressed = FALSE + ) + OR ( + compressed = FALSE + AND ( + NOT EXISTS ( + SELECT + 1 + FROM + latest_compressed_summary + ) + OR id > ( + SELECT + id + FROM + latest_compressed_summary + ) + ) + ) + OR id = ( + SELECT + id + FROM + latest_compressed_summary + ) + ) +ORDER BY + created_at ASC, + id ASC +` + +func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, getChatMessagesForPromptByChatID, chatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatQueuedMessages = `-- name: GetChatQueuedMessages :many +SELECT id, chat_id, content, created_at FROM chat_queued_messages +WHERE chat_id = $1 +ORDER BY id ASC +` + +func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error) { + rows, err := q.db.QueryContext(ctx, getChatQueuedMessages, chatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatQueuedMessage + for rows.Next() { + var i ChatQueuedMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.Content, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatsByOwnerID = `-- name: GetChatsByOwnerID :many +SELECT + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +FROM + chats +WHERE + owner_id = $1::uuid +ORDER BY + updated_at DESC +` + +func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getStaleChats = `-- name: GetStaleChats :many +SELECT + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +FROM + chats +WHERE + status = 'running'::chat_status + AND heartbeat_at < $1::timestamptz +` + +// Find chats that appear stuck (running but heartbeat has expired). +// Used for recovery after coderd crashes or long hangs. +func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, getStaleChats, staleThreshold) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertChat = `-- name: InsertChat :one +INSERT INTO chats ( + owner_id, + workspace_id, + workspace_agent_id, + parent_chat_id, + root_chat_id, + last_model_config_id, + title +) VALUES ( + $1::uuid, + $2::uuid, + $3::uuid, + $4::uuid, + $5::uuid, + $6::uuid, + $7::text +) +RETURNING + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +` + +type InsertChatParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + Title string `db:"title" json:"title"` +} + +func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, insertChat, + arg.OwnerID, + arg.WorkspaceID, + arg.WorkspaceAgentID, + arg.ParentChatID, + arg.RootChatID, + arg.LastModelConfigID, + arg.Title, + ) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const insertChatMessage = `-- name: InsertChatMessage :one +WITH updated_chat AS ( + UPDATE + chats + SET + last_model_config_id = $2::uuid + WHERE + id = $1::uuid + AND $2::uuid IS NOT NULL +) +INSERT INTO chat_messages ( + chat_id, + model_config_id, + role, + content, + visibility, + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + cache_creation_tokens, + cache_read_tokens, + context_limit, + compressed +) VALUES ( + $1::uuid, + $2::uuid, + $3::text, + $4::jsonb, + $5::chat_message_visibility, + $6::bigint, + $7::bigint, + $8::bigint, + $9::bigint, + $10::bigint, + $11::bigint, + $12::bigint, + COALESCE($13::boolean, FALSE) +) +RETURNING + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed +` + +type InsertChatMessageParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + Role string `db:"role" json:"role"` + Content pqtype.NullRawMessage `db:"content" json:"content"` + Visibility ChatMessageVisibility `db:"visibility" json:"visibility"` + InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"` + OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"` + TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"` + ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"` + CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"` + CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"` + ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"` + Compressed sql.NullBool `db:"compressed" json:"compressed"` +} + +func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessageParams) (ChatMessage, error) { + row := q.db.QueryRowContext(ctx, insertChatMessage, + arg.ChatID, + arg.ModelConfigID, + arg.Role, + arg.Content, + arg.Visibility, + arg.InputTokens, + arg.OutputTokens, + arg.TotalTokens, + arg.ReasoningTokens, + arg.CacheCreationTokens, + arg.CacheReadTokens, + arg.ContextLimit, + arg.Compressed, + ) + var i ChatMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + ) + return i, err +} + +const insertChatQueuedMessage = `-- name: InsertChatQueuedMessage :one +INSERT INTO chat_queued_messages (chat_id, content) +VALUES ($1, $2) +RETURNING id, chat_id, content, created_at +` + +type InsertChatQueuedMessageParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Content json.RawMessage `db:"content" json:"content"` +} + +func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChatQueuedMessageParams) (ChatQueuedMessage, error) { + row := q.db.QueryRowContext(ctx, insertChatQueuedMessage, arg.ChatID, arg.Content) + var i ChatQueuedMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.Content, + &i.CreatedAt, + ) + return i, err +} + +const listChatsByRootID = `-- name: ListChatsByRootID :many +SELECT + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +FROM + chats +WHERE + root_chat_id = $1::uuid +ORDER BY + created_at ASC +` + +func (q *sqlQuerier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, listChatsByRootID, rootChatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChildChatsByParentID = `-- name: ListChildChatsByParentID :many +SELECT + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +FROM + chats +WHERE + parent_chat_id = $1::uuid +ORDER BY + created_at ASC +` + +func (q *sqlQuerier) ListChildChatsByParentID(ctx context.Context, parentChatID uuid.UUID) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, listChildChatsByParentID, parentChatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const popNextQueuedMessage = `-- name: PopNextQueuedMessage :one +DELETE FROM chat_queued_messages +WHERE id = ( + SELECT cqm.id FROM chat_queued_messages cqm + WHERE cqm.chat_id = $1 + ORDER BY cqm.id ASC + LIMIT 1 +) +RETURNING id, chat_id, content, created_at +` + +func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) { + row := q.db.QueryRowContext(ctx, popNextQueuedMessage, chatID) + var i ChatQueuedMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.Content, + &i.CreatedAt, + ) + return i, err +} + +const updateChatByID = `-- name: UpdateChatByID :one +UPDATE + chats +SET + title = $1::text, + updated_at = NOW() +WHERE + id = $2::uuid +RETURNING + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +` + +type UpdateChatByIDParams struct { + Title string `db:"title" json:"title"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, updateChatByID, arg.Title, arg.ID) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const updateChatHeartbeat = `-- name: UpdateChatHeartbeat :execrows +UPDATE + chats +SET + heartbeat_at = NOW() +WHERE + id = $1::uuid + AND worker_id = $2::uuid + AND status = 'running'::chat_status +` + +type UpdateChatHeartbeatParams struct { + ID uuid.UUID `db:"id" json:"id"` + WorkerID uuid.UUID `db:"worker_id" json:"worker_id"` +} + +// Bumps the heartbeat timestamp for a running chat so that other +// replicas know the worker is still alive. +func (q *sqlQuerier) UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateChatHeartbeat, arg.ID, arg.WorkerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateChatMessageByID = `-- name: UpdateChatMessageByID :one +UPDATE + chat_messages +SET + model_config_id = COALESCE($1::uuid, model_config_id), + content = $2::jsonb +WHERE + id = $3::bigint +RETURNING + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed +` + +type UpdateChatMessageByIDParams struct { + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + Content pqtype.NullRawMessage `db:"content" json:"content"` + ID int64 `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) { + row := q.db.QueryRowContext(ctx, updateChatMessageByID, arg.ModelConfigID, arg.Content, arg.ID) + var i ChatMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + ) + return i, err +} + +const updateChatStatus = `-- name: UpdateChatStatus :one +UPDATE + chats +SET + status = $1::chat_status, + worker_id = $2::uuid, + started_at = $3::timestamptz, + heartbeat_at = $4::timestamptz, + updated_at = NOW() +WHERE + id = $5::uuid +RETURNING + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +` + +type UpdateChatStatusParams struct { + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, updateChatStatus, + arg.Status, + arg.WorkerID, + arg.StartedAt, + arg.HeartbeatAt, + arg.ID, + ) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const updateChatWorkspace = `-- name: UpdateChatWorkspace :one +UPDATE + chats +SET + workspace_id = $1::uuid, + workspace_agent_id = $2::uuid, + updated_at = NOW() +WHERE + id = $3::uuid +RETURNING + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id +` + +type UpdateChatWorkspaceParams struct { + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatWorkspace(ctx context.Context, arg UpdateChatWorkspaceParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, updateChatWorkspace, arg.WorkspaceID, arg.WorkspaceAgentID, arg.ID) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.WorkspaceID, + &i.WorkspaceAgentID, + &i.Title, + &i.Status, + &i.WorkerID, + &i.StartedAt, + &i.HeartbeatAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentChatID, + &i.RootChatID, + &i.LastModelConfigID, + ) + return i, err +} + +const upsertChatDiffStatus = `-- name: UpsertChatDiffStatus :one +INSERT INTO chat_diff_statuses ( + chat_id, + url, + pull_request_state, + changes_requested, + additions, + deletions, + changed_files, + refreshed_at, + stale_at +) VALUES ( + $1::uuid, + $2::text, + $3::text, + $4::boolean, + $5::integer, + $6::integer, + $7::integer, + $8::timestamptz, + $9::timestamptz +) +ON CONFLICT (chat_id) DO UPDATE +SET + url = EXCLUDED.url, + pull_request_state = EXCLUDED.pull_request_state, + changes_requested = EXCLUDED.changes_requested, + additions = EXCLUDED.additions, + deletions = EXCLUDED.deletions, + changed_files = EXCLUDED.changed_files, + refreshed_at = EXCLUDED.refreshed_at, + stale_at = EXCLUDED.stale_at, + updated_at = NOW() +RETURNING + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin +` + +type UpsertChatDiffStatusParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Url sql.NullString `db:"url" json:"url"` + PullRequestState sql.NullString `db:"pull_request_state" json:"pull_request_state"` + ChangesRequested bool `db:"changes_requested" json:"changes_requested"` + Additions int32 `db:"additions" json:"additions"` + Deletions int32 `db:"deletions" json:"deletions"` + ChangedFiles int32 `db:"changed_files" json:"changed_files"` + RefreshedAt time.Time `db:"refreshed_at" json:"refreshed_at"` + StaleAt time.Time `db:"stale_at" json:"stale_at"` +} + +func (q *sqlQuerier) UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) { + row := q.db.QueryRowContext(ctx, upsertChatDiffStatus, + arg.ChatID, + arg.Url, + arg.PullRequestState, + arg.ChangesRequested, + arg.Additions, + arg.Deletions, + arg.ChangedFiles, + arg.RefreshedAt, + arg.StaleAt, + ) + var i ChatDiffStatus + err := row.Scan( + &i.ChatID, + &i.Url, + &i.PullRequestState, + &i.ChangesRequested, + &i.Additions, + &i.Deletions, + &i.ChangedFiles, + &i.RefreshedAt, + &i.StaleAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.GitBranch, + &i.GitRemoteOrigin, + ) + return i, err +} + +const upsertChatDiffStatusReference = `-- name: UpsertChatDiffStatusReference :one +INSERT INTO chat_diff_statuses ( + chat_id, + url, + git_branch, + git_remote_origin, + stale_at +) VALUES ( + $1::uuid, + $2::text, + $3::text, + $4::text, + $5::timestamptz +) +ON CONFLICT (chat_id) DO UPDATE +SET + url = CASE + WHEN EXCLUDED.url IS NOT NULL THEN EXCLUDED.url + ELSE chat_diff_statuses.url + END, + git_branch = CASE + WHEN EXCLUDED.git_branch != '' THEN EXCLUDED.git_branch + ELSE chat_diff_statuses.git_branch + END, + git_remote_origin = CASE + WHEN EXCLUDED.git_remote_origin != '' THEN EXCLUDED.git_remote_origin + ELSE chat_diff_statuses.git_remote_origin + END, + stale_at = EXCLUDED.stale_at, + updated_at = NOW() +RETURNING + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin +` + +type UpsertChatDiffStatusReferenceParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Url sql.NullString `db:"url" json:"url"` + GitBranch string `db:"git_branch" json:"git_branch"` + GitRemoteOrigin string `db:"git_remote_origin" json:"git_remote_origin"` + StaleAt time.Time `db:"stale_at" json:"stale_at"` +} + +func (q *sqlQuerier) UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) { + row := q.db.QueryRowContext(ctx, upsertChatDiffStatusReference, + arg.ChatID, + arg.Url, + arg.GitBranch, + arg.GitRemoteOrigin, + arg.StaleAt, + ) + var i ChatDiffStatus + err := row.Scan( + &i.ChatID, + &i.Url, + &i.PullRequestState, + &i.ChangesRequested, + &i.Additions, + &i.Deletions, + &i.ChangedFiles, + &i.RefreshedAt, + &i.StaleAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.GitBranch, + &i.GitRemoteOrigin, + ) + return i, err +} + const countConnectionLogs = `-- name: CountConnectionLogs :one SELECT COUNT(*) AS count @@ -23459,7 +25333,7 @@ WHERE filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $24 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $24) THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql new file mode 100644 index 0000000000..a24891d427 --- /dev/null +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -0,0 +1,129 @@ +-- name: GetChatModelConfigByID :one +SELECT + * +FROM + chat_model_configs +WHERE + id = @id::uuid + AND deleted = FALSE; + +-- name: GetDefaultChatModelConfig :one +SELECT + * +FROM + chat_model_configs +WHERE + is_default = TRUE + AND deleted = FALSE; + +-- name: GetChatModelConfigByProviderAndModel :one +SELECT + * +FROM + chat_model_configs +WHERE + provider = @provider::text + AND model = @model::text + AND deleted = FALSE +ORDER BY + updated_at DESC, + created_at DESC, + id DESC +LIMIT 1; + +-- name: GetChatModelConfigs :many +SELECT + * +FROM + chat_model_configs +WHERE + deleted = FALSE +ORDER BY + provider ASC, + model ASC, + updated_at DESC, + id DESC; + +-- name: GetEnabledChatModelConfigs :many +SELECT + cmc.* +FROM + chat_model_configs cmc +JOIN + chat_providers cp ON cp.provider = cmc.provider +WHERE + cmc.enabled = TRUE + AND cmc.deleted = FALSE + AND cp.enabled = TRUE +ORDER BY + cmc.provider ASC, + cmc.model ASC, + cmc.updated_at DESC, + cmc.id DESC; + +-- name: InsertChatModelConfig :one +INSERT INTO chat_model_configs ( + provider, + model, + display_name, + created_by, + updated_by, + enabled, + is_default, + context_limit, + compression_threshold, + options +) VALUES ( + @provider::text, + @model::text, + @display_name::text, + sqlc.narg('created_by')::uuid, + sqlc.narg('updated_by')::uuid, + @enabled::boolean, + @is_default::boolean, + @context_limit::bigint, + @compression_threshold::integer, + @options::jsonb +) +RETURNING + *; + +-- name: UpdateChatModelConfig :one +UPDATE + chat_model_configs +SET + provider = @provider::text, + model = @model::text, + display_name = @display_name::text, + updated_by = sqlc.narg('updated_by')::uuid, + enabled = @enabled::boolean, + is_default = @is_default::boolean, + context_limit = @context_limit::bigint, + compression_threshold = @compression_threshold::integer, + options = @options::jsonb, + updated_at = NOW() +WHERE + id = @id::uuid + AND deleted = FALSE +RETURNING + *; + +-- name: UnsetDefaultChatModelConfigs :exec +UPDATE + chat_model_configs +SET + is_default = FALSE, + updated_at = NOW() +WHERE + is_default = TRUE + AND deleted = FALSE; + +-- name: DeleteChatModelConfigByID :exec +UPDATE + chat_model_configs +SET + deleted = TRUE, + deleted_at = NOW(), + updated_at = NOW() +WHERE + id = @id::uuid; diff --git a/coderd/database/queries/chatproviders.sql b/coderd/database/queries/chatproviders.sql new file mode 100644 index 0000000000..228fbf3b28 --- /dev/null +++ b/coderd/database/queries/chatproviders.sql @@ -0,0 +1,75 @@ +-- name: GetChatProviderByID :one +SELECT + * +FROM + chat_providers +WHERE + id = @id::uuid; + +-- name: GetChatProviderByProvider :one +SELECT + * +FROM + chat_providers +WHERE + provider = @provider::text; + +-- name: GetChatProviders :many +SELECT + * +FROM + chat_providers +ORDER BY + provider ASC; + +-- name: GetEnabledChatProviders :many +SELECT + * +FROM + chat_providers +WHERE + enabled = TRUE +ORDER BY + provider ASC; + +-- name: InsertChatProvider :one +INSERT INTO chat_providers ( + provider, + display_name, + api_key, + base_url, + api_key_key_id, + created_by, + enabled +) VALUES ( + @provider::text, + @display_name::text, + @api_key::text, + @base_url::text, + sqlc.narg('api_key_key_id')::text, + sqlc.narg('created_by')::uuid, + @enabled::boolean +) +RETURNING + *; + +-- name: UpdateChatProvider :one +UPDATE + chat_providers +SET + display_name = @display_name::text, + api_key = @api_key::text, + base_url = @base_url::text, + api_key_key_id = sqlc.narg('api_key_key_id')::text, + enabled = @enabled::boolean, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; + +-- name: DeleteChatProviderByID :exec +DELETE FROM + chat_providers +WHERE + id = @id::uuid; diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql new file mode 100644 index 0000000000..9c30797731 --- /dev/null +++ b/coderd/database/queries/chats.sql @@ -0,0 +1,409 @@ +-- name: DeleteChatByID :exec +DELETE FROM + chats +WHERE + id = @id::uuid; + +-- name: DeleteChatMessagesByChatID :exec +DELETE FROM + chat_messages +WHERE + chat_id = @chat_id::uuid; + +-- name: DeleteChatMessagesAfterID :exec +DELETE FROM + chat_messages +WHERE + chat_id = @chat_id::uuid + AND id > @after_id::bigint; + +-- name: GetChatByID :one +SELECT + * +FROM + chats +WHERE + id = @id::uuid; + +-- name: GetChatMessageByID :one +SELECT + * +FROM + chat_messages +WHERE + id = @id::bigint; + +-- name: GetChatMessagesByChatID :many +SELECT + * +FROM + chat_messages +WHERE + chat_id = @chat_id::uuid + AND visibility IN ('user', 'both') +ORDER BY + created_at ASC; + +-- name: GetChatMessagesForPromptByChatID :many +WITH latest_compressed_summary AS ( + SELECT + id + FROM + chat_messages + WHERE + chat_id = @chat_id::uuid + AND role = 'system' + AND visibility IN ('model', 'both') + AND compressed = TRUE + ORDER BY + created_at DESC, + id DESC + LIMIT + 1 +) +SELECT + * +FROM + chat_messages +WHERE + chat_id = @chat_id::uuid + AND visibility IN ('model', 'both') + AND ( + ( + role = 'system' + AND compressed = FALSE + ) + OR ( + compressed = FALSE + AND ( + NOT EXISTS ( + SELECT + 1 + FROM + latest_compressed_summary + ) + OR id > ( + SELECT + id + FROM + latest_compressed_summary + ) + ) + ) + OR id = ( + SELECT + id + FROM + latest_compressed_summary + ) + ) +ORDER BY + created_at ASC, + id ASC; + +-- name: GetChatsByOwnerID :many +SELECT + * +FROM + chats +WHERE + owner_id = @owner_id::uuid +ORDER BY + updated_at DESC; + +-- name: ListChildChatsByParentID :many +SELECT + * +FROM + chats +WHERE + parent_chat_id = @parent_chat_id::uuid +ORDER BY + created_at ASC; + +-- name: ListChatsByRootID :many +SELECT + * +FROM + chats +WHERE + root_chat_id = @root_chat_id::uuid +ORDER BY + created_at ASC; + +-- name: InsertChat :one +INSERT INTO chats ( + owner_id, + workspace_id, + workspace_agent_id, + parent_chat_id, + root_chat_id, + last_model_config_id, + title +) VALUES ( + @owner_id::uuid, + sqlc.narg('workspace_id')::uuid, + sqlc.narg('workspace_agent_id')::uuid, + sqlc.narg('parent_chat_id')::uuid, + sqlc.narg('root_chat_id')::uuid, + @last_model_config_id::uuid, + @title::text +) +RETURNING + *; + +-- name: InsertChatMessage :one +WITH updated_chat AS ( + UPDATE + chats + SET + last_model_config_id = sqlc.narg('model_config_id')::uuid + WHERE + id = @chat_id::uuid + AND sqlc.narg('model_config_id')::uuid IS NOT NULL +) +INSERT INTO chat_messages ( + chat_id, + model_config_id, + role, + content, + visibility, + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + cache_creation_tokens, + cache_read_tokens, + context_limit, + compressed +) VALUES ( + @chat_id::uuid, + sqlc.narg('model_config_id')::uuid, + @role::text, + sqlc.narg('content')::jsonb, + @visibility::chat_message_visibility, + sqlc.narg('input_tokens')::bigint, + sqlc.narg('output_tokens')::bigint, + sqlc.narg('total_tokens')::bigint, + sqlc.narg('reasoning_tokens')::bigint, + sqlc.narg('cache_creation_tokens')::bigint, + sqlc.narg('cache_read_tokens')::bigint, + sqlc.narg('context_limit')::bigint, + COALESCE(sqlc.narg('compressed')::boolean, FALSE) +) +RETURNING + *; + +-- name: UpdateChatMessageByID :one +UPDATE + chat_messages +SET + model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id), + content = sqlc.narg('content')::jsonb +WHERE + id = @id::bigint +RETURNING + *; + +-- name: UpdateChatByID :one +UPDATE + chats +SET + title = @title::text, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; + +-- name: UpdateChatWorkspace :one +UPDATE + chats +SET + workspace_id = sqlc.narg('workspace_id')::uuid, + workspace_agent_id = sqlc.narg('workspace_agent_id')::uuid, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; + +-- name: AcquireChat :one +-- Acquires a pending chat for processing. Uses SKIP LOCKED to prevent +-- multiple replicas from acquiring the same chat. +UPDATE + chats +SET + status = 'running'::chat_status, + started_at = @started_at::timestamptz, + heartbeat_at = @started_at::timestamptz, + updated_at = @started_at::timestamptz, + worker_id = @worker_id::uuid +WHERE + id = ( + SELECT + id + FROM + chats + WHERE + status = 'pending'::chat_status + ORDER BY + updated_at ASC + FOR UPDATE + SKIP LOCKED + LIMIT + 1 + ) +RETURNING + *; + +-- name: UpdateChatStatus :one +UPDATE + chats +SET + status = @status::chat_status, + worker_id = sqlc.narg('worker_id')::uuid, + started_at = sqlc.narg('started_at')::timestamptz, + heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; + +-- name: GetStaleChats :many +-- Find chats that appear stuck (running but heartbeat has expired). +-- Used for recovery after coderd crashes or long hangs. +SELECT + * +FROM + chats +WHERE + status = 'running'::chat_status + AND heartbeat_at < @stale_threshold::timestamptz; + +-- name: UpdateChatHeartbeat :execrows +-- Bumps the heartbeat timestamp for a running chat so that other +-- replicas know the worker is still alive. +UPDATE + chats +SET + heartbeat_at = NOW() +WHERE + id = @id::uuid + AND worker_id = @worker_id::uuid + AND status = 'running'::chat_status; + +-- name: GetChatDiffStatusByChatID :one +SELECT + * +FROM + chat_diff_statuses +WHERE + chat_id = @chat_id::uuid; + +-- name: GetChatDiffStatusesByChatIDs :many +SELECT + * +FROM + chat_diff_statuses +WHERE + chat_id = ANY(@chat_ids::uuid[]); + +-- name: UpsertChatDiffStatusReference :one +INSERT INTO chat_diff_statuses ( + chat_id, + url, + git_branch, + git_remote_origin, + stale_at +) VALUES ( + @chat_id::uuid, + sqlc.narg('url')::text, + @git_branch::text, + @git_remote_origin::text, + @stale_at::timestamptz +) +ON CONFLICT (chat_id) DO UPDATE +SET + url = CASE + WHEN EXCLUDED.url IS NOT NULL THEN EXCLUDED.url + ELSE chat_diff_statuses.url + END, + git_branch = CASE + WHEN EXCLUDED.git_branch != '' THEN EXCLUDED.git_branch + ELSE chat_diff_statuses.git_branch + END, + git_remote_origin = CASE + WHEN EXCLUDED.git_remote_origin != '' THEN EXCLUDED.git_remote_origin + ELSE chat_diff_statuses.git_remote_origin + END, + stale_at = EXCLUDED.stale_at, + updated_at = NOW() +RETURNING + *; + +-- name: UpsertChatDiffStatus :one +INSERT INTO chat_diff_statuses ( + chat_id, + url, + pull_request_state, + changes_requested, + additions, + deletions, + changed_files, + refreshed_at, + stale_at +) VALUES ( + @chat_id::uuid, + sqlc.narg('url')::text, + sqlc.narg('pull_request_state')::text, + @changes_requested::boolean, + @additions::integer, + @deletions::integer, + @changed_files::integer, + @refreshed_at::timestamptz, + @stale_at::timestamptz +) +ON CONFLICT (chat_id) DO UPDATE +SET + url = EXCLUDED.url, + pull_request_state = EXCLUDED.pull_request_state, + changes_requested = EXCLUDED.changes_requested, + additions = EXCLUDED.additions, + deletions = EXCLUDED.deletions, + changed_files = EXCLUDED.changed_files, + refreshed_at = EXCLUDED.refreshed_at, + stale_at = EXCLUDED.stale_at, + updated_at = NOW() +RETURNING + *; + +-- name: InsertChatQueuedMessage :one +INSERT INTO chat_queued_messages (chat_id, content) +VALUES (@chat_id, @content) +RETURNING *; + +-- name: GetChatQueuedMessages :many +SELECT * FROM chat_queued_messages +WHERE chat_id = @chat_id +ORDER BY id ASC; + +-- name: DeleteChatQueuedMessage :exec +DELETE FROM chat_queued_messages WHERE id = @id AND chat_id = @chat_id; + +-- name: DeleteAllChatQueuedMessages :exec +DELETE FROM chat_queued_messages WHERE chat_id = @chat_id; + +-- name: PopNextQueuedMessage :one +DELETE FROM chat_queued_messages +WHERE id = ( + SELECT cqm.id FROM chat_queued_messages cqm + WHERE cqm.chat_id = @chat_id + ORDER BY cqm.id ASC + LIMIT 1 +) +RETURNING *; + +-- name: GetChatByIDForUpdate :one +SELECT * FROM chats WHERE id = @id::uuid FOR UPDATE; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 56bf5de752..c2aa9b6742 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -399,7 +399,7 @@ WHERE filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = @requester_id AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = @requester_id) THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 40357f38c9..e7f8489915 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -14,6 +14,13 @@ const ( UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); + UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); + UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + UniqueChatModelConfigsPkey UniqueConstraint = "chat_model_configs_pkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); + UniqueChatProvidersPkey UniqueConstraint = "chat_providers_pkey" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_pkey PRIMARY KEY (id); + UniqueChatProvidersProviderKey UniqueConstraint = "chat_providers_provider_key" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_provider_key UNIQUE (provider); + UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); + UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); @@ -110,6 +117,7 @@ const ( UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree ((1)) WHERE ((is_default = true) AND (deleted = false)); UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); diff --git a/coderd/httpmw/chatparam.go b/coderd/httpmw/chatparam.go new file mode 100644 index 0000000000..280c70143c --- /dev/null +++ b/coderd/httpmw/chatparam.go @@ -0,0 +1,50 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type chatParamContextKey struct{} + +// ChatParam returns the chat from the ExtractChatParam handler. +func ChatParam(r *http.Request) database.Chat { + chat, ok := r.Context().Value(chatParamContextKey{}).(database.Chat) + if !ok { + panic("developer error: chat param middleware not provided") + } + return chat +} + +// ExtractChatParam grabs a chat from the "chat" URL parameter. +func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chatID, parsed := ParseUUIDParam(rw, r, "chat") + if !parsed { + return + } + + chat, err := db.GetChatByID(ctx, chatID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching chat.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, chatParamContextKey{}, chat) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/chatparam_test.go b/coderd/httpmw/chatparam_test.go new file mode 100644 index 0000000000..10bc8e5844 --- /dev/null +++ b/coderd/httpmw/chatparam_test.go @@ -0,0 +1,159 @@ +package httpmw_test + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +func TestChatParam(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.User) { + user := dbgen.User(t, db, database.User{}) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, token) + + ctx := chi.NewRouteContext() + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, user + } + + insertChat := func(t *testing.T, db database.Store, ownerID uuid.UUID) database.Chat { + t.Helper() + + _, err := db.InsertChatProvider(context.Background(), database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-api-key", + BaseUrl: "https://api.openai.com/v1", + ApiKeyKeyID: sql.NullString{}, + CreatedBy: uuid.NullUUID{UUID: ownerID, Valid: true}, + Enabled: true, + }) + require.NoError(t, err) + + modelConfig, err := db.InsertChatModelConfig(context.Background(), database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "gpt-4o-mini", + DisplayName: "Test model", + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 70, + Options: []byte("{}"), + }) + require.NoError(t, err) + + chat, err := db.InsertChat(context.Background(), database.InsertChatParams{ + OwnerID: ownerID, + WorkspaceID: uuid.NullUUID{}, + WorkspaceAgentID: uuid.NullUUID{}, + ParentChatID: uuid.NullUUID{}, + RootChatID: uuid.NullUUID{}, + LastModelConfigID: modelConfig.ID, + Title: "Test chat", + }) + require.NoError(t, err) + + return chat + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractChatParam(db)) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractChatParam(db)) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("chat", uuid.NewString()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("BadUUID", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractChatParam(db)) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("chat", "not-a-uuid") + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.ChatParam(r) + rw.WriteHeader(http.StatusOK) + }) + + r, user := setupAuthentication(db) + chat := insertChat(t, db, user.ID) + + chi.RouteContext(r.Context()).URLParams.Add("chat", chat.ID.String()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/coderd/httpmw/requestid.go b/coderd/httpmw/requestid.go index 15269f47f8..c17e32c1bb 100644 --- a/coderd/httpmw/requestid.go +++ b/coderd/httpmw/requestid.go @@ -15,13 +15,24 @@ type requestIDContextKey struct{} // RequestID returns the ID of the request. func RequestID(r *http.Request) uuid.UUID { - rid, ok := r.Context().Value(requestIDContextKey{}).(uuid.UUID) + rid, ok := RequestIDOptional(r) if !ok { panic("developer error: request id middleware not provided") } return rid } +// RequestIDOptional returns the request ID when present. +func RequestIDOptional(r *http.Request) (uuid.UUID, bool) { + rid, ok := r.Context().Value(requestIDContextKey{}).(uuid.UUID) + return rid, ok +} + +// WithRequestID stores a request ID in the context. +func WithRequestID(ctx context.Context, rid uuid.UUID) context.Context { + return context.WithValue(ctx, requestIDContextKey{}, rid) +} + // AttachRequestID adds a request ID to each HTTP request. func AttachRequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/httpmw/requestid_test.go b/coderd/httpmw/requestid_test.go index 7dc21a8f23..65b3b1e1ba 100644 --- a/coderd/httpmw/requestid_test.go +++ b/coderd/httpmw/requestid_test.go @@ -1,11 +1,13 @@ package httpmw_test import ( + "context" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/httpmw" @@ -31,3 +33,16 @@ func TestRequestID(t *testing.T) { require.NotEmpty(t, res.Header.Get("X-Coder-Request-ID")) require.NotEmpty(t, rw.Body.Bytes()) } + +func TestRequestIDHelpers(t *testing.T) { + t.Parallel() + + requestID := uuid.New() + ctx := httpmw.WithRequestID(context.Background(), requestID) + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + + gotRequestID, ok := httpmw.RequestIDOptional(req) + require.True(t, ok) + require.Equal(t, requestID, gotRequestID) + require.Equal(t, requestID, httpmw.RequestID(req)) +} diff --git a/coderd/pubsub/chatevent.go b/coderd/pubsub/chatevent.go new file mode 100644 index 0000000000..70ec8ab297 --- /dev/null +++ b/coderd/pubsub/chatevent.go @@ -0,0 +1,46 @@ +package pubsub + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +func ChatEventChannel(ownerID uuid.UUID) string { + return fmt.Sprintf("chat:owner:%s", ownerID) +} + +func HandleChatEvent(cb func(ctx context.Context, payload ChatEvent, err error)) func(ctx context.Context, message []byte, err error) { + return func(ctx context.Context, message []byte, err error) { + if err != nil { + cb(ctx, ChatEvent{}, xerrors.Errorf("chat event pubsub: %w", err)) + return + } + var payload ChatEvent + if err := json.Unmarshal(message, &payload); err != nil { + cb(ctx, ChatEvent{}, xerrors.Errorf("unmarshal chat event")) + return + } + + cb(ctx, payload, err) + } +} + +type ChatEvent struct { + Kind ChatEventKind `json:"kind"` + Chat codersdk.Chat `json:"chat"` +} + +type ChatEventKind string + +const ( + ChatEventKindStatusChange ChatEventKind = "status_change" + ChatEventKindTitleChange ChatEventKind = "title_change" + ChatEventKindCreated ChatEventKind = "created" + ChatEventKindDeleted ChatEventKind = "deleted" +) diff --git a/coderd/pubsub/chatstreamnotify.go b/coderd/pubsub/chatstreamnotify.go new file mode 100644 index 0000000000..078691c934 --- /dev/null +++ b/coderd/pubsub/chatstreamnotify.go @@ -0,0 +1,37 @@ +package pubsub + +import ( + "fmt" + + "github.com/google/uuid" +) + +// ChatStreamNotifyChannel returns the pubsub channel for per-chat +// stream notifications. Subscribers receive lightweight notifications +// and read actual content from the database. +func ChatStreamNotifyChannel(chatID uuid.UUID) string { + return fmt.Sprintf("chat:stream:%s", chatID) +} + +// ChatStreamNotifyMessage is the payload published on the per-chat +// stream notification channel. The actual message content is read +// from the database by subscribers. +type ChatStreamNotifyMessage struct { + // AfterMessageID tells subscribers to query messages after this + // ID. Set when a new message is persisted. + AfterMessageID int64 `json:"after_message_id,omitempty"` + + // Status is set when the chat status changes. Subscribers use + // this to update clients and to manage relay lifecycle. + Status string `json:"status,omitempty"` + + // WorkerID identifies which replica is running the chat. Used + // by enterprise relay to know where to connect. + WorkerID string `json:"worker_id,omitempty"` + + // Error is set when a processing error occurs. + Error string `json:"error,omitempty"` + + // QueueUpdate is set when the queued messages change. + QueueUpdate bool `json:"queue_update,omitempty"` +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 02d2e9669f..ded9be2820 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -72,6 +72,16 @@ var ( Type: "boundary_usage", } + // ResourceChat + // Valid Actions + // - "ActionCreate" :: create a new chat + // - "ActionDelete" :: delete a chat + // - "ActionRead" :: read chat messages and metadata + // - "ActionUpdate" :: update chat title or settings + ResourceChat = Object{ + Type: "chat", + } + // ResourceConnectionLog // Valid Actions // - "ActionRead" :: read connection logs @@ -429,6 +439,7 @@ func AllResources() []Objecter { ResourceAssignRole, ResourceAuditLog, ResourceBoundaryUsage, + ResourceChat, ResourceConnectionLog, ResourceCryptoKey, ResourceDebugInfo, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 48f679874d..5ac669c127 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -77,6 +77,13 @@ var taskActions = map[Action]ActionDefinition{ ActionDelete: "delete task", } +var chatActions = map[Action]ActionDefinition{ + ActionCreate: "create a new chat", + ActionRead: "read chat messages and metadata", + ActionUpdate: "update chat title or settings", + ActionDelete: "delete a chat", +} + // RBACPermissions is indexed by the type var RBACPermissions = map[string]PermissionDefinition{ // Wildcard is every object, and the action "*" provides all actions. @@ -103,6 +110,9 @@ var RBACPermissions = map[string]PermissionDefinition{ "task": { Actions: taskActions, }, + "chat": { + Actions: chatActions, + }, // Dormant workspaces have the same perms as workspaces. "workspace_dormant": { Actions: workspaceActions, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0cf67f3cf0..bf4eb16314 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1030,6 +1030,20 @@ func TestRolePermissions(t *testing.T) { false: {owner, setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin}, }, }, + { + Name: "ChatUsage", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceChat.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, memberMe}, + false: { + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 995fe109b4..40f319a8ba 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -28,6 +28,10 @@ const ( ScopeBoundaryUsageDelete ScopeName = "boundary_usage:delete" ScopeBoundaryUsageRead ScopeName = "boundary_usage:read" ScopeBoundaryUsageUpdate ScopeName = "boundary_usage:update" + ScopeChatCreate ScopeName = "chat:create" + ScopeChatDelete ScopeName = "chat:delete" + ScopeChatRead ScopeName = "chat:read" + ScopeChatUpdate ScopeName = "chat:update" ScopeConnectionLogRead ScopeName = "connection_log:read" ScopeConnectionLogUpdate ScopeName = "connection_log:update" ScopeCryptoKeyCreate ScopeName = "crypto_key:create" @@ -188,6 +192,10 @@ func (e ScopeName) Valid() bool { ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, + ScopeChatCreate, + ScopeChatDelete, + ScopeChatRead, + ScopeChatUpdate, ScopeConnectionLogRead, ScopeConnectionLogUpdate, ScopeCryptoKeyCreate, @@ -349,6 +357,10 @@ func AllScopeNameValues() []ScopeName { ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, + ScopeChatCreate, + ScopeChatDelete, + ScopeChatRead, + ScopeChatUpdate, ScopeConnectionLogRead, ScopeConnectionLogUpdate, ScopeCryptoKeyCreate, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 7e08c25714..d19dd140d5 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -139,7 +139,6 @@ const AgentAPIVersionREST = "1.0" func (api *API) patchWorkspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - var req agentsdk.PatchLogs if !httpapi.Read(ctx, rw, r, &req) { return @@ -1832,6 +1831,10 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() query := r.URL.Query() + gitRef := chatGitRef{ + Branch: strings.TrimSpace(query.Get("git_branch")), + RemoteOrigin: strings.TrimSpace(query.Get("git_remote_origin")), + } // Either match or configID must be provided! match := query.Get("match") if match == "" { @@ -1854,7 +1857,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ // listen determines if the request will wait for a // new token to be issued! - listen := r.URL.Query().Has("listen") + listen := query.Has("listen") var externalAuthConfig *externalauth.Config for _, extAuth := range api.ExternalAuthConfigs { @@ -1925,6 +1928,13 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ return } + // Persist git refs as soon as the agent requests external auth so branch + // context is retained even if the flow requires an out-of-band login. + if gitRef.Branch != "" || gitRef.RemoteOrigin != "" { + //nolint:gocritic // System context required to persist chat git refs. + api.storeChatGitRef(dbauthz.AsSystemRestricted(ctx), workspace.ID, workspace.OwnerID, gitRef) + } + var previousToken *database.ExternalAuthLink // handleRetrying will attempt to continually check for a new token // if listen is true. This is useful if an error is encountered in the @@ -1938,7 +1948,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ return } - api.workspaceAgentsExternalAuthListen(ctx, rw, previousToken, externalAuthConfig, workspace) + api.workspaceAgentsExternalAuthListen(ctx, rw, previousToken, externalAuthConfig, workspace, gitRef) } // This is the URL that will redirect the user with a state token. @@ -1996,10 +2006,11 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ }) return } + api.triggerWorkspaceChatDiffStatusRefresh(workspace, gitRef) httpapi.Write(ctx, rw, http.StatusOK, resp) } -func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace) { +func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace, gitRef chatGitRef) { // Since we're ticking frequently and this sign-in operation is rare, // we are OK with polling to avoid the complexity of pubsub. ticker, done := api.NewTicker(time.Second) @@ -2069,6 +2080,7 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R }) return } + api.triggerWorkspaceChatDiffStatusRefresh(workspace, gitRef) httpapi.Write(ctx, rw, http.StatusOK, resp) return } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 847b2403e7..f2114e1f92 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -404,7 +404,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req AvatarURL: member.AvatarURL, } - w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r, nil) + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{ + remoteAddr: r.RemoteAddr, + }) if err != nil { httperror.WriteResponseError(ctx, rw, err) return @@ -500,7 +502,9 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) { defer commitAudit() - w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r, nil) + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{ + remoteAddr: r.RemoteAddr, + }) if err != nil { httperror.WriteResponseError(ctx, rw, err) return @@ -522,6 +526,10 @@ type createWorkspaceOptions struct { // postCreateInTX is a function that is called within the transaction, after // the workspace is created but before the workspace build is created. postCreateInTX func(ctx context.Context, tx database.Store, workspace database.Workspace) error + // remoteAddr is the IP address of the request initiator, used for + // audit logging. HTTP handlers should pass r.RemoteAddr; + // programmatic callers may leave it empty. + remoteAddr string } func createWorkspace( @@ -531,7 +539,6 @@ func createWorkspace( api *API, owner workspaceOwner, req codersdk.CreateWorkspaceRequest, - r *http.Request, opts *createWorkspaceOptions, ) (codersdk.Workspace, error) { if opts == nil { @@ -545,7 +552,7 @@ func createWorkspace( // This is a premature auth check to avoid doing unnecessary work if the user // doesn't have permission to create a workspace. - if !api.Authorize(r, policy.ActionCreate, + if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) { // If this check fails, return a proper unauthorized error to the user to indicate // what is going on. @@ -562,14 +569,14 @@ func createWorkspace( // Do this upfront to save work. If this fails, the rest of the work // would be wasted. - if !api.Authorize(r, policy.ActionCreate, + if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) { return codersdk.Workspace{}, httperror.ErrResourceNotFound } // The user also needs permission to use the template. At this point they have // read perms, but not necessarily "use". This is also checked in `db.InsertWorkspace`. // Doing this up front can save some work below if the user doesn't have permission. - if !api.Authorize(r, policy.ActionUse, template) { + if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUse, template) { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name), Detail: "Although you are able to view the template, you are unable to create a workspace using it. " + @@ -801,9 +808,9 @@ func createWorkspace( db, api.FileCache, func(action policy.Action, object rbac.Objecter) bool { - return api.Authorize(r, action, object) + return api.HTTPAuth.AuthorizeContext(ctx, action, object) }, - audit.WorkspaceBuildBaggageFromRequest(r), + audit.WorkspaceBuildBaggage{IP: opts.remoteAddr}, ) return err }, nil) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 8b24ea7d07..dedf8593b1 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -638,6 +638,14 @@ type ExternalAuthRequest struct { ID string // Match is an arbitrary string matched against the regex of the provider. Match string + // GitBranch is the current git branch in the working directory. + // Sent by the agent so the control plane can resolve diffs + // without SSHing into the workspace. + GitBranch string + // GitRemoteOrigin is the remote origin URL of the git repository. + // Sent by the agent so the control plane can resolve diffs + // without SSHing into the workspace. + GitRemoteOrigin string // Listen indicates that the request should be long-lived and listen for // a new token to be requested. Listen bool @@ -653,6 +661,12 @@ func (c *Client) ExternalAuth(ctx context.Context, req ExternalAuthRequest) (Ext if req.Listen { q.Set("listen", "true") } + if req.GitBranch != "" { + q.Set("git_branch", req.GitBranch) + } + if req.GitRemoteOrigin != "" { + q.Set("git_remote_origin", req.GitRemoteOrigin) + } reqURL := "/api/v2/workspaceagents/me/external-auth?" + q.Encode() res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil) if err != nil { diff --git a/codersdk/agentsdk/agentsdk_test.go b/codersdk/agentsdk/agentsdk_test.go index 691fa0e3e7..327fd881d7 100644 --- a/codersdk/agentsdk/agentsdk_test.go +++ b/codersdk/agentsdk/agentsdk_test.go @@ -153,3 +153,33 @@ func TestRewriteDERPMap(t *testing.T) { require.Equal(t, "coconuts.org", node.HostName) require.Equal(t, 44558, node.DERPPort) } + +func TestExternalAuthRequestQuery(t *testing.T) { + t.Parallel() + + t.Run("IncludesGitRefFieldsAndOmitsWorkdir", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/workspaceagents/me/external-auth", r.URL.Path) + require.Equal(t, "true", r.URL.Query().Get("listen")) + require.Equal(t, "main", r.URL.Query().Get("git_branch")) + require.Equal(t, "https://github.com/coder/coder.git", r.URL.Query().Get("git_remote_origin")) + require.False(t, r.URL.Query().Has("workdir")) + _, _ = w.Write([]byte(`{"type":"github","access_token":"token"}`)) + })) + defer srv.Close() + + parsedURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + client := agentsdk.New(parsedURL, agentsdk.WithFixedToken("token")) + _, err = client.ExternalAuth(testutil.Context(t, testutil.WaitShort), agentsdk.ExternalAuthRequest{ + Match: "github.com", + Listen: true, + GitBranch: "main", + GitRemoteOrigin: "https://github.com/coder/coder.git", + }) + require.NoError(t, err) + }) +} diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 0a585bfa53..2e04689564 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -33,6 +33,11 @@ const ( APIKeyScopeBoundaryUsageDelete APIKeyScope = "boundary_usage:delete" APIKeyScopeBoundaryUsageRead APIKeyScope = "boundary_usage:read" APIKeyScopeBoundaryUsageUpdate APIKeyScope = "boundary_usage:update" + APIKeyScopeChatAll APIKeyScope = "chat:*" + APIKeyScopeChatCreate APIKeyScope = "chat:create" + APIKeyScopeChatDelete APIKeyScope = "chat:delete" + APIKeyScopeChatRead APIKeyScope = "chat:read" + APIKeyScopeChatUpdate APIKeyScope = "chat:update" APIKeyScopeCoderAll APIKeyScope = "coder:all" APIKeyScopeCoderApikeysManageSelf APIKeyScope = "coder:apikeys.manage_self" APIKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect" diff --git a/codersdk/chats.go b/codersdk/chats.go new file mode 100644 index 0000000000..c655182f73 --- /dev/null +++ b/codersdk/chats.go @@ -0,0 +1,903 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +// ChatStatus represents the status of a chat. +type ChatStatus string + +const ( + ChatStatusWaiting ChatStatus = "waiting" + ChatStatusPending ChatStatus = "pending" + ChatStatusRunning ChatStatus = "running" + ChatStatusPaused ChatStatus = "paused" + ChatStatusCompleted ChatStatus = "completed" + ChatStatusError ChatStatus = "error" +) + +// Chat represents a chat session with an AI agent. +type Chat struct { + ID uuid.UUID `json:"id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + WorkspaceAgentID *uuid.UUID `json:"workspace_agent_id,omitempty" format:"uuid"` + ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` + RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` + LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` + Title string `json:"title"` + Status ChatStatus `json:"status"` + DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// ChatMessage represents a single message in a chat. +type ChatMessage struct { + ID int64 `json:"id"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + Role string `json:"role"` + Content []ChatMessagePart `json:"content,omitempty"` + Usage *ChatMessageUsage `json:"usage,omitempty"` +} + +// ChatMessageUsage contains token usage information for a chat message. +type ChatMessageUsage struct { + InputTokens *int64 `json:"input_tokens,omitempty"` + OutputTokens *int64 `json:"output_tokens,omitempty"` + TotalTokens *int64 `json:"total_tokens,omitempty"` + ReasoningTokens *int64 `json:"reasoning_tokens,omitempty"` + CacheCreationTokens *int64 `json:"cache_creation_tokens,omitempty"` + CacheReadTokens *int64 `json:"cache_read_tokens,omitempty"` + ContextLimit *int64 `json:"context_limit,omitempty"` +} + +// ChatMessagePartType represents a structured message part type. +type ChatMessagePartType string + +const ( + ChatMessagePartTypeText ChatMessagePartType = "text" + ChatMessagePartTypeReasoning ChatMessagePartType = "reasoning" + ChatMessagePartTypeToolCall ChatMessagePartType = "tool-call" + ChatMessagePartTypeToolResult ChatMessagePartType = "tool-result" + ChatMessagePartTypeSource ChatMessagePartType = "source" + ChatMessagePartTypeFile ChatMessagePartType = "file" +) + +// ChatMessagePart is a structured chunk of a chat message. +type ChatMessagePart struct { + Type ChatMessagePartType `json:"type"` + Text string `json:"text,omitempty"` + Signature string `json:"signature,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + ArgsDelta string `json:"args_delta,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + ResultDelta string `json:"result_delta,omitempty"` + IsError bool `json:"is_error,omitempty"` + SourceID string `json:"source_id,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + MediaType string `json:"media_type,omitempty"` + Data []byte `json:"data,omitempty"` +} + +// ChatInputPartType represents an input part type for user chat input. +type ChatInputPartType string + +const ( + ChatInputPartTypeText ChatInputPartType = "text" +) + +// ChatInputPart is a single user input part for creating a chat. +type ChatInputPart struct { + Type ChatInputPartType `json:"type"` + Text string `json:"text,omitempty"` +} + +// CreateChatRequest is the request to create a new chat. +type CreateChatRequest struct { + Content []ChatInputPart `json:"content"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` +} + +// UpdateChatRequest is the request to update a chat. +type UpdateChatRequest struct { + Title string `json:"title"` +} + +// CreateChatMessageRequest is the request to add a message to a chat. +type CreateChatMessageRequest struct { + Content []ChatInputPart `json:"content"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` +} + +// EditChatMessageRequest is the request to edit a user message in a chat. +type EditChatMessageRequest struct { + Content []ChatInputPart `json:"content"` +} + +// CreateChatMessageResponse is the response from adding a message to a chat. +type CreateChatMessageResponse struct { + Message *ChatMessage `json:"message,omitempty"` + QueuedMessage *ChatQueuedMessage `json:"queued_message,omitempty"` + Queued bool `json:"queued"` +} + +// ChatWithMessages is a chat along with its messages. +type ChatWithMessages struct { + Chat Chat `json:"chat"` + Messages []ChatMessage `json:"messages"` + QueuedMessages []ChatQueuedMessage `json:"queued_messages"` +} + +// ChatModelProviderUnavailableReason explains why a provider cannot be used. +type ChatModelProviderUnavailableReason string + +const ( + ChatModelProviderUnavailableMissingAPIKey ChatModelProviderUnavailableReason = "missing_api_key" + ChatModelProviderUnavailableFetchFailed ChatModelProviderUnavailableReason = "fetch_failed" +) + +// ChatModel represents a model in the chat model catalog. +type ChatModel struct { + ID string `json:"id"` + Provider string `json:"provider"` + Model string `json:"model"` + DisplayName string `json:"display_name"` +} + +// ChatModelProvider represents provider availability and model results. +type ChatModelProvider struct { + Provider string `json:"provider"` + Available bool `json:"available"` + UnavailableReason ChatModelProviderUnavailableReason `json:"unavailable_reason,omitempty"` + Models []ChatModel `json:"models"` +} + +// ChatModelsResponse is the catalog returned from chat model discovery. +type ChatModelsResponse struct { + Providers []ChatModelProvider `json:"providers"` +} + +// ChatProviderConfigSource describes how a provider entry is sourced. +type ChatProviderConfigSource string + +const ( + ChatProviderConfigSourceDatabase ChatProviderConfigSource = "database" + ChatProviderConfigSourceEnvPreset ChatProviderConfigSource = "env_preset" + ChatProviderConfigSourceSupported ChatProviderConfigSource = "supported" +) + +// ChatProviderConfig is an admin-managed provider configuration. +type ChatProviderConfig struct { + ID uuid.UUID `json:"id" format:"uuid"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + HasAPIKey bool `json:"has_api_key"` + BaseURL string `json:"base_url,omitempty"` + Source ChatProviderConfigSource `json:"source"` + CreatedAt time.Time `json:"created_at,omitempty" format:"date-time"` + UpdatedAt time.Time `json:"updated_at,omitempty" format:"date-time"` +} + +// CreateChatProviderConfigRequest creates a chat provider config. +type CreateChatProviderConfigRequest struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name,omitempty"` + APIKey string `json:"api_key,omitempty"` + BaseURL string `json:"base_url,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// UpdateChatProviderConfigRequest updates a chat provider config. +type UpdateChatProviderConfigRequest struct { + DisplayName string `json:"display_name,omitempty"` + APIKey *string `json:"api_key,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// ChatModelConfig is an admin-managed model configuration. +type ChatModelConfig struct { + ID uuid.UUID `json:"id" format:"uuid"` + Provider string `json:"provider"` + Model string `json:"model"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + IsDefault bool `json:"is_default"` + ContextLimit int64 `json:"context_limit"` + CompressionThreshold int32 `json:"compression_threshold"` + ModelConfig *ChatModelCallConfig `json:"model_config,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// ChatModelProviderOptions contains typed provider-specific options. +// +// Note: Azure models use the `openai` options shape. +// Note: Bedrock models use the `anthropic` options shape. +type ChatModelProviderOptions struct { + OpenAI *ChatModelOpenAIProviderOptions `json:"openai,omitempty"` + Anthropic *ChatModelAnthropicProviderOptions `json:"anthropic,omitempty"` + Google *ChatModelGoogleProviderOptions `json:"google,omitempty"` + OpenAICompat *ChatModelOpenAICompatProviderOptions `json:"openaicompat,omitempty"` + OpenRouter *ChatModelOpenRouterProviderOptions `json:"openrouter,omitempty"` + Vercel *ChatModelVercelProviderOptions `json:"vercel,omitempty"` +} + +// ChatModelOpenAIProviderOptions configures OpenAI provider behavior. +type ChatModelOpenAIProviderOptions struct { + Include []string `json:"include,omitempty"` + Instructions *string `json:"instructions,omitempty"` + LogitBias map[string]int64 `json:"logit_bias,omitempty"` + LogProbs *bool `json:"log_probs,omitempty"` + TopLogProbs *int64 `json:"top_log_probs,omitempty"` + MaxToolCalls *int64 `json:"max_tool_calls,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + User *string `json:"user,omitempty"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` + ReasoningSummary *string `json:"reasoning_summary,omitempty"` + MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty"` + TextVerbosity *string `json:"text_verbosity,omitempty"` + Prediction map[string]any `json:"prediction,omitempty"` + Store *bool `json:"store,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + PromptCacheKey *string `json:"prompt_cache_key,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + StructuredOutputs *bool `json:"structured_outputs,omitempty"` + StrictJSONSchema *bool `json:"strict_json_schema,omitempty"` +} + +// ChatModelAnthropicThinkingOptions configures Anthropic thinking budget. +type ChatModelAnthropicThinkingOptions struct { + BudgetTokens *int64 `json:"budget_tokens,omitempty"` +} + +// ChatModelAnthropicProviderOptions configures Anthropic provider behavior. +type ChatModelAnthropicProviderOptions struct { + SendReasoning *bool `json:"send_reasoning,omitempty"` + Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty"` + Effort *string `json:"effort,omitempty"` + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` +} + +// ChatModelGoogleThinkingConfig configures Google thinking behavior. +type ChatModelGoogleThinkingConfig struct { + ThinkingBudget *int64 `json:"thinking_budget,omitempty"` + IncludeThoughts *bool `json:"include_thoughts,omitempty"` +} + +// ChatModelGoogleSafetySetting configures Google safety filtering. +type ChatModelGoogleSafetySetting struct { + Category string `json:"category,omitempty"` + Threshold string `json:"threshold,omitempty"` +} + +// ChatModelGoogleProviderOptions configures Google provider behavior. +type ChatModelGoogleProviderOptions struct { + ThinkingConfig *ChatModelGoogleThinkingConfig `json:"thinking_config,omitempty"` + CachedContent string `json:"cached_content,omitempty"` + SafetySettings []ChatModelGoogleSafetySetting `json:"safety_settings,omitempty"` + Threshold string `json:"threshold,omitempty"` +} + +// ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. +type ChatModelOpenAICompatProviderOptions struct { + User *string `json:"user,omitempty"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` +} + +// ChatModelOpenRouterReasoningOptions configures OpenRouter reasoning behavior. +type ChatModelOpenRouterReasoningOptions struct { + Enabled *bool `json:"enabled,omitempty"` + Exclude *bool `json:"exclude,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitempty"` + Effort *string `json:"effort,omitempty"` +} + +// ChatModelOpenRouterProvider configures OpenRouter routing preferences. +type ChatModelOpenRouterProvider struct { + Order []string `json:"order,omitempty"` + AllowFallbacks *bool `json:"allow_fallbacks,omitempty"` + RequireParameters *bool `json:"require_parameters,omitempty"` + DataCollection *string `json:"data_collection,omitempty"` + Only []string `json:"only,omitempty"` + Ignore []string `json:"ignore,omitempty"` + Quantizations []string `json:"quantizations,omitempty"` + Sort *string `json:"sort,omitempty"` +} + +// ChatModelOpenRouterProviderOptions configures OpenRouter provider behavior. +type ChatModelOpenRouterProviderOptions struct { + Reasoning *ChatModelOpenRouterReasoningOptions `json:"reasoning,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` + IncludeUsage *bool `json:"include_usage,omitempty"` + LogitBias map[string]int64 `json:"logit_bias,omitempty"` + LogProbs *bool `json:"log_probs,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + User *string `json:"user,omitempty"` + Provider *ChatModelOpenRouterProvider `json:"provider,omitempty"` +} + +// ChatModelVercelReasoningOptions configures Vercel reasoning behavior. +type ChatModelVercelReasoningOptions struct { + Enabled *bool `json:"enabled,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitempty"` + Effort *string `json:"effort,omitempty"` + Exclude *bool `json:"exclude,omitempty"` +} + +// ChatModelVercelGatewayProviderOptions configures Vercel routing behavior. +type ChatModelVercelGatewayProviderOptions struct { + Order []string `json:"order,omitempty"` + Models []string `json:"models,omitempty"` +} + +// ChatModelVercelProviderOptions configures Vercel provider behavior. +type ChatModelVercelProviderOptions struct { + Reasoning *ChatModelVercelReasoningOptions `json:"reasoning,omitempty"` + ProviderOptions *ChatModelVercelGatewayProviderOptions `json:"providerOptions,omitempty"` + User *string `json:"user,omitempty"` + LogitBias map[string]int64 `json:"logit_bias,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int64 `json:"top_logprobs,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` +} + +// ChatModelCallConfig configures per-call model behavior defaults. +type ChatModelCallConfig struct { + MaxOutputTokens *int64 `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int64 `json:"top_k,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty"` +} + +// CreateChatModelConfigRequest creates a chat model config. +type CreateChatModelConfigRequest struct { + Provider string `json:"provider"` + Model string `json:"model"` + DisplayName string `json:"display_name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + IsDefault *bool `json:"is_default,omitempty"` + ContextLimit *int64 `json:"context_limit,omitempty"` + CompressionThreshold *int32 `json:"compression_threshold,omitempty"` + ModelConfig *ChatModelCallConfig `json:"model_config,omitempty"` +} + +// UpdateChatModelConfigRequest updates a chat model config. +type UpdateChatModelConfigRequest struct { + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + IsDefault *bool `json:"is_default,omitempty"` + ContextLimit *int64 `json:"context_limit,omitempty"` + CompressionThreshold *int32 `json:"compression_threshold,omitempty"` + ModelConfig *ChatModelCallConfig `json:"model_config,omitempty"` +} + +// ChatGitChange represents a git file change detected during a chat session. +type ChatGitChange struct { + ID uuid.UUID `json:"id" format:"uuid"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + FilePath string `json:"file_path"` + ChangeType string `json:"change_type"` // added, modified, deleted, renamed + OldPath *string `json:"old_path,omitempty"` + DiffSummary *string `json:"diff_summary,omitempty"` + DetectedAt time.Time `json:"detected_at" format:"date-time"` +} + +// ChatDiffStatus represents cached diff status for a chat. The URL +// may point to a pull request or a branch page depending on whether +// a PR has been opened. +type ChatDiffStatus struct { + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + URL *string `json:"url,omitempty"` + PullRequestState *string `json:"pull_request_state,omitempty"` + ChangesRequested bool `json:"changes_requested"` + Additions int32 `json:"additions"` + Deletions int32 `json:"deletions"` + ChangedFiles int32 `json:"changed_files"` + RefreshedAt *time.Time `json:"refreshed_at,omitempty" format:"date-time"` + StaleAt *time.Time `json:"stale_at,omitempty" format:"date-time"` +} + +// ChatDiffContents represents the resolved diff text for a chat. +type ChatDiffContents struct { + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + Provider *string `json:"provider,omitempty"` + RemoteOrigin *string `json:"remote_origin,omitempty"` + Branch *string `json:"branch,omitempty"` + PullRequestURL *string `json:"pull_request_url,omitempty"` + Diff string `json:"diff,omitempty"` +} + +// ChatStreamEventType represents the kind of chat stream update. +type ChatStreamEventType string + +const ( + ChatStreamEventTypeMessagePart ChatStreamEventType = "message_part" + ChatStreamEventTypeMessage ChatStreamEventType = "message" + ChatStreamEventTypeStatus ChatStreamEventType = "status" + ChatStreamEventTypeError ChatStreamEventType = "error" + ChatStreamEventTypeQueueUpdate ChatStreamEventType = "queue_update" +) + +// ChatQueuedMessage represents a queued message waiting to be processed. +type ChatQueuedMessage struct { + ID int64 `json:"id"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + Content []ChatMessagePart `json:"content"` + CreatedAt time.Time `json:"created_at" format:"date-time"` +} + +// ChatStreamMessagePart is a streamed message part update. +type ChatStreamMessagePart struct { + Role string `json:"role,omitempty"` + Part ChatMessagePart `json:"part"` +} + +// ChatStreamStatus represents an updated chat status. +type ChatStreamStatus struct { + Status ChatStatus `json:"status"` +} + +// ChatStreamError represents an error event in the stream. +type ChatStreamError struct { + Message string `json:"message"` +} + +// ChatStreamEvent represents a real-time update for chat streaming. +type ChatStreamEvent struct { + Type ChatStreamEventType `json:"type"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + Message *ChatMessage `json:"message,omitempty"` + MessagePart *ChatStreamMessagePart `json:"message_part,omitempty"` + Status *ChatStreamStatus `json:"status,omitempty"` + Error *ChatStreamError `json:"error,omitempty"` + QueuedMessages []ChatQueuedMessage `json:"queued_messages,omitempty"` +} + +type chatStreamEnvelope struct { + Type ServerSentEventType `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +// ListChats returns all chats for the authenticated user. +func (c *Client) ListChats(ctx context.Context) ([]Chat, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var chats []Chat + return chats, json.NewDecoder(res.Body).Decode(&chats) +} + +// ListChatModels returns the available chat model catalog. +func (c *Client) ListChatModels(ctx context.Context) (ChatModelsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/models", nil) + if err != nil { + return ChatModelsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatModelsResponse{}, ReadBodyAsError(res) + } + + var catalog ChatModelsResponse + return catalog, json.NewDecoder(res.Body).Decode(&catalog) +} + +// ListChatProviders returns admin-managed chat provider configs. +func (c *Client) ListChatProviders(ctx context.Context) ([]ChatProviderConfig, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/providers", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var providers []ChatProviderConfig + return providers, json.NewDecoder(res.Body).Decode(&providers) +} + +// CreateChatProvider creates an admin-managed chat provider config. +func (c *Client) CreateChatProvider(ctx context.Context, req CreateChatProviderConfigRequest) (ChatProviderConfig, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/providers", req) + if err != nil { + return ChatProviderConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return ChatProviderConfig{}, ReadBodyAsError(res) + } + + var provider ChatProviderConfig + return provider, json.NewDecoder(res.Body).Decode(&provider) +} + +// UpdateChatProvider updates an admin-managed chat provider config. +func (c *Client) UpdateChatProvider(ctx context.Context, providerID uuid.UUID, req UpdateChatProviderConfigRequest) (ChatProviderConfig, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/providers/%s", providerID), req) + if err != nil { + return ChatProviderConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatProviderConfig{}, ReadBodyAsError(res) + } + + var provider ChatProviderConfig + return provider, json.NewDecoder(res.Body).Decode(&provider) +} + +// DeleteChatProvider deletes an admin-managed chat provider config. +func (c *Client) DeleteChatProvider(ctx context.Context, providerID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/providers/%s", providerID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// ListChatModelConfigs returns admin-managed chat model configs. +func (c *Client) ListChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/model-configs", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var configs []ChatModelConfig + return configs, json.NewDecoder(res.Body).Decode(&configs) +} + +// CreateChatModelConfig creates an admin-managed chat model config. +func (c *Client) CreateChatModelConfig(ctx context.Context, req CreateChatModelConfigRequest) (ChatModelConfig, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/model-configs", req) + if err != nil { + return ChatModelConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return ChatModelConfig{}, ReadBodyAsError(res) + } + + var config ChatModelConfig + return config, json.NewDecoder(res.Body).Decode(&config) +} + +// UpdateChatModelConfig updates an admin-managed chat model config. +func (c *Client) UpdateChatModelConfig(ctx context.Context, modelConfigID uuid.UUID, req UpdateChatModelConfigRequest) (ChatModelConfig, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelConfigID), req) + if err != nil { + return ChatModelConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatModelConfig{}, ReadBodyAsError(res) + } + + var config ChatModelConfig + return config, json.NewDecoder(res.Body).Decode(&config) +} + +// DeleteChatModelConfig deletes an admin-managed chat model config. +func (c *Client) DeleteChatModelConfig(ctx context.Context, modelConfigID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/model-configs/%s", modelConfigID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// CreateChat creates a new chat. +func (c *Client) CreateChat(ctx context.Context, req CreateChatRequest) (Chat, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats", req) + if err != nil { + return Chat{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return Chat{}, ReadBodyAsError(res) + } + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +// StreamChat streams chat updates in real time. +// +// The returned channel includes initial snapshot events first, followed by +// live updates. Callers must close the returned io.Closer to release the +// websocket connection when done. +func (c *Client) StreamChat(ctx context.Context, chatID uuid.UUID) (<-chan ChatStreamEvent, io.Closer, error) { + conn, err := c.Dial( + ctx, + fmt.Sprintf("/api/experimental/chats/%s/stream", chatID), + &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled}, + ) + if err != nil { + return nil, nil, err + } + conn.SetReadLimit(1 << 22) // 4MiB + + streamCtx, streamCancel := context.WithCancel(ctx) + events := make(chan ChatStreamEvent, 128) + + send := func(event ChatStreamEvent) bool { + if event.ChatID == uuid.Nil { + event.ChatID = chatID + } + select { + case <-streamCtx.Done(): + return false + case events <- event: + return true + } + } + + go func() { + defer close(events) + defer streamCancel() + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "") + }() + + for { + var envelope chatStreamEnvelope + if err := wsjson.Read(streamCtx, conn, &envelope); err != nil { + if streamCtx.Err() != nil { + return + } + switch websocket.CloseStatus(err) { + case websocket.StatusNormalClosure, websocket.StatusGoingAway: + return + } + _ = send(ChatStreamEvent{ + Type: ChatStreamEventTypeError, + Error: &ChatStreamError{ + Message: fmt.Sprintf("read chat stream: %v", err), + }, + }) + return + } + + switch envelope.Type { + case ServerSentEventTypePing: + continue + case ServerSentEventTypeData: + var batch []ChatStreamEvent + decodeErr := json.Unmarshal(envelope.Data, &batch) + if decodeErr == nil { + for _, streamedEvent := range batch { + if !send(streamedEvent) { + return + } + } + continue + } + + { + _ = send(ChatStreamEvent{ + Type: ChatStreamEventTypeError, + Error: &ChatStreamError{ + Message: fmt.Sprintf( + "decode chat stream event batch: %v", + decodeErr, + ), + }, + }) + return + } + case ServerSentEventTypeError: + message := "chat stream returned an error" + if len(envelope.Data) > 0 { + var response Response + if err := json.Unmarshal(envelope.Data, &response); err == nil { + message = formatChatStreamResponseError(response) + } else { + trimmed := strings.TrimSpace(string(envelope.Data)) + if trimmed != "" { + message = trimmed + } + } + } + _ = send(ChatStreamEvent{ + Type: ChatStreamEventTypeError, + Error: &ChatStreamError{ + Message: message, + }, + }) + return + default: + _ = send(ChatStreamEvent{ + Type: ChatStreamEventTypeError, + Error: &ChatStreamError{ + Message: fmt.Sprintf("unknown chat stream event type %q", envelope.Type), + }, + }) + return + } + } + }() + + return events, closeFunc(func() error { + streamCancel() + return conn.Close(websocket.StatusNormalClosure, "") + }), nil +} + +// GetChat returns a chat by ID, including its messages. +func (c *Client) GetChat(ctx context.Context, chatID uuid.UUID) (ChatWithMessages, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s", chatID), nil) + if err != nil { + return ChatWithMessages{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatWithMessages{}, ReadBodyAsError(res) + } + var chat ChatWithMessages + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +// DeleteChat deletes a chat by ID. +func (c *Client) DeleteChat(ctx context.Context, chatID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/%s", chatID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// CreateChatMessage adds a message to a chat. +func (c *Client) CreateChatMessage(ctx context.Context, chatID uuid.UUID, req CreateChatMessageRequest) (CreateChatMessageResponse, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/messages", chatID), req) + if err != nil { + return CreateChatMessageResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return CreateChatMessageResponse{}, ReadBodyAsError(res) + } + var resp CreateChatMessageResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// EditChatMessage edits an existing user message in a chat and re-runs from there. +func (c *Client) EditChatMessage( + ctx context.Context, + chatID uuid.UUID, + messageID int64, + req EditChatMessageRequest, +) (ChatMessage, error) { + res, err := c.Request( + ctx, + http.MethodPatch, + fmt.Sprintf("/api/experimental/chats/%s/messages/%d", chatID, messageID), + req, + ) + if err != nil { + return ChatMessage{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatMessage{}, ReadBodyAsError(res) + } + var message ChatMessage + return message, json.NewDecoder(res.Body).Decode(&message) +} + +// InterruptChat cancels an in-flight chat run and leaves it waiting. +func (c *Client) InterruptChat(ctx context.Context, chatID uuid.UUID) (Chat, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/interrupt", chatID), nil) + if err != nil { + return Chat{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Chat{}, ReadBodyAsError(res) + } + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +// GetChatGitChanges returns git changes for a chat. +func (c *Client) GetChatGitChanges(ctx context.Context, chatID uuid.UUID) ([]ChatGitChange, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/git-changes", chatID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var changes []ChatGitChange + return changes, json.NewDecoder(res.Body).Decode(&changes) +} + +// GetChatDiffStatus returns cached GitHub pull request diff status for a chat. +func (c *Client) GetChatDiffStatus(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/diff-status", chatID), nil) + if err != nil { + return ChatDiffStatus{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatDiffStatus{}, ReadBodyAsError(res) + } + var status ChatDiffStatus + return status, json.NewDecoder(res.Body).Decode(&status) +} + +// GetChatDiffContents returns resolved diff contents for a chat. +func (c *Client) GetChatDiffContents(ctx context.Context, chatID uuid.UUID) (ChatDiffContents, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/diff", chatID), nil) + if err != nil { + return ChatDiffContents{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatDiffContents{}, ReadBodyAsError(res) + } + var diff ChatDiffContents + return diff, json.NewDecoder(res.Body).Decode(&diff) +} + +func formatChatStreamResponseError(response Response) string { + message := strings.TrimSpace(response.Message) + detail := strings.TrimSpace(response.Detail) + switch { + case message == "" && detail == "": + return "chat stream returned an error" + case message == "": + return detail + case detail == "": + return message + default: + return fmt.Sprintf("%s: %s", message, detail) + } +} diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go new file mode 100644 index 0000000000..7cc32e9683 --- /dev/null +++ b/codersdk/chats_test.go @@ -0,0 +1,53 @@ +package codersdk_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testing.T) { + t.Parallel() + + sendReasoning := true + effort := "high" + + raw, err := json.Marshal(codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ + SendReasoning: &sendReasoning, + Effort: &effort, + }, + }) + require.NoError(t, err) + require.NotContains(t, string(raw), `"type":"anthropic.options"`) + require.NotContains(t, string(raw), `"data":`) + require.Contains(t, string(raw), `"send_reasoning":true`) + require.Contains(t, string(raw), `"effort":"high"`) +} + +func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *testing.T) { + t.Parallel() + + raw := []byte(`{ + "anthropic": { + "send_reasoning": true, + "effort": "high" + } + }`) + + var decoded codersdk.ChatModelProviderOptions + err := json.Unmarshal(raw, &decoded) + require.NoError(t, err) + require.NotNil(t, decoded.Anthropic) + require.NotNil(t, decoded.Anthropic.SendReasoning) + require.True(t, *decoded.Anthropic.SendReasoning) + require.NotNil(t, decoded.Anthropic.Effort) + require.Equal( + t, + "high", + *decoded.Anthropic.Effort, + ) +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f249b5b060..e2c4bb65e7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -579,68 +579,69 @@ type DeploymentValues struct { DocsURL serpent.URL `json:"docs_url,omitempty"` RedirectToAccessURL serpent.Bool `json:"redirect_to_access_url,omitempty"` // HTTPAddress is a string because it may be set to zero to disable. - HTTPAddress serpent.String `json:"http_address,omitempty" typescript:",notnull"` - AutobuildPollInterval serpent.Duration `json:"autobuild_poll_interval,omitempty"` - JobReaperDetectorInterval serpent.Duration `json:"job_hang_detector_interval,omitempty"` - DERP DERP `json:"derp,omitempty" typescript:",notnull"` - Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` - Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` - ProxyTrustedHeaders serpent.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` - ProxyTrustedOrigins serpent.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` - CacheDir serpent.String `json:"cache_directory,omitempty" typescript:",notnull"` - EphemeralDeployment serpent.Bool `json:"ephemeral_deployment,omitempty" typescript:",notnull"` - PostgresURL serpent.String `json:"pg_connection_url,omitempty" typescript:",notnull"` - PostgresAuth string `json:"pg_auth,omitempty" typescript:",notnull"` - PostgresConnMaxOpen serpent.Int64 `json:"pg_conn_max_open,omitempty" typescript:",notnull"` - PostgresConnMaxIdle serpent.String `json:"pg_conn_max_idle,omitempty" typescript:",notnull"` - OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` - OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` - Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` - TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` - Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` - HTTPCookies HTTPCookieConfig `json:"http_cookies,omitempty" typescript:",notnull"` - StrictTransportSecurity serpent.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` - StrictTransportSecurityOptions serpent.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` - SSHKeygenAlgorithm serpent.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` - MetricsCacheRefreshInterval serpent.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` - AgentStatRefreshInterval serpent.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` - AgentFallbackTroubleshootingURL serpent.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` - BrowserOnly serpent.Bool `json:"browser_only,omitempty" typescript:",notnull"` - SCIMAPIKey serpent.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKeys serpent.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` - Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` - RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` - Experiments serpent.StringArray `json:"experiments,omitempty" typescript:",notnull"` - UpdateCheck serpent.Bool `json:"update_check,omitempty" typescript:",notnull"` - Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` - Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` - Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` - DisablePathApps serpent.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` - Sessions SessionLifetime `json:"session_lifetime,omitempty" typescript:",notnull"` - DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` - Support SupportConfig `json:"support,omitempty" typescript:",notnull"` - EnableAuthzRecording serpent.Bool `json:"enable_authz_recording,omitempty" typescript:",notnull"` - ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` - SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` - WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` - DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` - DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` - ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` - EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` - UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` - WebTerminalRenderer serpent.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"` - AllowWorkspaceRenames serpent.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"` - Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"` - Retention RetentionConfig `json:"retention,omitempty" typescript:",notnull"` - CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"` - TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"` - Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"` - AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"` - WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"` - Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"` - HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"` - AI AIConfig `json:"ai,omitempty"` - StatsCollection StatsCollectionConfig `json:"stats_collection,omitempty" typescript:",notnull"` + HTTPAddress serpent.String `json:"http_address,omitempty" typescript:",notnull"` + AutobuildPollInterval serpent.Duration `json:"autobuild_poll_interval,omitempty"` + JobReaperDetectorInterval serpent.Duration `json:"job_hang_detector_interval,omitempty"` + DERP DERP `json:"derp,omitempty" typescript:",notnull"` + Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` + Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` + ProxyTrustedHeaders serpent.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` + ProxyTrustedOrigins serpent.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` + CacheDir serpent.String `json:"cache_directory,omitempty" typescript:",notnull"` + EphemeralDeployment serpent.Bool `json:"ephemeral_deployment,omitempty" typescript:",notnull"` + PostgresURL serpent.String `json:"pg_connection_url,omitempty" typescript:",notnull"` + PostgresAuth string `json:"pg_auth,omitempty" typescript:",notnull"` + PostgresConnMaxOpen serpent.Int64 `json:"pg_conn_max_open,omitempty" typescript:",notnull"` + PostgresConnMaxIdle serpent.String `json:"pg_conn_max_idle,omitempty" typescript:",notnull"` + OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` + OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` + Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` + TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` + Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` + HTTPCookies HTTPCookieConfig `json:"http_cookies,omitempty" typescript:",notnull"` + StrictTransportSecurity serpent.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` + StrictTransportSecurityOptions serpent.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` + SSHKeygenAlgorithm serpent.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` + MetricsCacheRefreshInterval serpent.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` + AgentStatRefreshInterval serpent.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` + AgentFallbackTroubleshootingURL serpent.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` + BrowserOnly serpent.Bool `json:"browser_only,omitempty" typescript:",notnull"` + SCIMAPIKey serpent.String `json:"scim_api_key,omitempty" typescript:",notnull"` + ExternalTokenEncryptionKeys serpent.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` + Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` + RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` + Experiments serpent.StringArray `json:"experiments,omitempty" typescript:",notnull"` + UpdateCheck serpent.Bool `json:"update_check,omitempty" typescript:",notnull"` + Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` + Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` + Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` + DisablePathApps serpent.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` + Sessions SessionLifetime `json:"session_lifetime,omitempty" typescript:",notnull"` + DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` + Support SupportConfig `json:"support,omitempty" typescript:",notnull"` + EnableAuthzRecording serpent.Bool `json:"enable_authz_recording,omitempty" typescript:",notnull"` + ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` + ExternalAuthGithubDefaultProviderEnable serpent.Bool `json:"external_auth_github_default_provider_enable,omitempty" typescript:",notnull"` + SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` + WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` + DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` + DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` + ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` + EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` + WebTerminalRenderer serpent.String `json:"web_terminal_renderer,omitempty" typescript:",notnull"` + AllowWorkspaceRenames serpent.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"` + Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"` + Retention RetentionConfig `json:"retention,omitempty" typescript:",notnull"` + CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"` + TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"` + Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"` + AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"` + WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"` + Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"` + HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"` + AI AIConfig `json:"ai,omitempty"` + StatsCollection StatsCollectionConfig `json:"stats_collection,omitempty" typescript:",notnull"` Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -3153,6 +3154,15 @@ Write out the current server config as YAML to stdout.`, Value: &c.ExternalAuthConfigs, Hidden: true, }, + { + Name: "External Auth GitHub Default Provider Enable", + Description: "Enable the default GitHub external auth provider managed by Coder.", + Flag: "external-auth-github-default-provider-enable", + Env: "CODER_EXTERNAL_AUTH_GITHUB_DEFAULT_PROVIDER_ENABLE", + YAML: "externalAuthGithubDefaultProviderEnable", + Value: &c.ExternalAuthGithubDefaultProviderEnable, + Default: "true", + }, { Name: "Custom wgtunnel Host", Description: `Hostname of HTTPS server that runs https://github.com/coder/wgtunnel. By default, this will pick the best available wgtunnel server hosted by Coder. e.g. "tunnel.example.com".`, @@ -3583,7 +3593,6 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupClient, YAML: "hideAITasks", }, - // AI Bridge Options { Name: "AI Bridge Enabled", @@ -4264,6 +4273,7 @@ const ( ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentAgents Experiment = "agents" // Enables agent-powered chat functionality. ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. ) @@ -4281,6 +4291,8 @@ func (e Experiment) DisplayName() string { return "Browser Push Notifications" case ExperimentOAuth2: return "OAuth2 Provider Functionality" + case ExperimentAgents: + return "Agents" case ExperimentMCPServerHTTP: return "MCP HTTP Server Functionality" default: @@ -4299,6 +4311,7 @@ var ExperimentsKnown = Experiments{ ExperimentWorkspaceUsage, ExperimentWebPush, ExperimentOAuth2, + ExperimentAgents, ExperimentMCPServerHTTP, } @@ -4306,6 +4319,7 @@ var ExperimentsKnown = Experiments{ // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. +// TODO: Add ExperimentAgents to ExperimentsSafe once it is safe for general use. var ExperimentsSafe = Experiments{} // Experiments is a list of experiments. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 496b66f313..724e265de5 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -11,6 +11,7 @@ const ( ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" ResourceBoundaryUsage RBACResource = "boundary_usage" + ResourceChat RBACResource = "chat" ResourceConnectionLog RBACResource = "connection_log" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" @@ -82,6 +83,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate}, + ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceConnectionLog: {ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 3a87dd8bfc..012ff120d5 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -304,6 +304,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ } ] }, + "external_auth_github_default_provider_enable": true, "external_token_encryption_keys": [ "string" ], diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index f19fb41bf6..d697662a6a 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -172,10 +172,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -305,10 +305,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -438,10 +438,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -533,10 +533,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -909,9 +909,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8c194f406d..ab53b42ef6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -865,9 +865,9 @@ #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -2805,6 +2805,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o } ] }, + "external_auth_github_default_provider_enable": true, "external_token_encryption_keys": [ "string" ], @@ -3373,6 +3374,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o } ] }, + "external_auth_github_default_provider_enable": true, "external_token_encryption_keys": [ "string" ], @@ -3682,78 +3684,79 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------------------|------------------------------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------| -| `access_url` | [serpent.URL](#serpenturl) | false | | | -| `additional_csp_policy` | array of string | false | | | -| `address` | [serpent.HostPort](#serpenthostport) | false | | Deprecated: Use HTTPAddress or TLS.Address instead. | -| `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | -| `agent_stat_refresh_interval` | integer | false | | | -| `ai` | [codersdk.AIConfig](#codersdkaiconfig) | false | | | -| `allow_workspace_renames` | boolean | false | | | -| `autobuild_poll_interval` | integer | false | | | -| `browser_only` | boolean | false | | | -| `cache_directory` | string | false | | | -| `cli_upgrade_message` | string | false | | | -| `config` | string | false | | | -| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | -| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | -| `derp` | [codersdk.DERP](#codersdkderp) | false | | | -| `disable_owner_workspace_exec` | boolean | false | | | -| `disable_password_auth` | boolean | false | | | -| `disable_path_apps` | boolean | false | | | -| `disable_workspace_sharing` | boolean | false | | | -| `docs_url` | [serpent.URL](#serpenturl) | false | | | -| `enable_authz_recording` | boolean | false | | | -| `enable_terraform_debug_mode` | boolean | false | | | -| `ephemeral_deployment` | boolean | false | | | -| `experiments` | array of string | false | | | -| `external_auth` | [serpent.Struct-array_codersdk_ExternalAuthConfig](#serpentstruct-array_codersdk_externalauthconfig) | false | | | -| `external_token_encryption_keys` | array of string | false | | | -| `healthcheck` | [codersdk.HealthcheckConfig](#codersdkhealthcheckconfig) | false | | | -| `hide_ai_tasks` | boolean | false | | | -| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | -| `http_cookies` | [codersdk.HTTPCookieConfig](#codersdkhttpcookieconfig) | false | | | -| `job_hang_detector_interval` | integer | false | | | -| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | -| `metrics_cache_refresh_interval` | integer | false | | | -| `notifications` | [codersdk.NotificationsConfig](#codersdknotificationsconfig) | false | | | -| `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | -| `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | -| `pg_auth` | string | false | | | -| `pg_conn_max_idle` | string | false | | | -| `pg_conn_max_open` | integer | false | | | -| `pg_connection_url` | string | false | | | -| `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | | -| `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | | -| `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | | -| `proxy_health_status_interval` | integer | false | | | -| `proxy_trusted_headers` | array of string | false | | | -| `proxy_trusted_origins` | array of string | false | | | -| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | | -| `redirect_to_access_url` | boolean | false | | | -| `retention` | [codersdk.RetentionConfig](#codersdkretentionconfig) | false | | | -| `scim_api_key` | string | false | | | -| `session_lifetime` | [codersdk.SessionLifetime](#codersdksessionlifetime) | false | | | -| `ssh_keygen_algorithm` | string | false | | | -| `stats_collection` | [codersdk.StatsCollectionConfig](#codersdkstatscollectionconfig) | false | | | -| `strict_transport_security` | integer | false | | | -| `strict_transport_security_options` | array of string | false | | | -| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | -| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | -| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | -| `terms_of_service_url` | string | false | | | -| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | -| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | -| `update_check` | boolean | false | | | -| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | -| `verbose` | boolean | false | | | -| `web_terminal_renderer` | string | false | | | -| `wgtunnel_host` | string | false | | | -| `wildcard_access_url` | string | false | | | -| `workspace_hostname_suffix` | string | false | | | -| `workspace_prebuilds` | [codersdk.PrebuildsConfig](#codersdkprebuildsconfig) | false | | | -| `write_config` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------------------------------|------------------------------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------| +| `access_url` | [serpent.URL](#serpenturl) | false | | | +| `additional_csp_policy` | array of string | false | | | +| `address` | [serpent.HostPort](#serpenthostport) | false | | Deprecated: Use HTTPAddress or TLS.Address instead. | +| `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | +| `agent_stat_refresh_interval` | integer | false | | | +| `ai` | [codersdk.AIConfig](#codersdkaiconfig) | false | | | +| `allow_workspace_renames` | boolean | false | | | +| `autobuild_poll_interval` | integer | false | | | +| `browser_only` | boolean | false | | | +| `cache_directory` | string | false | | | +| `cli_upgrade_message` | string | false | | | +| `config` | string | false | | | +| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | +| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | +| `derp` | [codersdk.DERP](#codersdkderp) | false | | | +| `disable_owner_workspace_exec` | boolean | false | | | +| `disable_password_auth` | boolean | false | | | +| `disable_path_apps` | boolean | false | | | +| `disable_workspace_sharing` | boolean | false | | | +| `docs_url` | [serpent.URL](#serpenturl) | false | | | +| `enable_authz_recording` | boolean | false | | | +| `enable_terraform_debug_mode` | boolean | false | | | +| `ephemeral_deployment` | boolean | false | | | +| `experiments` | array of string | false | | | +| `external_auth` | [serpent.Struct-array_codersdk_ExternalAuthConfig](#serpentstruct-array_codersdk_externalauthconfig) | false | | | +| `external_auth_github_default_provider_enable` | boolean | false | | | +| `external_token_encryption_keys` | array of string | false | | | +| `healthcheck` | [codersdk.HealthcheckConfig](#codersdkhealthcheckconfig) | false | | | +| `hide_ai_tasks` | boolean | false | | | +| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | +| `http_cookies` | [codersdk.HTTPCookieConfig](#codersdkhttpcookieconfig) | false | | | +| `job_hang_detector_interval` | integer | false | | | +| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | +| `metrics_cache_refresh_interval` | integer | false | | | +| `notifications` | [codersdk.NotificationsConfig](#codersdknotificationsconfig) | false | | | +| `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | +| `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | +| `pg_auth` | string | false | | | +| `pg_conn_max_idle` | string | false | | | +| `pg_conn_max_open` | integer | false | | | +| `pg_connection_url` | string | false | | | +| `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | | +| `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | | +| `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | | +| `proxy_health_status_interval` | integer | false | | | +| `proxy_trusted_headers` | array of string | false | | | +| `proxy_trusted_origins` | array of string | false | | | +| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | | +| `redirect_to_access_url` | boolean | false | | | +| `retention` | [codersdk.RetentionConfig](#codersdkretentionconfig) | false | | | +| `scim_api_key` | string | false | | | +| `session_lifetime` | [codersdk.SessionLifetime](#codersdksessionlifetime) | false | | | +| `ssh_keygen_algorithm` | string | false | | | +| `stats_collection` | [codersdk.StatsCollectionConfig](#codersdkstatscollectionconfig) | false | | | +| `strict_transport_security` | integer | false | | | +| `strict_transport_security_options` | array of string | false | | | +| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | +| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | +| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | +| `terms_of_service_url` | string | false | | | +| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | +| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | +| `update_check` | boolean | false | | | +| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | +| `verbose` | boolean | false | | | +| `web_terminal_renderer` | string | false | | | +| `wgtunnel_host` | string | false | | | +| `wildcard_access_url` | string | false | | | +| `workspace_hostname_suffix` | string | false | | | +| `workspace_prebuilds` | [codersdk.PrebuildsConfig](#codersdkprebuildsconfig) | false | | | +| `write_config` | boolean | false | | | ## codersdk.DiagnosticExtra @@ -3981,9 +3984,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------| -| `auto-fill-parameters`, `example`, `mcp-server-http`, `notifications`, `oauth2`, `web-push`, `workspace-usage` | +| Value(s) | +|--------------------------------------------------------------------------------------------------------------------------| +| `agents`, `auto-fill-parameters`, `example`, `mcp-server-http`, `notifications`, `oauth2`, `web-push`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes @@ -7322,9 +7325,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 3788ae15dd..88523b96a3 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -811,11 +811,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index dee0b48423..52e202f5c2 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1258,6 +1258,17 @@ The upgrade message to display to users when a client/server mismatch is detecte Support links to display in the top right drop down menu. +### --external-auth-github-default-provider-enable + +| | | +|-------------|------------------------------------------------------------------| +| Type | bool | +| Environment | $CODER_EXTERNAL_AUTH_GITHUB_DEFAULT_PROVIDER_ENABLE | +| YAML | externalAuthGithubDefaultProviderEnable | +| Default | true | + +Enable the default GitHub external auth provider managed by Coder. + ### --proxy-health-interval | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index cb9d69c65a..7656a6436a 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -63,6 +63,9 @@ OPTIONS: Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments. + --external-auth-github-default-provider-enable bool, $CODER_EXTERNAL_AUTH_GITHUB_DEFAULT_PROVIDER_ENABLE (default: true) + Enable the default GitHub external auth provider managed by Coder. + --postgres-auth password|awsiamrds, $CODER_PG_AUTH (default: password) Type of auth to use when connecting to postgres. For AWS RDS, using IAM authentication (awsiamrds) is recommended. diff --git a/enterprise/coderd/chats.go b/enterprise/coderd/chats.go new file mode 100644 index 0000000000..b919d0188c --- /dev/null +++ b/enterprise/coderd/chats.go @@ -0,0 +1,172 @@ +package coderd + +import ( + "context" + "net/http" + "net/url" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/chatd" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/websocket" +) + +// RelaySourceHeader marks replica-relayed stream requests. +const RelaySourceHeader = "X-Coder-Relay-Source-Replica" + +const ( + authorizationHeader = "Authorization" + cookieHeader = "Cookie" +) + +// newRemotePartsProvider creates a RemotePartsProvider that dials a remote +// replica's stream endpoint to fetch message_part events. It filters to only +// forward message_part events since durable events come via pubsub. +func newRemotePartsProvider( + resolveReplicaAddress func(context.Context, uuid.UUID) (string, bool), + replicaHTTPClient *http.Client, + replicaID uuid.UUID, +) chatd.RemotePartsProvider { + return func( + ctx context.Context, + chatID uuid.UUID, + workerID uuid.UUID, + requestHeader http.Header, + ) ( + []codersdk.ChatStreamEvent, + <-chan codersdk.ChatStreamEvent, + func(), + error, + ) { + address, ok := resolveReplicaAddress(ctx, workerID) + if !ok { + return nil, nil, nil, xerrors.New("worker replica not found") + } + + baseURL, err := url.Parse(address) + if err != nil { + return nil, nil, nil, xerrors.Errorf("parse relay address %q: %w", address, err) + } + relayCtx, relayCancel := context.WithCancel(ctx) + sdkClient := codersdk.New(baseURL) + sdkClient.HTTPClient = replicaHTTPClient + sdkClient.SessionTokenProvider = relayHeaderTokenProvider{ + header: relayHeaders(requestHeader, replicaID), + } + sourceEvents, sourceStream, err := sdkClient.StreamChat(relayCtx, chatID) + if err != nil { + relayCancel() + return nil, nil, nil, xerrors.Errorf("dial relay stream: %w", err) + } + + snapshot := make([]codersdk.ChatStreamEvent, 0, 100) + preloaded := make([]codersdk.ChatStreamEvent, 0, 100) + drainInitial: + for len(snapshot) < cap(snapshot) { + select { + case <-relayCtx.Done(): + _ = sourceStream.Close() + relayCancel() + return nil, nil, nil, xerrors.Errorf("dial relay stream: %w", relayCtx.Err()) + case event, ok := <-sourceEvents: + if !ok { + break drainInitial + } + if event.Type != codersdk.ChatStreamEventTypeMessagePart { + continue + } + snapshot = append(snapshot, event) + preloaded = append(preloaded, event) + default: + break drainInitial + } + } + + events := make(chan codersdk.ChatStreamEvent, 128) + + go func() { + defer close(events) + defer relayCancel() + defer func() { + _ = sourceStream.Close() + }() + + for _, event := range preloaded { + select { + case events <- event: + case <-relayCtx.Done(): + return + } + } + + for { + select { + case <-relayCtx.Done(): + return + case event, ok := <-sourceEvents: + if !ok { + return + } + if event.Type != codersdk.ChatStreamEventTypeMessagePart { + continue + } + select { + case events <- event: + case <-relayCtx.Done(): + return + } + } + } + }() + + cancel := func() { + relayCancel() + _ = sourceStream.Close() + } + return snapshot, events, cancel, nil + } +} + +type relayHeaderTokenProvider struct { + header http.Header +} + +func (p relayHeaderTokenProvider) AsRequestOption() codersdk.RequestOption { + return func(req *http.Request) { + for key, values := range p.header { + for _, value := range values { + req.Header.Add(key, value) + } + } + } +} + +func (p relayHeaderTokenProvider) SetDialOption(opts *websocket.DialOptions) { + if opts.HTTPHeader == nil { + opts.HTTPHeader = make(http.Header) + } + for key, values := range p.header { + for _, value := range values { + opts.HTTPHeader.Add(key, value) + } + } +} + +func (p relayHeaderTokenProvider) GetSessionToken() string { + return p.header.Get(codersdk.SessionTokenHeader) +} + +func relayHeaders(source http.Header, replicaID uuid.UUID) http.Header { + header := make(http.Header) + if source != nil { + for _, key := range []string{codersdk.SessionTokenHeader, authorizationHeader, cookieHeader} { + for _, value := range source.Values(key) { + header.Add(key, value) + } + } + } + header.Set(RelaySourceHeader, replicaID.String()) + return header +} diff --git a/enterprise/coderd/chats_test.go b/enterprise/coderd/chats_test.go new file mode 100644 index 0000000000..628c289dd7 --- /dev/null +++ b/enterprise/coderd/chats_test.go @@ -0,0 +1,355 @@ +package coderd_test + +import ( + "context" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/chatd/chattest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestChatStreamRelay(t *testing.T) { + t.Parallel() + + t.Run("RelayMessagePartsAcrossReplicas", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + db, pubsub := dbtestutil.NewDB(t) + firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureHighAvailability: 1, + }, + }, + }) + + secondClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + DontAddLicense: true, + DontAddFirstUser: true, + }) + secondClient.SetSessionToken(firstClient.SessionToken()) + + // Verify we have two replicas + replicas, err := secondClient.Replicas(ctx) + require.NoError(t, err) + require.Len(t, replicas, 2) + firstReplicaID := replicaIDForClientURL(t, firstClient.URL, replicas) + secondReplicaID := replicaIDForClientURL(t, secondClient.URL, replicas) + + streamingChunks := make(chan chattest.OpenAIChunk, 8) + chatStreamStarted := make(chan struct{}, 1) + openai := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if req.Stream { + select { + case chatStreamStarted <- struct{}{}: + default: + } + return chattest.OpenAIResponse{StreamingChunks: streamingChunks} + } + return chattest.OpenAINonStreamingResponse("ok") + }) + + //nolint:gocritic // Test uses owner client to configure chat providers. + provider, err := firstClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test", + BaseURL: openai, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, provider.Source) + + model, err := firstClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: provider.Provider, + Model: "gpt-4", + DisplayName: "GPT-4", + ContextLimit: &[]int64{1000}[0], + CompressionThreshold: &[]int32{70}[0], + }) + require.NoError(t, err) + + // Create a chat on the first replica + chat, err := firstClient.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "Test chat for relay", + }}, + ModelConfigID: &model.ID, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusPending, chat.Status) + + var runningChat database.Chat + require.Eventually(t, func() bool { + current, getErr := db.GetChatByID(ctx, chat.ID) + if getErr != nil { + return false + } + if current.Status != database.ChatStatusRunning || !current.WorkerID.Valid { + return false + } + runningChat = current + return true + }, testutil.WaitLong, testutil.IntervalFast) + + var localClient *codersdk.Client + var relayClient *codersdk.Client + switch runningChat.WorkerID.UUID { + case firstReplicaID: + localClient = firstClient + relayClient = secondClient + case secondReplicaID: + localClient = secondClient + relayClient = firstClient + default: + require.FailNowf( + t, + "worker replica was not recognized", + "worker %s was not one of %s or %s", + runningChat.WorkerID.UUID, + firstReplicaID, + secondReplicaID, + ) + } + + firstEvents, firstStream, err := localClient.StreamChat(ctx, chat.ID) + require.NoError(t, err) + defer firstStream.Close() + + select { + case <-chatStreamStarted: + case <-ctx.Done(): + require.FailNowf( + t, + "timed out waiting for OpenAI stream request", + "chat stream request did not start before context deadline: %v", + ctx.Err(), + ) + } + + firstChunkText := "relay-part-one" + streamingChunks <- chattest.OpenAITextChunks(firstChunkText)[0] + firstEvent := waitForStreamTextPart(ctx, t, firstEvents, firstChunkText) + require.Equal(t, "assistant", firstEvent.MessagePart.Role) + + secondEvents, secondStream, err := relayClient.StreamChat(ctx, chat.ID) + require.NoError(t, err) + defer secondStream.Close() + + secondSnapshotEvent := waitForStreamTextPart(ctx, t, secondEvents, firstChunkText) + require.Equal(t, "assistant", secondSnapshotEvent.MessagePart.Role) + + secondChunkText := "relay-part-two" + streamingChunks <- chattest.OpenAITextChunks(secondChunkText)[0] + waitForStreamTextPart(ctx, t, firstEvents, secondChunkText) + waitForStreamTextPart(ctx, t, secondEvents, secondChunkText) + + close(streamingChunks) + }) +} + +func waitForStreamTextPart( + ctx context.Context, + t *testing.T, + events <-chan codersdk.ChatStreamEvent, + expectedText string, +) codersdk.ChatStreamEvent { + t.Helper() + + for { + select { + case <-ctx.Done(): + require.FailNowf( + t, + "timed out waiting for chat stream event", + "expected text part %q before context deadline: %v", + expectedText, + ctx.Err(), + ) + case event, ok := <-events: + require.Truef(t, ok, "chat stream closed while waiting for %q", expectedText) + + if event.Type == codersdk.ChatStreamEventTypeError { + errMessage := "unknown chat stream error" + if event.Error != nil && event.Error.Message != "" { + errMessage = event.Error.Message + } + require.FailNowf( + t, + "chat stream returned error event", + "while waiting for %q: %s", + expectedText, + errMessage, + ) + } + + if event.Type != codersdk.ChatStreamEventTypeMessagePart || event.MessagePart == nil { + continue + } + if event.MessagePart.Part.Type != codersdk.ChatMessagePartTypeText { + continue + } + + require.Equal(t, expectedText, event.MessagePart.Part.Text) + return event + } + } +} + +func replicaIDForClientURL( + t *testing.T, + clientURL *url.URL, + replicas []codersdk.Replica, +) uuid.UUID { + t.Helper() + + for _, replica := range replicas { + relayURL, err := url.Parse(replica.RelayAddress) + require.NoErrorf( + t, + err, + "parse replica relay address %q", + replica.RelayAddress, + ) + if relayURL.Host == clientURL.Host { + return replica.ID + } + } + + require.FailNowf( + t, + "missing replica for client URL", + "client host %q not present in replica list", + clientURL.Host, + ) + return uuid.Nil +} + +func TestChatModelConfigDefault(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, _ := coderdenttest.New(t, nil) + + //nolint:gocritic // Test uses owner client to configure chat providers. + provider, err := client.CreateChatProvider( + ctx, + codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test", + BaseURL: "https://example.com", + }, + ) + require.NoError(t, err) + + contextLimit := int64(1000) + compressionThreshold := int32(70) + trueValue := true + falseValue := false + + firstModel, err := client.CreateChatModelConfig( + ctx, + codersdk.CreateChatModelConfigRequest{ + Provider: provider.Provider, + Model: "gpt-5-a", + DisplayName: "GPT 5 A", + IsDefault: &trueValue, + ContextLimit: &contextLimit, + CompressionThreshold: &compressionThreshold, + }, + ) + require.NoError(t, err) + require.True(t, firstModel.IsDefault) + + secondModel, err := client.CreateChatModelConfig( + ctx, + codersdk.CreateChatModelConfigRequest{ + Provider: provider.Provider, + Model: "gpt-5-b", + DisplayName: "GPT 5 B", + IsDefault: &trueValue, + ContextLimit: &contextLimit, + CompressionThreshold: &compressionThreshold, + }, + ) + require.NoError(t, err) + require.True(t, secondModel.IsDefault) + + modelConfigs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + firstStored := findChatModelConfigByID(t, modelConfigs, firstModel.ID) + secondStored := findChatModelConfigByID(t, modelConfigs, secondModel.ID) + require.False(t, firstStored.IsDefault) + require.True(t, secondStored.IsDefault) + + updatedFirst, err := client.UpdateChatModelConfig( + ctx, + firstModel.ID, + codersdk.UpdateChatModelConfigRequest{ + IsDefault: &trueValue, + }, + ) + require.NoError(t, err) + require.True(t, updatedFirst.IsDefault) + + modelConfigs, err = client.ListChatModelConfigs(ctx) + require.NoError(t, err) + firstStored = findChatModelConfigByID(t, modelConfigs, firstModel.ID) + secondStored = findChatModelConfigByID(t, modelConfigs, secondModel.ID) + require.True(t, firstStored.IsDefault) + require.False(t, secondStored.IsDefault) + + updatedFirst, err = client.UpdateChatModelConfig( + ctx, + firstModel.ID, + codersdk.UpdateChatModelConfigRequest{ + IsDefault: &falseValue, + }, + ) + require.NoError(t, err) + require.False(t, updatedFirst.IsDefault) + + modelConfigs, err = client.ListChatModelConfigs(ctx) + require.NoError(t, err) + firstStored = findChatModelConfigByID(t, modelConfigs, firstModel.ID) + secondStored = findChatModelConfigByID(t, modelConfigs, secondModel.ID) + require.False(t, firstStored.IsDefault) + require.True(t, secondStored.IsDefault) +} + +func findChatModelConfigByID( + t *testing.T, + modelConfigs []codersdk.ChatModelConfig, + id uuid.UUID, +) codersdk.ChatModelConfig { + t.Helper() + + for _, modelConfig := range modelConfigs { + if modelConfig.ID == id { + return modelConfig + } + } + + require.FailNowf(t, "missing model config", "model config %s not found", id) + return codersdk.ChatModelConfig{} +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index a897711597..e0ee96b3b1 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -3,6 +3,7 @@ package coderd import ( "context" "crypto/ed25519" + "crypto/tls" "fmt" "math" "net/http" @@ -15,6 +16,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "tailscale.com/tailcfg" @@ -100,6 +102,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } ctx, cancelFunc := context.WithCancel(ctx) + defer func() { + if err != nil { + cancelFunc() + } + }() if options.ExternalTokenEncryption == nil { options.ExternalTokenEncryption = make([]dbcrypt.Cipher, 0) @@ -141,6 +148,33 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) } + meshTLSConfig, err := replicasync.CreateDERPMeshTLSConfig(options.AccessURL.Hostname(), options.TLSCertificates) + if err != nil { + return nil, xerrors.Errorf("create DERP mesh TLS config: %w", err) + } + + var replicaManagerPtr atomic.Pointer[replicasync.Manager] + resolveReplicaAddress := func( + _ context.Context, + replicaID uuid.UUID, + ) (string, bool) { + manager := replicaManagerPtr.Load() + if manager == nil { + return "", false + } + for _, replica := range manager.AllPrimary() { + if replica.ID != replicaID { + continue + } + relayAddress := strings.TrimSpace(replica.RelayAddress) + if relayAddress == "" { + return "", false + } + return relayAddress, true + } + return "", false + } + api := &API{ ctx: ctx, cancel: cancelFunc, @@ -156,6 +190,44 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } // This must happen before coderd initialization! options.PostAuthAdditionalHeadersFunc = api.writeEntitlementWarningsHeader + + // Wire up enterprise chat relay for cross-replica message_part streaming. + // Must be set before coderd.New so the chat processor gets it. + replicaHTTPClient := replicaRelayHTTPClient(options.HTTPClient, meshTLSConfig) + if replicaHTTPClient == nil { + replicaHTTPClient = options.Options.HTTPClient + } + if replicaHTTPClient == nil { + replicaHTTPClient = http.DefaultClient + } + // Use a closure that captures api by reference so it can access api.AGPL.ID + // after coderd.New is called. The provider is only invoked when Subscribe + // is called, which happens after initialization, so api.AGPL will be set. + options.Options.ChatRemotePartsProvider = func( + ctx context.Context, + chatID uuid.UUID, + workerID uuid.UUID, + requestHeader http.Header, + ) ( + []codersdk.ChatStreamEvent, + <-chan codersdk.ChatStreamEvent, + func(), + error, + ) { + // Get the replica ID from the API (will be set after coderd.New) + replicaID := api.AGPL.ID + if replicaID == uuid.Nil { + // Fallback if somehow called before initialization + replicaID = uuid.New() + } + provider := newRemotePartsProvider( + resolveReplicaAddress, + replicaHTTPClient, + replicaID, + ) + return provider(ctx, chatID, workerID, requestHeader) + } + api.AGPL = coderd.New(options.Options) defer func() { if err != nil { @@ -583,10 +655,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }))) } - meshTLSConfig, err := replicasync.CreateDERPMeshTLSConfig(options.AccessURL.Hostname(), options.TLSCertificates) - if err != nil { - return nil, xerrors.Errorf("create DERP mesh TLS config: %w", err) - } // We always want to run the replica manager even if we don't have DERP // enabled, since it's used to detect other coder servers for licensing. api.replicaManager, err = replicasync.New(ctx, options.Logger, options.Database, options.Pubsub, &replicasync.Options{ @@ -600,6 +668,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if err != nil { return nil, xerrors.Errorf("initialize replica: %w", err) } + replicaManagerPtr.Store(api.replicaManager) if api.DERPServer != nil { api.derpMesh = derpmesh.New(options.Logger.Named("derpmesh"), api.DERPServer, meshTLSConfig) } @@ -651,6 +720,28 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { return api, nil } +func replicaRelayHTTPClient(base *http.Client, tlsConfig *tls.Config) *http.Client { + if base == nil { + base = http.DefaultClient + } + + clone := *base + var transport *http.Transport + switch t := base.Transport.(type) { + case *http.Transport: + transport = t.Clone() + default: + if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { + transport = defaultTransport.Clone() + } else { + transport = &http.Transport{} + } + } + transport.TLSClientConfig = tlsConfig + clone.Transport = transport + return &clone +} + type Options struct { *coderd.Options diff --git a/enterprise/dbcrypt/cliutil.go b/enterprise/dbcrypt/cliutil.go index 8bee1c8947..c435bb1b6c 100644 --- a/enterprise/dbcrypt/cliutil.go +++ b/enterprise/dbcrypt/cliutil.go @@ -3,6 +3,7 @@ package dbcrypt import ( "context" "database/sql" + "strings" "golang.org/x/xerrors" @@ -82,6 +83,32 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe log.Debug(ctx, "encrypted user tokens", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) } + providers, err := cryptDB.GetChatProviders(ctx) + if err != nil { + return xerrors.Errorf("get chat providers: %w", err) + } + log.Info(ctx, "encrypting chat provider keys", slog.F("provider_count", len(providers))) + for idx, provider := range providers { + if strings.TrimSpace(provider.APIKey) == "" { + continue + } + if provider.ApiKeyKeyID.Valid && provider.ApiKeyKeyID.String == ciphers[0].HexDigest() { + log.Debug(ctx, "skipping chat provider", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + continue + } + if _, err := cryptDB.UpdateChatProvider(ctx, database.UpdateChatProviderParams{ + DisplayName: provider.DisplayName, + APIKey: provider.APIKey, + BaseUrl: provider.BaseUrl, + ApiKeyKeyID: sql.NullString{}, // dbcrypt will update as required + Enabled: provider.Enabled, + ID: provider.ID, + }); err != nil { + return xerrors.Errorf("update chat provider id=%s provider=%s: %w", provider.ID, provider.Provider, err) + } + log.Debug(ctx, "encrypted chat provider key", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + } + // Revoke old keys for _, c := range ciphers[1:] { if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil { @@ -172,6 +199,28 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph log.Debug(ctx, "decrypted user tokens", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) } + providers, err := cryptDB.GetChatProviders(ctx) + if err != nil { + return xerrors.Errorf("get chat providers: %w", err) + } + log.Info(ctx, "decrypting chat provider keys", slog.F("provider_count", len(providers))) + for idx, provider := range providers { + if !provider.ApiKeyKeyID.Valid { + continue + } + if _, err := cryptDB.UpdateChatProvider(ctx, database.UpdateChatProviderParams{ + DisplayName: provider.DisplayName, + APIKey: provider.APIKey, + BaseUrl: provider.BaseUrl, + ApiKeyKeyID: sql.NullString{}, // we explicitly want to clear the key id + Enabled: provider.Enabled, + ID: provider.ID, + }); err != nil { + return xerrors.Errorf("update chat provider id=%s provider=%s: %w", provider.ID, provider.Provider, err) + } + log.Debug(ctx, "decrypted chat provider key", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + } + // Revoke _all_ keys for _, c := range ciphers { if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil { @@ -192,6 +241,10 @@ DELETE FROM user_links DELETE FROM external_auth_links WHERE oauth_access_token_key_id IS NOT NULL OR oauth_refresh_token_key_id IS NOT NULL; +UPDATE chat_providers + SET api_key = '', + api_key_key_id = NULL + WHERE api_key_key_id IS NOT NULL; COMMIT; ` @@ -203,9 +256,9 @@ func Delete(ctx context.Context, log slog.Logger, sqlDB *sql.DB) error { store := database.New(sqlDB) _, err := sqlDB.ExecContext(ctx, sqlDeleteEncryptedUserTokens) if err != nil { - return xerrors.Errorf("delete user links: %w", err) + return xerrors.Errorf("delete encrypted tokens and chat provider keys: %w", err) } - log.Info(ctx, "deleted encrypted user tokens") + log.Info(ctx, "deleted encrypted user tokens and chat provider API keys") log.Info(ctx, "revoking all active keys") keys, err := store.GetDBCryptKeys(ctx) diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 08136122ad..45f4132efa 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/base64" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" @@ -351,6 +352,92 @@ func (db *dbCrypt) GetCryptoKeysByFeature(ctx context.Context, feature database. return keys, nil } +func (db *dbCrypt) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) { + provider, err := db.Store.GetChatProviderByID(ctx, id) + if err != nil { + return database.ChatProvider{}, err + } + if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + return provider, nil +} + +func (db *dbCrypt) GetChatProviderByProvider(ctx context.Context, providerName string) (database.ChatProvider, error) { + provider, err := db.Store.GetChatProviderByProvider(ctx, providerName) + if err != nil { + return database.ChatProvider{}, err + } + if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + return provider, nil +} + +func (db *dbCrypt) GetChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + providers, err := db.Store.GetChatProviders(ctx) + if err != nil { + return nil, err + } + + for i := range providers { + if err := db.decryptField(&providers[i].APIKey, providers[i].ApiKeyKeyID); err != nil { + return nil, err + } + } + + return providers, nil +} + +func (db *dbCrypt) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) { + providers, err := db.Store.GetEnabledChatProviders(ctx) + if err != nil { + return nil, err + } + + for i := range providers { + if err := db.decryptField(&providers[i].APIKey, providers[i].ApiKeyKeyID); err != nil { + return nil, err + } + } + + return providers, nil +} + +func (db *dbCrypt) InsertChatProvider(ctx context.Context, params database.InsertChatProviderParams) (database.ChatProvider, error) { + if strings.TrimSpace(params.APIKey) == "" { + params.ApiKeyKeyID = sql.NullString{} + } else if err := db.encryptField(¶ms.APIKey, ¶ms.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + + provider, err := db.Store.InsertChatProvider(ctx, params) + if err != nil { + return database.ChatProvider{}, err + } + if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + return provider, nil +} + +func (db *dbCrypt) UpdateChatProvider(ctx context.Context, params database.UpdateChatProviderParams) (database.ChatProvider, error) { + if strings.TrimSpace(params.APIKey) == "" { + params.ApiKeyKeyID = sql.NullString{} + } else if err := db.encryptField(¶ms.APIKey, ¶ms.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + + provider, err := db.Store.UpdateChatProvider(ctx, params) + if err != nil { + return database.ChatProvider{}, err + } + if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil { + return database.ChatProvider{}, err + } + return provider, nil +} + func (db *dbCrypt) encryptField(field *string, digest *sql.NullString) error { // If no cipher is loaded, then we can't encrypt anything! if db.ciphers == nil || db.primaryCipherDigest == "" { diff --git a/flake.lock b/flake.lock index edb080a06d..dea5417e7e 100644 --- a/flake.lock +++ b/flake.lock @@ -76,11 +76,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1758035966, - "narHash": "sha256-qqIJ3yxPiB0ZQTT9//nFGQYn8X/PBoJbofA7hRKZnmE=", + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "owner": "nixos", "repo": "nixpkgs", - "rev": "8d4ddb19d03c65a36ad8d189d001dc32ffb0306b", + "rev": "0182a361324364ae3f436a63005877674cf45efb", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 23405fc911..6b6a4309cf 100644 --- a/flake.nix +++ b/flake.nix @@ -109,6 +109,24 @@ vendorHash = "sha256-69kg3qkvEWyCAzjaCSr3a73MNonub9sZTYyGaCW+UTI="; }; + # Keep Terraform aligned with provisioner/terraform/testdata/version.txt + # so `make gen` remains deterministic in Nix shells. + terraform_1_14_1 = + if pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.isx86_64 then + pkgs.runCommand "terraform-1.14.1" { + nativeBuildInputs = [ pkgs.unzip ]; + src = pkgs.fetchurl { + url = "https://releases.hashicorp.com/terraform/1.14.1/terraform_1.14.1_linux_amd64.zip"; + hash = "sha256-n1MHDuYm354VeIfB0/mvPYEHobZUNxzZkEBinu1piyc="; + }; + } '' + mkdir -p "$out/bin" + unzip -p "$src" terraform > "$out/bin/terraform" + chmod +x "$out/bin/terraform" + '' + else + unstablePkgs.terraform; + # Packages required to build the frontend frontendPackages = with pkgs; @@ -156,7 +174,7 @@ gnused gnugrep gnutar - unstablePkgs.go_1_25 + unstablePkgs.go_1_26 gofumpt go-migrate (pinnedPkgs.golangci-lint) @@ -170,7 +188,7 @@ lazydocker lazygit less - mockgen + unstablePkgs.mockgen moreutils nfpm nix-prefetch-git @@ -191,7 +209,7 @@ # sqlc sqlc-custom syft - unstablePkgs.terraform + terraform_1_14_1 typos which # Needed for many LD system libs! @@ -285,6 +303,14 @@ lib.optionalDrvAttr stdenv.isLinux "${glibcLocales}/lib/locale/locale-archive"; NODE_OPTIONS = "--max-old-space-size=8192"; + BIOME_BINARY = + if pkgs.stdenv.isLinux then + if pkgs.stdenv.hostPlatform.isAarch64 then + "@biomejs/cli-linux-arm64-musl/biome" + else + "@biomejs/cli-linux-x64-musl/biome" + else + ""; GOPRIVATE = "coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder"; }; }; diff --git a/go.mod b/go.mod index 3a3bc5aac9..02f63b01a6 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,14 @@ replace github.com/aquasecurity/trivy => github.com/coder/trivy v0.0.0-202508072 // https://github.com/spf13/afero/pull/487 replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696 +// Forked for two reasons: +// 1) Adds thinking effort to Anthropic provider +// 2) Downgraded to Go 1.25 due to issue with Windows CI +// https://github.com/kylecarbs/fantasy/compare/main...kylecarbs:fantasy:cj/go1.25 +replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f + +replace github.com/charmbracelet/anthropic-sdk-go => github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab + require ( cdr.dev/slog/v3 v3.0.0-rc1 cloud.google.com/go/compute/metadata v0.9.0 @@ -83,7 +91,7 @@ require ( github.com/aquasecurity/trivy-iac v0.8.0 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible - github.com/aws/smithy-go v1.24.0 + github.com/aws/smithy-go v1.24.1 github.com/bramvdbogaerde/go-scp v1.6.0 github.com/briandowns/spinner v1.23.0 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 @@ -138,7 +146,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b - github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/hc-install v0.9.2 github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f github.com/hashicorp/terraform-json v0.27.2 @@ -150,7 +158,7 @@ require ( github.com/justinas/nosurf v1.2.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.4 github.com/lib/pq v1.10.9 github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/go-wordwrap v1.0.1 @@ -167,7 +175,7 @@ require ( github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/prometheus/common v0.67.4 + github.com/prometheus/common v0.67.5 github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.26.1 @@ -186,17 +194,17 @@ require ( github.com/zclconf/go-cty-yaml v1.2.0 go.mozilla.org/pkcs7 v0.9.0 go.nhat.io/otelsql v0.16.0 - go.opentelemetry.io/otel v1.39.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 - go.opentelemetry.io/otel/sdk v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/trace v1.40.0 go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 go.uber.org/mock v0.6.0 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 golang.org/x/crypto v0.48.0 - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa golang.org/x/mod v0.33.0 golang.org/x/net v0.50.0 golang.org/x/oauth2 v0.35.0 @@ -219,7 +227,7 @@ require ( ) require ( - cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect @@ -253,19 +261,19 @@ require ( github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.1 - github.com/aws/aws-sdk-go-v2/credentials v1.19.1 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.9 + github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -288,7 +296,7 @@ require ( github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect github.com/dustin/go-humanize v1.0.1 github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0-alpha.5 // indirect github.com/elastic/go-windows v1.0.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -305,7 +313,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -321,10 +329,10 @@ require ( github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.5.0 // indirect @@ -336,12 +344,11 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-plugin-go v0.29.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 // indirect github.com/hdevalence/ed25519consensus v0.1.0 // indirect github.com/illarion/gonotify v1.0.1 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect - github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.3.5 // indirect @@ -388,14 +395,14 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/riandyrn/otelchi v0.5.1 // indirect github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect @@ -437,9 +444,9 @@ require ( go.opentelemetry.io/collector/pdata/pprofile v0.121.0 // indirect go.opentelemetry.io/collector/semconv v0.123.0 // indirect go.opentelemetry.io/contrib v1.19.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect @@ -448,10 +455,10 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + gopkg.in/ini.v1 v1.67.1 // indirect howett.net/plist v1.0.0 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect sigs.k8s.io/yaml v1.5.0 // indirect @@ -464,12 +471,13 @@ require github.com/SherClockHolmes/webpush-go v1.4.0 require ( github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) require ( + charm.land/fantasy v0.8.1 github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/brianvoe/gofakeit/v7 v7.14.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 @@ -491,17 +499,19 @@ require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/logging v1.13.1 // indirect + cloud.google.com/go/logging v1.13.2 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect - cloud.google.com/go/storage v1.56.0 // indirect + cloud.google.com/go/storage v1.60.0 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 // indirect github.com/DataDog/datadog-agent/pkg/version v0.64.2 // indirect github.com/DataDog/dd-trace-go/v2 v2.0.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/alecthomas/chroma v0.10.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect @@ -509,24 +519,29 @@ require ( github.com/aquasecurity/jfather v0.0.8 // indirect github.com/aquasecurity/trivy v0.61.1-0.20250407075540-f1329c7ea1aa // indirect github.com/aquasecurity/trivy-checks v1.11.3-0.20250604022615-9a7efa7c9169 // indirect - github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect + github.com/charmbracelet/x/json v0.2.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/coder/paralleltestctx v0.0.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/daixiang0/gci v0.13.7 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/esiqveland/notify v0.13.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect @@ -534,19 +549,24 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/hashicorp/go-getter v1.7.9 // indirect - github.com/hashicorp/go-safetemp v1.0.0 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect + github.com/hashicorp/go-getter v1.8.4 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kaptinlin/go-i18n v0.2.4 // indirect + github.com/kaptinlin/jsonpointer v0.4.10 // indirect + github.com/kaptinlin/jsonschema v0.6.10 // indirect + github.com/kaptinlin/messageformat-go v0.4.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/openai/openai-go v1.12.0 // indirect + github.com/openai/openai-go/v2 v2.7.1 // indirect github.com/openai/openai-go/v3 v3.15.0 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -569,13 +589,13 @@ require ( github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - google.golang.org/genai v1.12.0 // indirect + google.golang.org/genai v1.47.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect mvdan.cc/gofumpt v0.8.0 // indirect diff --git a/go.sum b/go.sum index 0d6c324f12..2768342573 100644 --- a/go.sum +++ b/go.sum @@ -2,635 +2,48 @@ cdr.dev/slog/v3 v3.0.0-rc1 h1:EN7Zim6GvTpAeHQjI0ERDEfqKbTyXRvgH4UhlzLpvWM= cdr.dev/slog/v3 v3.0.0-rc1/go.mod h1:iO/OALX1VxlI03mkodCGdVP7pXzd2bRMvu3ePvlJ9ak= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= -cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= -cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= -cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= -cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= -cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= -cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= -cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= -cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= -cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= -cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= -cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE= -cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= -cloud.google.com/go/accesscontextmanager v1.7.0/go.mod h1:CEGLewx8dwa33aDAZQujl7Dx+uYhS0eay198wB/VumQ= -cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= -cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= -cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg= -cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= -cloud.google.com/go/aiplatform v1.36.1/go.mod h1:WTm12vJRPARNvJ+v6P52RDHCNe4AhvjcIZ/9/RRHy/k= -cloud.google.com/go/aiplatform v1.37.0/go.mod h1:IU2Cv29Lv9oCn/9LkFiiuKfwrRTq+QQMbW+hPCxJGZw= -cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= -cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= -cloud.google.com/go/analytics v0.17.0/go.mod h1:WXFa3WSym4IZ+JiKmavYdJwGG/CvpqiqczmL59bTD9M= -cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= -cloud.google.com/go/analytics v0.19.0/go.mod h1:k8liqf5/HCnOUkbawNtrWWc+UAzyDlW89doe8TtoDsE= -cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk= -cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc= -cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= -cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc= -cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04= -cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= -cloud.google.com/go/apigeeregistry v0.4.0/go.mod h1:EUG4PGcsZvxOXAdyEghIdXwAEi/4MEaoqLMLDMIwKXY= -cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= -cloud.google.com/go/apigeeregistry v0.6.0/go.mod h1:BFNzW7yQVLZ3yj0TKcwzb8n25CFBri51GVGOEUcgQsc= -cloud.google.com/go/apikeys v0.4.0/go.mod h1:XATS/yqZbaBK0HOssf+ALHp8jAlNHUgyfprvNcBIszU= -cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= -cloud.google.com/go/apikeys v0.6.0/go.mod h1:kbpXu5upyiAlGkKrJgQl8A0rKNNJ7dQ377pdroRSSi8= -cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno= -cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak= -cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= -cloud.google.com/go/appengine v1.7.0/go.mod h1:eZqpbHFCqRGa2aCdope7eC0SWLV1j0neb/QnMJVWx6A= -cloud.google.com/go/appengine v1.7.1/go.mod h1:IHLToyb/3fKutRysUlFO0BPt5j7RiQ45nrzEJmKTo6E= -cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= -cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= -cloud.google.com/go/area120 v0.7.0/go.mod h1:a3+8EUD1SX5RUcCs3MY5YasiO1z6yLiNLRiFrykbynY= -cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= -cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= -cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= -cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0= -cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc= -cloud.google.com/go/artifactregistry v1.11.1/go.mod h1:lLYghw+Itq9SONbCa1YWBoWs1nOucMH0pwXN1rOBZFI= -cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= -cloud.google.com/go/artifactregistry v1.12.0/go.mod h1:o6P3MIvtzTOnmvGagO9v/rOjjA0HmhJ+/6KAXrmYDCI= -cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08= -cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= -cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= -cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= -cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ= -cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY= -cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= -cloud.google.com/go/asset v1.12.0/go.mod h1:h9/sFOa4eDIyKmH6QMpm4eUK3pDojWnUhTgJlk762Hg= -cloud.google.com/go/asset v1.13.0/go.mod h1:WQAMyYek/b7NBpYq/K4KJWcRqzoalEsxz/t/dTk4THw= -cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= -cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= -cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= -cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= -cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= -cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= -cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= -cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8= -cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM= -cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= -cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc= -cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI= -cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= -cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE= -cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE= -cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= -cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4= -cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8= -cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= -cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4QZmH4e8WXGGU= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= -cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw= -cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc= -cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/ZurWFIxmF9I/E= -cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= -cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q= -cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= -cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= -cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= -cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI= -cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y= -cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= -cloud.google.com/go/billing v1.13.0/go.mod h1:7kB2W9Xf98hP9Sr12KfECgfGclsH3CQR0R08tnRlRbc= -cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= -cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= -cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0= -cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk= -cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= -cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg= -cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590= -cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= -cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk= -cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk= -cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= -cloud.google.com/go/channel v1.12.0/go.mod h1:VkxCGKASi4Cq7TbXxlaBezonAYpp1GCnKMY6tnMQnLU= -cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U= -cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA= -cloud.google.com/go/cloudbuild v1.6.0/go.mod h1:UIbc/w9QCbH12xX+ezUsgblrWv+Cv4Tw83GiSMHOn9M= -cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= -cloud.google.com/go/cloudbuild v1.9.0/go.mod h1:qK1d7s4QlO0VwfYn5YuClDGg2hfmLZEb4wQGAbIgL1s= -cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM= -cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk= -cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= -cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= -cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= -cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4= -cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI= -cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= -cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAiZtdWcsUyNwBs= -cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= -cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= -cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= -cloud.google.com/go/compute v1.12.0/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= -cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= -cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE= -cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= -cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= -cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= -cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= -cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= -cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= -cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= -cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= -cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= -cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg= -cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo= -cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= -cloud.google.com/go/container v1.14.0/go.mod h1:3AoJMPhHfLDxLvrlVWaK57IXzaPnLaZq63WX59aQBfM= -cloud.google.com/go/container v1.15.0/go.mod h1:ft+9S0WGjAyjDggg5S06DXj+fHJICWg8L7isCQe9pQA= -cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= -cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= -cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= -cloud.google.com/go/containeranalysis v0.9.0/go.mod h1:orbOANbwk5Ejoom+s+DUCTTJ7IBdBQJDcSylAx/on9s= -cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= -cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= -cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= -cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H0/tc8f8ZbZIE= -cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM= -cloud.google.com/go/datacatalog v1.8.1/go.mod h1:RJ58z4rMp3gvETA465Vg+ag8BGgBdnRPEMMSTr5Uv+M= -cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= -cloud.google.com/go/datacatalog v1.13.0/go.mod h1:E4Rj9a5ZtAxcQJlEBTLgMTphfP11/lNaAshpoBgemX8= -cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= -cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= -cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= -cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= -cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= -cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0= -cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= -cloud.google.com/go/dataform v0.7.0/go.mod h1:7NulqnVozfHvWUBpMDfKMUESr+85aJsC/2O0o3jWPDE= -cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38= -cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w= -cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= -cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= -cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= -cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= -cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA= -cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A= -cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= -cloud.google.com/go/dataplex v1.6.0/go.mod h1:bMsomC/aEJOSpHXdFKFGQ1b0TDPIeL28nJObeO1ppRs= -cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s= -cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI= -cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= -cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= -cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= -cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= -cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= -cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= -cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= -cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g= -cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4= -cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= -cloud.google.com/go/datastream v1.7.0/go.mod h1:uxVRMm2elUSPuh65IbZpzJNMbuzkcvu5CjMqVIUHrww= -cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c= -cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s= -cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= -cloud.google.com/go/deploy v1.8.0/go.mod h1:z3myEJnA/2wnB4sgjqdMfgxCA0EqC3RBTNcVPs93mtQ= -cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= -cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= -cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= -cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz0njv7sMx/iek= -cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0= -cloud.google.com/go/dialogflow v1.29.0/go.mod h1:b+2bzMe+k1s9V+F2jbJwpHPzrnIyHihAdRFMtn2WXuM= -cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= -cloud.google.com/go/dialogflow v1.32.0/go.mod h1:jG9TRJl8CKrDhMEcvfcfFkkpp8ZhgPz3sBGmAUYJ2qE= -cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM= -cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q= -cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= -cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= -cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= -cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k= -cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4= -cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= -cloud.google.com/go/documentai v1.18.0/go.mod h1:F6CK6iUH8J81FehpskRmhLq/3VlwQvb7TvwOceQ2tbs= -cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= -cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= -cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= -cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= -cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= -cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= -cloud.google.com/go/edgecontainer v1.0.0/go.mod h1:cttArqZpBB2q58W/upSG++ooo6EsblxDIolxa3jSjbY= -cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= -cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI= -cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8= -cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= -cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc= -cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw= -cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= -cloud.google.com/go/eventarc v1.11.0/go.mod h1:PyUjsUKPWoRBCHeOxZd/lbOOjahV41icXyUY5kSTvVY= -cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w= -cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI= -cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= -cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466dre85Kydllg= -cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= -cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= -cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= -cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY= -cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08= -cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= -cloud.google.com/go/functions v1.12.0/go.mod h1:AXWGrF3e2C/5ehvwYo/GH6O5s09tOPksiKhz+hH8WkA= -cloud.google.com/go/functions v1.13.0/go.mod h1:EU4O007sQm6Ef/PwRsI8N2umygGqPBS/IZQKBQBcJ3c= -cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= -cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= -cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w= -cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM= -cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= -cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60= -cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo= -cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= -cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= -cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= -cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= -cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= -cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= -cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= -cloud.google.com/go/gkehub v0.12.0/go.mod h1:djiIwwzTTBrF5NaXCGv3mf7klpEMcST17VBTVVDcuaw= -cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA= -cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI= -cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= -cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= -cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM= -cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o= -cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= -cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= -cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= -cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc= -cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= -cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= -cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= -cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= -cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= -cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= -cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= -cloud.google.com/go/iap v1.7.0/go.mod h1:beqQx56T9O1G1yNPph+spKpNibDlYIiIixiqsQXxLIo= -cloud.google.com/go/iap v1.7.1/go.mod h1:WapEwPc7ZxGt2jFGB/C/bm+hP0Y6NXzOYGjpPnmMS74= -cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= -cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY= -cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= -cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs= -cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g= -cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= -cloud.google.com/go/iot v1.6.0/go.mod h1:IqdAsmE2cTYYNO1Fvjfzo9po179rAtJeVGUvkLN3rLE= -cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= -cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg= -cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0= -cloud.google.com/go/kms v1.8.0/go.mod h1:4xFEhYFqvW+4VMELtZyxomGSYtSQKzM178ylFW4jMAg= -cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= -cloud.google.com/go/kms v1.10.0/go.mod h1:ng3KTUtQQU9bPX3+QGLsflZIHlkbn8amFAMY63m8d24= -cloud.google.com/go/kms v1.10.1/go.mod h1:rIWk/TryCkR59GMC3YtHtXeLzd634lBbKenvyySAyYI= -cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= -cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= -cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= -cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8= -cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= -cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= -cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= -cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= -cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= -cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= -cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= -cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= -cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= -cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= -cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= -cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= -cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI= -cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= -cloud.google.com/go/maps v0.7.0/go.mod h1:3GnvVl3cqeSvgMcpRlQidXsPYuDGQ8naBis7MVzpXsY= -cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= -cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= -cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= -cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= -cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= -cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA= -cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY= -cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= -cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= -cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= -cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8= -cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI= -cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= -cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk= -cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= -cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= -cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= -cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= -cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= -cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8= -cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= -cloud.google.com/go/networkconnectivity v1.11.0/go.mod h1:iWmDD4QF16VCDLXUqvyspJjIEtBR/4zq5hwnY2X3scM= -cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8= -cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4= -cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= -cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= -cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= -cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= -cloud.google.com/go/networksecurity v0.8.0/go.mod h1:B78DkqsxFG5zRSVuwYFRZ9Xz8IcQ5iECsNrPn74hKHU= -cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= -cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= -cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA= -cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0= -cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= -cloud.google.com/go/notebooks v1.8.0/go.mod h1:Lq6dYKOYOWUCTvw5t2q1gp1lAp0zxAxRycayS0iJcqQ= -cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4= -cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs= -cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= -cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA= -cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk= -cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= -cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE= -cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc= -cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= -cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= -cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= -cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo= -cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw= -cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= -cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= -cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= -cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70= -cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo= -cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= -cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= -cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= -cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= -cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg= -cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE= -cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= -cloud.google.com/go/policytroubleshooter v1.6.0/go.mod h1:zYqaPTsmfvpjm5ULxAyD/lINQxJ0DDsnWOP/GZ7xzBc= -cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= -cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= -cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= -cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPiZhSaNhIgfJlnIXs= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI= -cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= -cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= -cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= -cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= -cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= -cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= -cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= -cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= -cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= -cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= -cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE= -cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U= -cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= -cloud.google.com/go/recaptchaenterprise/v2 v2.7.0/go.mod h1:19wVj/fs5RtYtynAPJdDTb69oW0vNHYDBTbB4NvMD9c= -cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= -cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= -cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= -cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= -cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= -cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs= -cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70= -cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= -cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= -cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= -cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA= -cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM= -cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= -cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA= -cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0= -cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= -cloud.google.com/go/resourcemanager v1.6.0/go.mod h1:YcpXGRs8fDzcUl1Xw8uOVmI8JEadvhRIkoXXUNVYcVo= -cloud.google.com/go/resourcemanager v1.7.0/go.mod h1:HlD3m6+bwhzj9XCouqmeiGuni95NTrExfhoSrkC/3EI= -cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU= -cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg= -cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= -cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= -cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= -cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc= -cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y= -cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= -cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do= -cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo= -cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= -cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= -cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= -cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= -cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk= -cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44= -cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= -cloud.google.com/go/scheduler v1.9.0/go.mod h1:yexg5t+KSmqu+njTIh3b7oYPheFtBWGcbVUYF1GGMIc= -cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= -cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4= -cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4= -cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= -cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= -cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= -cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= -cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q= -cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA= -cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= -cloud.google.com/go/security v1.13.0/go.mod h1:Q1Nvxl1PAgmeW0y3HTt54JYIvUdtcpYKVfIB8AOMZ+0= -cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= -cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= -cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk= -cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk= -cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= -cloud.google.com/go/securitycenter v1.19.0/go.mod h1:LVLmSg8ZkkyaNy4u7HCIshAngSQ8EcIRREP3xBnyfag= -cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU= -cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s= -cloud.google.com/go/servicecontrol v1.10.0/go.mod h1:pQvyvSRh7YzUF2efw7H87V92mxU8FnFDawMClGCNuAA= -cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= -cloud.google.com/go/servicecontrol v1.11.1/go.mod h1:aSnNNlwEFBY+PWGQ2DoM0JJ/QUXqV5/ZD9DOLB7SnUk= -cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= -cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= -cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4= -cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U= -cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= -cloud.google.com/go/servicedirectory v1.9.0/go.mod h1:29je5JjiygNYlmsGz8k6o+OZ8vd4f//bQLtvzkPPT/s= -cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco= -cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo= -cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= -cloud.google.com/go/servicemanagement v1.8.0/go.mod h1:MSS2TDlIEQD/fzsSGfCdJItQveu9NXnUniTrq/L8LK4= -cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E= -cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU= -cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= -cloud.google.com/go/serviceusage v1.6.0/go.mod h1:R5wwQcbOWsyuOfbP9tGdAnCAc6B9DRwPG1xtWMDeuPA= -cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4= -cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw= -cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= -cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= -cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= -cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= -cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= -cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= -cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= -cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco= -cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= -cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542tg3VqeYI= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= -cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= -cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= -cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= -cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= -cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= -cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= -cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= -cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= -cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= -cloud.google.com/go/storagetransfer v1.8.0/go.mod h1:JpegsHHU1eXg7lMHkvf+KE5XDJ7EQu0GwNJbbVGanEw= -cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= -cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= -cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM= -cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA= -cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= -cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8= -cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4= -cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= -cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ= -cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg= -cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= -cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28= -cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= -cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= -cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= +cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8= +cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= -cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= -cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= -cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= -cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= -cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= -cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk= -cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw= -cloud.google.com/go/video v1.12.0/go.mod h1:MLQew95eTuaNDEGriQdcYn0dTwf9oWiA4uYebxM5kdg= -cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= -cloud.google.com/go/video v1.14.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= -cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= -cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= -cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= -cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M= -cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU= -cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= -cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= -cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= -cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= -cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY= -cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E= -cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= -cloud.google.com/go/vision/v2 v2.7.0/go.mod h1:H89VysHy21avemp6xcf9b9JvZHVehWbET0uT/bcuY/0= -cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE= -cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g= -cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= -cloud.google.com/go/vmmigration v1.6.0/go.mod h1:bopQ/g4z+8qXzichC7GW1w2MjbErL54rk3/C843CjfY= -cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208= -cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= -cloud.google.com/go/vmwareengine v0.3.0/go.mod h1:wvoyMvNWdIzxMYSpH/R7y2h5h3WFkx6d+1TIsP39WGY= -cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w= -cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8= -cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= -cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= -cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= -cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc= -cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A= -cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= -cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo= -cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ= -cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= -cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= -cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= -cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= -cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= -cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= -git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/appsec-internal-go v1.11.2 h1:Q00pPMQzqMIw7jT2ObaORIxBzSly+deS0Ely9OZ/Bj0= @@ -669,19 +82,18 @@ github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 h1:GlvoS github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0/go.mod h1:mYQmU7mbHH6DrCaS8N6GZcxwPoeNfyuopUoLQltwSzs= github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0/go.mod h1:D56Cl9r8M5i3UwAchE+LlLc5hPN3kJtdZNVJn06lSHU= -github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= @@ -691,7 +103,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/SasSwart/openai-go/v3 v3.0.0-20260204134041-fb987b42a728 h1:FOjd3xOH+arcrtz1e5P6WZ/VtRD5KQHHRg4kc4BZers= @@ -706,10 +117,6 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= -github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= @@ -724,15 +131,10 @@ github.com/ammario/tlru v0.4.0 h1:sJ80I0swN3KOX2YxC6w8FbCqpQucWdbb+J36C05FPuU= github.com/ammario/tlru v0.4.0/go.mod h1:aYzRFu0XLo4KavE9W8Lx7tzjkX+pAApz+NgcKYIFUBQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= -github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= -github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= @@ -760,19 +162,16 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= -github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= -github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU= -github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= +github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 h1:QbFjOdplTkOgviHNKyTW/TZpvIYhD6lqEc3tkIvqMoQ= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2/go.mod h1:d0pTYUeTv5/tPSlbPZZQSqssM158jZBs02jx2LDslM8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= @@ -781,22 +180,30 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -843,8 +250,6 @@ github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bramvdbogaerde/go-scp v1.6.0 h1:lDh0lUuz1dbIhJqlKLwWT7tzIRONCp1Mtx3pgQVaLQo= github.com/bramvdbogaerde/go-scp v1.6.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk= @@ -859,12 +264,7 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -881,19 +281,18 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= +github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TTENg= github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs= github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= @@ -902,7 +301,6 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -911,21 +309,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= github.com/coder/aibridge v1.0.7 h1:X0KOlYNFwxD2Qx5kOHSDNm5qBljuPxgKB1aLFxFrbks= @@ -940,7 +325,6 @@ github.com/coder/clistat v1.2.0 h1:37KJKqiCllJsRvWqTHf3qiLIXX0JB6oqE5oxcqgdLkY= github.com/coder/clistat v1.2.0/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A= github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4= github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ= -github.com/coder/glog v1.0.1-0.20220322161911-7365fe7f2cd1/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU= github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322/go.mod h1:rOLFDDVKVFiDqZFXoteXc97YXx7kFi9kYqR+2ETPkLQ= github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs24WOxc3PBvygSNTQurm0PYPujJjLLOzs0= @@ -998,7 +382,6 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48= @@ -1035,6 +418,8 @@ github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/cli v28.3.2+incompatible h1:mOt9fcLE7zaACbxW1GeS65RI67wIJrTnqS3hP2huFsY= github.com/docker/cli v28.3.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= @@ -1043,7 +428,6 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd h1:QMSNEh9uQkDjyPwu/J541GgSH+4hw+0skJDIj9HJ3mE= github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -1051,8 +435,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0-alpha.5 h1:IUIZ1pu0wnpxrn7o6utj8AeoZBS2upI11kLcddBF414= +github.com/ebitengine/purego v0.10.0-alpha.5/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8TdzIQ= github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= @@ -1065,29 +449,14 @@ github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4 github.com/emersion/go-smtp v0.21.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= -github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= -github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= -github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= @@ -1107,8 +476,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fergusstrange/embedded-postgres v1.32.0 h1:kh2ozEvAx2A0LoIJZEGNwHmoFTEQD243KrHjifcYGMo= github.com/fergusstrange/embedded-postgres v1.32.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= @@ -1128,7 +495,6 @@ github.com/gen2brain/beeep v0.11.1 h1:EbSIhrQZFDj1K2fzlMpAYlFOzV8YuNe721A58XcCTY github.com/gen2brain/beeep v0.11.1/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -1141,28 +507,18 @@ github.com/go-chi/hostrouter v0.3.0 h1:75it1eO3FvkG8te1CvU6Kvr3WzAZNEBbo8xIrxUKL github.com/go-chi/hostrouter v0.3.0/go.mod h1:KLB+7PH/ceOr6FCmMyWD2Dmql/clpOe+y7I7CUeTkaQ= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= -github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= -github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= -github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= -github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= -github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= -github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.1/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -1182,8 +538,6 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= -github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -1201,8 +555,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -1213,7 +567,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -1251,70 +604,27 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 h1:4txT5G2kqVAKMjzidIabL/8KqjIK71yj30YOeuxLn10= github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -1330,73 +640,32 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= -github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= -github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= -github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= -github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= -github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= -github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= -github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70/go.mod h1:fm2FdDCzJdtbXF7WKAMvBb5NEPouXPHFbGNYs9ShFns= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -1406,8 +675,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= -github.com/hashicorp/go-getter v1.7.9 h1:G9gcjrDixz7glqJ+ll5IWvggSBR+R0B54DSRt4qfdC4= -github.com/hashicorp/go-getter v1.7.9/go.mod h1:dyFCmT1AQkDfOIt9NH8pw9XBDqNrIKJT5ylbpi7zPNE= +github.com/hashicorp/go-getter v1.8.4 h1:hGEd2xsuVKgwkMtPVufq73fAmZU/x65PPcqH3cb0D9A= +github.com/hashicorp/go-getter v1.8.4/go.mod h1:x27pPGSg9kzoB147QXI8d/nDvp2IgYGcwuRjpaXE9Yg= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -1418,16 +687,12 @@ github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b h1:3GrpnZQBxcMj1 github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b/go.mod h1:qIFzeFcJU3OIFk/7JreWXcUjFmcCaeHTH9KoNyHYVCs= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= -github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= -github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c h1:5v6L/m/HcAZYbrLGYBpPkcCVtDWwIgFxq2+FUmfPxPk= github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c/go.mod h1:xoy1vl2+4YvqSQEkKcFjNYxTk7cll+o1f1t2wxnHIX8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -1445,8 +710,8 @@ github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoK github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= -github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= -github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 h1:mlAq/OrMlg04IuJT7NpefI1wwtdpWudnEmjuQs04t/4= github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1/go.mod h1:GQhpKVvvuwzD79e8/NZ+xzj+ZpWovdPAe8nfV/skwNU= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= @@ -1465,9 +730,6 @@ github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/icholy/replace v0.6.0 h1:EBiD2pGqZIOJAbEaf/5GVRaD/Pmbb4n+K3LrBdXd4dw= github.com/icholy/replace v0.6.0/go.mod h1:zzi8pxElj2t/5wHHHYmH45D+KxytX/t4w3ClY5nlK+g= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= @@ -1487,11 +749,6 @@ github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYA github.com/jedib0t/go-pretty/v6 v6.7.1 h1:bHDSsj93NuJ563hHuM7ohk/wpX7BmRFNIsVv1ssI2/M= github.com/jedib0t/go-pretty/v6 v6.7.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -1502,12 +759,16 @@ github.com/jsimonetti/rtnetlink v1.3.5 h1:hVlNQNRlLDGZz31gBPicsG7Q53rnlsz1l1Ix/9 github.com/jsimonetti/rtnetlink v1.3.5/go.mod h1:0LFedyiTkebnd43tE4YAkWGIq9jQphow4CcwxaT2Y00= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= +github.com/kaptinlin/go-i18n v0.2.4 h1:aIi0BaDbR1FyNTra2cf1Y8vQUbSwVqXVsehZjkkqgbI= +github.com/kaptinlin/go-i18n v0.2.4/go.mod h1:h+/0DIpnlHlF4+ZftBRYncH4LoqU4Y3eh94nY+z6yeY= +github.com/kaptinlin/jsonpointer v0.4.10 h1:DIpoLKB3Tr62REbLM6OL96RMa85Aft1qwF4l17B55QQ= +github.com/kaptinlin/jsonpointer v0.4.10/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA= +github.com/kaptinlin/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I= +github.com/kaptinlin/jsonschema v0.6.10/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8= +github.com/kaptinlin/messageformat-go v0.4.10 h1:ixW2Zf9XUi2lv8NZf+eHUJnWE+YO7K76pFbxuKeqwRs= +github.com/kaptinlin/messageformat-go v0.4.10/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -1516,12 +777,8 @@ github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNq github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= @@ -1529,18 +786,18 @@ github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryy github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:5UMYqr13zFQKfq8YscVuFwE7cCQpLieaPJDtLUPe11E= +github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= -github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY= +github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f h1:8Xa42VBnKO+83zEhFBZF2eKVOl1pCLXxeCXOllCSVB4= +github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f/go.mod h1:Ro/xRzPtU/+P178CMzGBn8XsEFp/X/nr7meQ1pjX7eI= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -1559,9 +816,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= -github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= -github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= @@ -1572,7 +826,6 @@ github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mr github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -1581,18 +834,15 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= @@ -1607,8 +857,6 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= -github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -1688,6 +936,8 @@ github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisti github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.120.1/go.mod h1:Z/S1brD5gU2Ntht/bHxBVnGxXKTvZDr0dNv/riUzPmY= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8= +github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -1712,10 +962,6 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= -github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= @@ -1748,22 +994,18 @@ github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSu github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rhysd/actionlint v1.7.10 h1:FL3XIEs72G4/++168vlv5FKOWMSWvWIQw1kBCadyOcM= github.com/rhysd/actionlint v1.7.10/go.mod h1:ZHX/hrmknlsJN73InPTKsKdXpAv9wVdrJy8h8HAwFHg= github.com/riandyrn/otelchi v0.5.1 h1:0/45omeqpP7f/cvdL16GddQBfAEmZvUyl2QzLSE6uYo= @@ -1776,16 +1018,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= -github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= @@ -1801,8 +1037,8 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= @@ -1811,7 +1047,6 @@ github.com/sony/gobreaker/v2 v2.3.0 h1:7VYxZ69QXRQ2Q4eEawHn6eU4FiuwovzJwsUMA03Lu github.com/sony/gobreaker/v2 v2.3.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= github.com/sosedoff/gitkit v0.4.0 h1:opyQJ/h9xMRLsz2ca/2CRXtstePcpldiZN8DpLLF8Os= github.com/sosedoff/gitkit v0.4.0/go.mod h1:V3EpGZ0nvCBhXerPsbDeqtyReNb48cwP9KtkUYTKT5I= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -1841,7 +1076,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -1907,7 +1141,6 @@ github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a h1:BH1SOPEvehD2kVrndDnGJiUF0TrBpNs+iyYocu6h0og= github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= @@ -1972,12 +1205,9 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= @@ -2034,40 +1264,37 @@ go.opentelemetry.io/collector/semconv v0.123.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxD go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.3.0/go.mod h1:rIo4suHNhQwBIPg9axF8V9CA72Wz2mKF1teNrup8yzs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.3.0/go.mod h1:c/VDhno8888bvQYmbYLqe41/Ldmr/KKunbvWM4/fEjk= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -2090,8 +1317,6 @@ go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wus go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 h1:X66ZEoMN2SuaoI/dfZVYobB6E5zjZyyHUMWlCA7MgGE= go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516/go.mod h1:TQvodOM+hJTioNQJilmLXu08JNb8i+ccq418+KWu1/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -2103,281 +1328,84 @@ golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= -golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= -golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -2385,7 +1413,6 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -2394,12 +1421,7 @@ golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= @@ -2407,104 +1429,32 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= @@ -2513,10 +1463,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= @@ -2525,282 +1471,25 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= -gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= -gonum.org/v1/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= -google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= -google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= -google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= -google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= -google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91A08= -google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= -google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo= -google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= -google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= -google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= -google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genai v1.12.0 h1:0JjAdwvEAha9ZpPH5hL6dVG8bpMnRbAMCgv2f2LDnz4= -google.golang.org/genai v1.12.0/go.mod h1:HFXR1zT3LCdLxd/NW6IOSCczOYyRAxwaShvYbgPSeVw= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= -google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= -google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= -google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= -google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= -google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= -google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= -google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221117204609-8f9c96812029/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE= -google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230112194545-e10362b5ecf9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA= -google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= -google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= -google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA= -google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= -google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= -google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= -google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= -google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= +google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= +google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= @@ -2810,18 +1499,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -2834,14 +1519,6 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc h1:DXLLFYv/k/xr0rWcwVEvWme1GR36Oc4kNMspg38JeiE= gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= @@ -2853,48 +1530,10 @@ kernel.org/pub/linux/libs/security/libcap/cap v1.2.73/go.mod h1:hbeKwKcboEsxARYm kernel.org/pub/linux/libs/security/libcap/psx v1.2.73/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= -lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= -modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= -modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= -modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= -modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= -modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= -modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= -modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= -modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= -modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= -modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= -modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= -modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= -modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= -modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= -modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= diff --git a/provisioner/terraform/serve_internal_test.go b/provisioner/terraform/serve_internal_test.go index c87ee30724..ec93b49d46 100644 --- a/provisioner/terraform/serve_internal_test.go +++ b/provisioner/terraform/serve_internal_test.go @@ -44,7 +44,7 @@ func Test_absoluteBinaryPath(t *testing.T) { { name: "TestMalformedVersion", terraformVersion: "version", - expectedErr: xerrors.Errorf("Terraform binary get version failed: Malformed version: version"), + expectedErr: xerrors.Errorf("Terraform binary get version failed: malformed version: version"), }, } // nolint:paralleltest diff --git a/scripts/biome_format.sh b/scripts/biome_format.sh new file mode 100755 index 0000000000..fe41515260 --- /dev/null +++ b/scripts/biome_format.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "$script_dir/.." && pwd) +target=$1 + +output_file=$(mktemp) +trap 'rm -f "$output_file"' EXIT + +if ( + cd "$repo_root/site" + pnpm exec biome format --write "$target" +) >"$output_file" 2>&1; then + cat "$output_file" + exit 0 +fi +status=$? + +cat "$output_file" >&2 + +if [[ $status -eq 127 ]] || grep -q "Could not start dynamically linked executable" "$output_file" || grep -q "NixOS cannot run dynamically linked executables" "$output_file"; then + echo "WARNING: skipping biome format for '$target' because the biome binary is unavailable in this environment." >&2 + exit 0 +fi + +exit $status diff --git a/scripts/clidocgen/main.go b/scripts/clidocgen/main.go index 68b97b7f19..da8452c7ce 100644 --- a/scripts/clidocgen/main.go +++ b/scripts/clidocgen/main.go @@ -48,6 +48,10 @@ func prepareEnv() { if err != nil { panic(err) } + err = os.Setenv("TMPDIR", "/tmp") + if err != nil { + panic(err) + } } func deleteEmptyDirs(dir string) error { diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 884aa2944a..6c170429b9 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1042,7 +1042,22 @@ const fillParameters = async ( case "number": { const parameterField = parameterLabel.locator("input"); - await parameterField.fill(buildParameter.value); + // Dynamic parameters can hydrate after initial render and + // overwrite an early fill. Re-apply until the desired value + // is stable. + for (let attempt = 0; attempt < 3; attempt++) { + await parameterField.fill(buildParameter.value); + try { + await expect(parameterField).toHaveValue(buildParameter.value, { + timeout: 1000, + }); + break; + } catch (error) { + if (attempt === 2) { + throw error; + } + } + } } break; default: diff --git a/site/jest.config.ts b/site/jest.config.ts index 79a0558c3e..e7a07a4a1d 100644 --- a/site/jest.config.ts +++ b/site/jest.config.ts @@ -38,6 +38,8 @@ module.exports = { moduleNameMapper: { "\\.css$": "/src/testHelpers/styleMock.ts", "^@fontsource": "/src/testHelpers/styleMock.ts", + "^@pierre/diffs/react$": + "/src/testHelpers/pierreDiffsReactMock.tsx", }, }, ], diff --git a/site/package.json b/site/package.json index f8cc45b057..ceff82a28d 100644 --- a/site/package.json +++ b/site/package.json @@ -50,6 +50,7 @@ "@mui/material": "5.18.0", "@mui/system": "5.18.0", "@mui/x-tree-view": "7.29.10", + "@pierre/diffs": "1.0.11", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", @@ -91,6 +92,7 @@ "lodash": "4.17.21", "lucide-react": "0.555.0", "monaco-editor": "0.55.1", + "motion": "12.34.1", "pretty-bytes": "6.1.1", "react": "19.2.2", "react-color": "2.19.3", @@ -110,6 +112,7 @@ "resize-observer-polyfill": "1.5.1", "semver": "7.7.3", "sonner": "2.0.7", + "streamdown": "2.2.0", "tailwind-merge": "2.6.0", "tailwindcss-animate": "1.0.7", "tzdata": "1.0.46", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index dc24197641..a21ba16ce1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: '@mui/x-tree-view': specifier: 7.29.10 version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@pierre/diffs': + specifier: 1.0.11 + version: 1.0.11(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-avatar': specifier: 1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -187,6 +190,9 @@ importers: monaco-editor: specifier: 0.55.1 version: 0.55.1 + motion: + specifier: 12.34.1 + version: 12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) pretty-bytes: specifier: 6.1.1 version: 6.1.1 @@ -244,6 +250,9 @@ importers: sonner: specifier: 2.0.7 version: 2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + streamdown: + specifier: 2.2.0 + version: 2.2.0(react@19.2.2) tailwind-merge: specifier: 2.6.0 version: 2.6.0 @@ -1477,6 +1486,12 @@ packages: cpu: [x64] os: [win32] + '@pierre/diffs@1.0.11': + resolution: {integrity: sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.11.tgz} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} engines: {node: '>=14'} @@ -2141,6 +2156,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.22.0': + resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==, tarball: https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz} + + '@shikijs/engine-javascript@3.22.0': + resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==, tarball: https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz} + + '@shikijs/engine-oniguruma@3.22.0': + resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==, tarball: https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz} + + '@shikijs/langs@3.22.0': + resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==, tarball: https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz} + + '@shikijs/themes@3.22.0': + resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==, tarball: https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz} + + '@shikijs/transformers@3.22.0': + resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==, tarball: https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.22.0.tgz} + + '@shikijs/types@3.22.0': + resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==, tarball: https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, tarball: https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, tarball: https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz} @@ -3436,6 +3475,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==, tarball: https://registry.npmjs.org/diff/-/diff-4.0.2.tgz} engines: {node: '>=0.3.1'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==, tarball: https://registry.npmjs.org/diff/-/diff-8.0.3.tgz} + engines: {node: '>=0.3.1'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==, tarball: https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz} @@ -3761,6 +3804,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==, tarball: https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz} + framer-motion@12.34.1: + resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==, tarball: https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.1.tgz} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz} engines: {node: '>= 0.6'} @@ -3890,18 +3947,39 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==, tarball: https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz} + hast-util-parse-selector@2.2.5: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==, tarball: https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==, tarball: https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==, tarball: https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, tarball: https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz} hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz} @@ -3928,6 +4006,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, tarball: https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==, tarball: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz} engines: {node: '>= 0.8'} @@ -4541,6 +4622,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==, tarball: https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz} + lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==, tarball: https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz} peerDependencies: @@ -4575,6 +4659,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.2: + resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==, tarball: https://registry.npmjs.org/marked/-/marked-17.0.2.tgz} + engines: {node: '>= 20'} + hasBin: true + material-colors@1.2.6: resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==, tarball: https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz} @@ -4788,6 +4877,26 @@ packages: moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==, tarball: https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz} + motion-dom@12.34.1: + resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==, tarball: https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.1.tgz} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==, tarball: https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz} + + motion@12.34.1: + resolution: {integrity: sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA==, tarball: https://registry.npmjs.org/motion/-/motion-12.34.1.tgz} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmjs.org/ms/-/ms-2.0.0.tgz} @@ -4889,6 +4998,12 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, tarball: https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz} engines: {node: '>=6'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==, tarball: https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==, tarball: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==, tarball: https://registry.npmjs.org/open/-/open-10.2.0.tgz} engines: {node: '>=18'} @@ -5379,10 +5494,28 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, tarball: https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==, tarball: https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==, tarball: https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==, tarball: https://registry.npmjs.org/regex/-/regex-6.1.0.tgz} + regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==, tarball: https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz} engines: {node: '>= 0.4'} + rehype-harden@1.1.7: + resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==, tarball: https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.7.tgz} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==, tarball: https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz} @@ -5395,6 +5528,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==, tarball: https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz} + remend@1.2.0: + resolution: {integrity: sha512-NbKrdWweTRuByPYErzQCNpNtsR9M1QQ0hK2UzmnmlSaEqHnkQ5Korlyi8KpdbOJ0rImJfRy4EAY0uDxYnL9Plw==, tarball: https://registry.npmjs.org/remend/-/remend-1.2.0.tgz} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, tarball: https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz} engines: {node: '>=0.10.0'} @@ -5533,6 +5669,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz} engines: {node: '>=8'} + shiki@3.22.0: + resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==, tarball: https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, tarball: https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz} engines: {node: '>= 0.4'} @@ -5655,6 +5794,11 @@ packages: prettier: optional: true + streamdown@2.2.0: + resolution: {integrity: sha512-Y51o1I/sjpAy4Yn7j7R4TbUl9gcUZ7BTrHS+68IhrUBoYpNQZ28z06vww1MBFu4mSwvgF8xQIxIH2b9S9IHDyQ==, tarball: https://registry.npmjs.org/streamdown/-/streamdown-2.2.0.tgz} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==, tarball: https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz} @@ -5747,6 +5891,9 @@ packages: tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz} + tailwind-merge@3.4.1: + resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz} + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==, tarball: https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz} peerDependencies: @@ -6070,6 +6217,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, tarball: https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz} @@ -6211,6 +6361,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, tarball: https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, tarball: https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz} engines: {node: '>=12'} @@ -7468,6 +7621,18 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.14.0': optional: true + '@pierre/diffs@1.0.11(react-dom@19.2.2(react@19.2.2))(react@19.2.2)': + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/engine-javascript': 3.22.0 + '@shikijs/transformers': 3.22.0 + diff: 8.0.3 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + shiki: 3.22.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -8088,6 +8253,44 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@shikijs/core@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + + '@shikijs/engine-oniguruma@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/themes@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/transformers@3.22.0': + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/types': 3.22.0 + + '@shikijs/types@3.22.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.0': @@ -9414,6 +9617,8 @@ snapshots: diff@4.0.2: optional: true + diff@8.0.3: {} + dlv@1.1.3: {} doctrine@3.0.0: @@ -9840,6 +10045,16 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + motion-dom: 12.34.1 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + fresh@0.5.2: {} front-matter@4.0.2: @@ -9978,8 +10193,59 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + hast-util-parse-selector@2.2.5: {} + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -10000,6 +10266,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10012,6 +10288,14 @@ snapshots: property-information: 5.6.0 space-separated-tokens: 1.1.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} highlight.js@10.7.3: {} @@ -10034,6 +10318,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -10891,6 +11177,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru_map@0.4.1: {} + lucide-react@0.555.0(react@19.2.2): dependencies: react: 19.2.2 @@ -10918,6 +11206,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.2: {} + material-colors@1.2.6: {} math-intrinsics@1.1.0: {} @@ -11324,6 +11614,21 @@ snapshots: dependencies: color-name: 1.1.4 + motion-dom@12.34.1: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + framer-motion: 12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + ms@2.0.0: {} ms@2.1.3: {} @@ -11420,6 +11725,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -11976,12 +12289,37 @@ snapshots: regenerator-runtime@0.14.1: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.1: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 set-function-name: 2.0.1 + rehype-harden@1.1.7: + dependencies: + unist-util-visit: 5.0.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -12016,6 +12354,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remend@1.2.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -12175,6 +12515,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@3.22.0: + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/engine-javascript': 3.22.0 + '@shikijs/engine-oniguruma': 3.22.0 + '@shikijs/langs': 3.22.0 + '@shikijs/themes': 3.22.0 + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -12299,6 +12650,27 @@ snapshots: - react-dom - utf-8-validate + streamdown@2.2.0(react@19.2.2): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.2 + react: 19.2.2 + rehype-harden: 1.1.7 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.2.0 + tailwind-merge: 3.4.1 + unified: 11.0.5 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - supports-color + strict-event-emitter@0.5.1: {} string-length@4.0.2: @@ -12389,6 +12761,8 @@ snapshots: tailwind-merge@2.6.0: {} + tailwind-merge@3.4.1: {} + tailwindcss-animate@1.0.7(tailwindcss@3.4.18(yaml@2.7.0)): dependencies: tailwindcss: 3.4.18(yaml@2.7.0) @@ -12707,6 +13081,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -12823,6 +13202,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} webidl-conversions@8.0.0: {} diff --git a/site/src/App.tsx b/site/src/App.tsx index 0573af9be3..4d6c5ad94a 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -12,6 +12,7 @@ import { QueryClient, QueryClientProvider } from "react-query"; import { RouterProvider } from "react-router"; import { Toaster } from "./components/Toaster/Toaster"; import { AuthProvider } from "./contexts/auth/AuthProvider"; +import { DiffsWorkerPoolProvider } from "./contexts/DiffsWorkerPoolProvider"; import { ThemeProvider } from "./contexts/ThemeProvider"; import { router } from "./router"; @@ -52,14 +53,16 @@ export const AppProviders: FC = ({ return ( - - - - {children} - - - - + + + + + {children} + + + + + {showDevtools && } ); diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index c753261997..12a0a82919 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -280,4 +280,50 @@ describe("api.ts", () => { }); }); }); + + describe("chat configuration endpoints", () => { + it.each<[string, () => Promise, unknown]>([ + [ + "/api/experimental/chats/models", + () => API.getChatModels(), + { + providers: [], + }, + ], + [ + "/api/experimental/chats/providers", + () => API.getChatProviderConfigs(), + [], + ], + [ + "/api/experimental/chats/model-configs", + () => API.getChatModelConfigs(), + [], + ], + ])("returns response data for %s", async (path, request, responseData) => { + vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ + data: responseData, + }); + + const result = await request(); + + expect(axiosInstance.get).toHaveBeenCalledWith(path); + expect(result).toStrictEqual(responseData); + }); + + it.each<[string, () => Promise]>([ + ["/api/experimental/chats/models", () => API.getChatModels()], + ["/api/experimental/chats/providers", () => API.getChatProviderConfigs()], + [ + "/api/experimental/chats/model-configs", + () => API.getChatModelConfigs(), + ], + ])("rethrows axios errors for %s", async (path, request) => { + const expectedError = new Error("request failed"); + vi.spyOn(axiosInstance, "get").mockRejectedValueOnce(expectedError); + + await expect(request()).rejects.toBe(expectedError); + expect(axiosInstance.get).toHaveBeenCalledWith(path); + }); + }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6d27c68fc2..4023bbdeae 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -138,6 +138,20 @@ export const watchWorkspace = ( }); }; +export const watchChat = ( + chatId: string, +): OneWayWebSocket => { + return new OneWayWebSocket({ + apiRoute: `/api/experimental/chats/${chatId}/stream`, + }); +}; + +export const watchChats = (): OneWayWebSocket => { + return new OneWayWebSocket({ + apiRoute: "/api/experimental/chats/watch", + }); +}; + export const watchAgentContainers = ( agentId: string, ): OneWayWebSocket => { @@ -324,6 +338,29 @@ export type GetTemplatesQuery = Readonly<{ readonly q: string; }>; +interface ChatGitChangeResponse extends TypesGen.ChatGitChange { + readonly patch?: string; + readonly diff_patch?: string; + readonly unified_diff?: string; + readonly diffs_url?: string; + readonly diff_url?: string; + readonly diffs_link?: string; +} + +export type ChatDiffStatusResponse = Readonly< + { + chat_id: string; + url?: string; + pull_request_state?: string; + changes_requested: boolean; + additions: number; + deletions: number; + changed_files: number; + refreshed_at?: string; + stale_at?: string; + } & Record +>; + function normalizeGetTemplatesOptions( options: GetTemplatesOptions | GetTemplatesQuery = {}, ): Record { @@ -357,6 +394,9 @@ export type DeploymentConfig = Readonly<{ options: TypesGen.SerpentOption[]; }>; +const chatProviderConfigsPath = "/api/experimental/chats/providers"; +const chatModelConfigsPath = "/api/experimental/chats/model-configs"; + type Claims = { license_expires: number; // nbf is a standard JWT claim for "not before" - the license valid from date @@ -2865,6 +2905,186 @@ class ApiMethods { return response.data; }; + // Chat API methods + getChats = async (): Promise => { + const response = await this.axios.get( + "/api/experimental/chats", + ); + return response.data; + }; + + getChat = async (chatId: string): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}`, + ); + return response.data; + }; + + createChat = async ( + req: TypesGen.CreateChatRequest, + ): Promise => { + const response = await this.axios.post( + "/api/experimental/chats", + req, + ); + return response.data; + }; + + deleteChat = async (chatId: string): Promise => { + await this.axios.delete(`/api/experimental/chats/${chatId}`); + }; + + createChatMessage = async ( + chatId: string, + req: TypesGen.CreateChatMessageRequest, + ): Promise => { + const response = await this.axios.post( + `/api/experimental/chats/${chatId}/messages`, + req, + ); + return response.data; + }; + + editChatMessage = async ( + chatId: string, + messageId: number, + req: TypesGen.EditChatMessageRequest, + ): Promise => { + const response = await this.axios.patch( + `/api/experimental/chats/${chatId}/messages/${messageId}`, + req, + ); + return response.data; + }; + + interruptChat = async (chatId: string): Promise => { + const response = await this.axios.post( + `/api/experimental/chats/${chatId}/interrupt`, + ); + return response.data; + }; + + deleteChatQueuedMessage = async ( + chatId: string, + queuedMessageId: number, + ): Promise => { + await this.axios.delete( + `/api/experimental/chats/${chatId}/queue/${queuedMessageId}`, + ); + }; + + promoteChatQueuedMessage = async ( + chatId: string, + queuedMessageId: number, + ): Promise => { + const response = await this.axios.post( + `/api/experimental/chats/${chatId}/queue/${queuedMessageId}/promote`, + ); + return response.data; + }; + + getChatGitChanges = async ( + chatId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/git-changes`, + ); + return response.data; + }; + + getChatDiffStatus = async ( + chatId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/diff-status`, + ); + return response.data; + }; + + getChatDiffContents = async ( + chatId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/diff`, + ); + return response.data; + }; + + getChatModels = async (): Promise => { + const response = await this.axios.get( + "/api/experimental/chats/models", + ); + return response.data; + }; + + getChatProviderConfigs = async (): Promise => { + const response = await this.axios.get( + chatProviderConfigsPath, + ); + return response.data; + }; + + createChatProviderConfig = async ( + req: TypesGen.CreateChatProviderConfigRequest, + ): Promise => { + const response = await this.axios.post( + chatProviderConfigsPath, + req, + ); + return response.data; + }; + + updateChatProviderConfig = async ( + providerConfigId: string, + req: TypesGen.UpdateChatProviderConfigRequest, + ): Promise => { + const response = await this.axios.patch( + `${chatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`, + req, + ); + return response.data; + }; + + deleteChatProviderConfig = async ( + providerConfigId: string, + ): Promise => { + await this.axios.delete( + `${chatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`, + ); + }; + + getChatModelConfigs = async (): Promise => { + const response = + await this.axios.get(chatModelConfigsPath); + return response.data; + }; + + createChatModelConfig = async ( + req: TypesGen.CreateChatModelConfigRequest, + ): Promise => { + const response = await this.axios.post( + chatModelConfigsPath, + req, + ); + return response.data; + }; + + updateChatModelConfig = async ( + modelConfigId: string, + req: TypesGen.UpdateChatModelConfigRequest, + ): Promise => { + const response = await this.axios.patch( + `${chatModelConfigsPath}/${encodeURIComponent(modelConfigId)}`, + req, + ); + return response.data; + }; + + deleteChatModelConfig = async (modelConfigId: string): Promise => { + await this.axios.delete( + `${chatModelConfigsPath}/${encodeURIComponent(modelConfigId)}`, + ); + }; getAIBridgeModels = async (options: SearchParamOptions) => { const url = getURLWithSearchParams("/api/v2/aibridge/models", options); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts new file mode 100644 index 0000000000..0f51dc375a --- /dev/null +++ b/site/src/api/queries/chats.ts @@ -0,0 +1,188 @@ +import { API, type ChatDiffStatusResponse } from "api/api"; +import type * as TypesGen from "api/typesGenerated"; +import type { QueryClient } from "react-query"; + +export const chatsKey = ["chats"] as const; +export const chatKey = (chatId: string) => ["chats", chatId] as const; + +export const chats = () => ({ + queryKey: chatsKey, + queryFn: () => API.getChats(), +}); + +export const chat = (chatId: string) => ({ + queryKey: chatKey(chatId), + queryFn: () => API.getChat(chatId), +}); + +export const createChat = (queryClient: QueryClient) => ({ + mutationFn: (req: TypesGen.CreateChatRequest) => API.createChat(req), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatsKey }); + }, +}); + +export const deleteChat = (queryClient: QueryClient) => ({ + mutationFn: (chatId: string) => API.deleteChat(chatId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatsKey }); + }, +}); + +export const createChatMessage = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: (req: TypesGen.CreateChatMessageRequest) => + API.createChatMessage(chatId, req), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatsKey }); + }, +}); + +type EditChatMessageMutationArgs = { + messageId: number; + req: TypesGen.EditChatMessageRequest; +}; + +export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ + mutationFn: ({ messageId, req }: EditChatMessageMutationArgs) => + API.editChatMessage(chatId, messageId, req), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: chatsKey }), + queryClient.invalidateQueries({ queryKey: chatKey(chatId) }), + ]); + }, +}); + +export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ + mutationFn: () => API.interruptChat(chatId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatsKey }); + }, +}); + +export const deleteChatQueuedMessage = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: (queuedMessageId: number) => + API.deleteChatQueuedMessage(chatId, queuedMessageId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatKey(chatId) }); + }, +}); + +export const promoteChatQueuedMessage = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: (queuedMessageId: number) => + API.promoteChatQueuedMessage(chatId, queuedMessageId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: chatsKey }), + queryClient.invalidateQueries({ queryKey: chatKey(chatId) }), + ]); + }, +}); + +export const chatDiffStatusKey = (chatId: string) => + ["chats", chatId, "diff-status"] as const; + +export const chatDiffStatus = (chatId: string) => ({ + queryKey: chatDiffStatusKey(chatId), + queryFn: (): Promise => API.getChatDiffStatus(chatId), +}); + +export const chatDiffContentsKey = (chatId: string) => + ["chats", chatId, "diff-contents"] as const; + +export const chatDiffContents = (chatId: string) => ({ + queryKey: chatDiffContentsKey(chatId), + queryFn: () => API.getChatDiffContents(chatId), +}); + +export const chatModelsKey = ["chat-models"] as const; + +export const chatModels = () => ({ + queryKey: chatModelsKey, + queryFn: (): Promise => API.getChatModels(), +}); + +export const chatProviderConfigsKey = ["chat-provider-configs"] as const; + +export const chatProviderConfigs = () => ({ + queryKey: chatProviderConfigsKey, + queryFn: (): Promise => + API.getChatProviderConfigs(), +}); + +export const chatModelConfigsKey = ["chat-model-configs"] as const; + +export const chatModelConfigs = () => ({ + queryKey: chatModelConfigsKey, + queryFn: (): Promise => API.getChatModelConfigs(), +}); + +const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }), + queryClient.invalidateQueries({ queryKey: chatModelConfigsKey }), + queryClient.invalidateQueries({ queryKey: chatModelsKey }), + ]); +}; + +export const createChatProviderConfig = (queryClient: QueryClient) => ({ + mutationFn: (req: TypesGen.CreateChatProviderConfigRequest) => + API.createChatProviderConfig(req), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + +type UpdateChatProviderConfigMutationArgs = { + providerConfigId: string; + req: TypesGen.UpdateChatProviderConfigRequest; +}; + +export const updateChatProviderConfig = (queryClient: QueryClient) => ({ + mutationFn: ({ + providerConfigId, + req, + }: UpdateChatProviderConfigMutationArgs) => + API.updateChatProviderConfig(providerConfigId, req), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + +export const createChatModelConfig = (queryClient: QueryClient) => ({ + mutationFn: (req: TypesGen.CreateChatModelConfigRequest) => + API.createChatModelConfig(req), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + +type UpdateChatModelConfigMutationArgs = { + modelConfigId: string; + req: TypesGen.UpdateChatModelConfigRequest; +}; + +export const updateChatModelConfig = (queryClient: QueryClient) => ({ + mutationFn: ({ modelConfigId, req }: UpdateChatModelConfigMutationArgs) => + API.updateChatModelConfig(modelConfigId, req), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + +export const deleteChatModelConfig = (queryClient: QueryClient) => ({ + mutationFn: (modelConfigId: string) => + API.deleteChatModelConfig(modelConfigId), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index f558956ef5..cb14916586 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -38,6 +38,16 @@ export const workspaceByOwnerAndNameKey = ( name: string, ) => ["workspace", ownerUsername, name, "settings"]; +export const workspaceByIdKey = (workspaceId: string) => + ["workspace", workspaceId] as const; + +export const workspaceById = (workspaceId: string) => { + return { + queryKey: workspaceByIdKey(workspaceId), + queryFn: () => API.getWorkspace(workspaceId), + }; +}; + export const workspaceByOwnerAndName = (owner: string, name: string) => { return { queryKey: workspaceByOwnerAndNameKey(owner, name), diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 25a10286f4..66a18b9999 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -41,6 +41,12 @@ export const RBACResourceActions: Partial< read: "read boundary usage statistics", update: "upsert boundary usage statistics", }, + chat: { + create: "create a new chat", + delete: "delete a chat", + read: "read chat messages and metadata", + update: "update chat title or settings", + }, connection_log: { read: "read connection logs", update: "upsert connection log entries", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 57c2beafc4..773e47026b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -194,6 +194,11 @@ export type APIKeyScope = | "boundary_usage:delete" | "boundary_usage:read" | "boundary_usage:update" + | "chat:*" + | "chat:create" + | "chat:delete" + | "chat:read" + | "chat:update" | "coder:all" | "coder:apikeys.manage_self" | "coder:application_connect" @@ -398,6 +403,11 @@ export const APIKeyScopes: APIKeyScope[] = [ "boundary_usage:delete", "boundary_usage:read", "boundary_usage:update", + "chat:*", + "chat:create", + "chat:delete", + "chat:read", + "chat:update", "coder:all", "coder:apikeys.manage_self", "coder:application_connect", @@ -1036,6 +1046,521 @@ export interface ChangePasswordWithOneTimePasscodeRequest { readonly one_time_passcode: string; } +// From codersdk/chats.go +/** + * Chat represents a chat session with an AI agent. + */ +export interface Chat { + readonly id: string; + readonly owner_id: string; + readonly workspace_id?: string; + readonly workspace_agent_id?: string; + readonly parent_chat_id?: string; + readonly root_chat_id?: string; + readonly last_model_config_id: string; + readonly title: string; + readonly status: ChatStatus; + readonly diff_status?: ChatDiffStatus; + readonly created_at: string; + readonly updated_at: string; +} + +// From codersdk/chats.go +/** + * ChatDiffContents represents the resolved diff text for a chat. + */ +export interface ChatDiffContents { + readonly chat_id: string; + readonly provider?: string; + readonly remote_origin?: string; + readonly branch?: string; + readonly pull_request_url?: string; + readonly diff?: string; +} + +// From codersdk/chats.go +/** + * ChatDiffStatus represents cached diff status for a chat. The URL + * may point to a pull request or a branch page depending on whether + * a PR has been opened. + */ +export interface ChatDiffStatus { + readonly chat_id: string; + readonly url?: string; + readonly pull_request_state?: string; + readonly changes_requested: boolean; + readonly additions: number; + readonly deletions: number; + readonly changed_files: number; + readonly refreshed_at?: string; + readonly stale_at?: string; +} + +// From codersdk/chats.go +/** + * ChatGitChange represents a git file change detected during a chat session. + */ +export interface ChatGitChange { + readonly id: string; + readonly chat_id: string; + readonly file_path: string; + readonly change_type: string; // added, modified, deleted, renamed + readonly old_path?: string; + readonly diff_summary?: string; + readonly detected_at: string; +} + +// From codersdk/chats.go +/** + * ChatInputPart is a single user input part for creating a chat. + */ +export interface ChatInputPart { + readonly type: ChatInputPartType; + readonly text?: string; +} + +// From codersdk/chats.go +export type ChatInputPartType = "text"; + +export const ChatInputPartTypes: ChatInputPartType[] = ["text"]; + +// From codersdk/chats.go +/** + * ChatMessage represents a single message in a chat. + */ +export interface ChatMessage { + readonly id: number; + readonly chat_id: string; + readonly model_config_id?: string; + readonly created_at: string; + readonly role: string; + readonly content?: readonly ChatMessagePart[]; + readonly usage?: ChatMessageUsage; +} + +// From codersdk/chats.go +/** + * ChatMessagePart is a structured chunk of a chat message. + */ +export interface ChatMessagePart { + readonly type: ChatMessagePartType; + readonly text?: string; + readonly signature?: string; + readonly tool_call_id?: string; + readonly tool_name?: string; + readonly args?: Record; + readonly args_delta?: string; + readonly result?: Record; + readonly result_delta?: string; + readonly is_error?: boolean; + readonly source_id?: string; + readonly url?: string; + readonly title?: string; + readonly media_type?: string; + readonly data?: string; +} + +// From codersdk/chats.go +export type ChatMessagePartType = + | "file" + | "reasoning" + | "source" + | "text" + | "tool-call" + | "tool-result"; + +export const ChatMessagePartTypes: ChatMessagePartType[] = [ + "file", + "reasoning", + "source", + "text", + "tool-call", + "tool-result", +]; + +// From codersdk/chats.go +/** + * ChatMessageUsage contains token usage information for a chat message. + */ +export interface ChatMessageUsage { + readonly input_tokens?: number; + readonly output_tokens?: number; + readonly total_tokens?: number; + readonly reasoning_tokens?: number; + readonly cache_creation_tokens?: number; + readonly cache_read_tokens?: number; + readonly context_limit?: number; +} + +// From codersdk/chats.go +/** + * ChatModel represents a model in the chat model catalog. + */ +export interface ChatModel { + readonly id: string; + readonly provider: string; + readonly model: string; + readonly display_name: string; +} + +// From codersdk/chats.go +/** + * ChatModelAnthropicProviderOptions configures Anthropic provider behavior. + */ +export interface ChatModelAnthropicProviderOptions { + readonly send_reasoning?: boolean; + readonly thinking?: ChatModelAnthropicThinkingOptions; + readonly effort?: string; + readonly disable_parallel_tool_use?: boolean; +} + +// From codersdk/chats.go +/** + * ChatModelAnthropicThinkingOptions configures Anthropic thinking budget. + */ +export interface ChatModelAnthropicThinkingOptions { + readonly budget_tokens?: number; +} + +// From codersdk/chats.go +/** + * ChatModelCallConfig configures per-call model behavior defaults. + */ +export interface ChatModelCallConfig { + readonly max_output_tokens?: number; + readonly temperature?: number; + readonly top_p?: number; + readonly top_k?: number; + readonly presence_penalty?: number; + readonly frequency_penalty?: number; + readonly provider_options?: ChatModelProviderOptions; +} + +// From codersdk/chats.go +/** + * ChatModelConfig is an admin-managed model configuration. + */ +export interface ChatModelConfig { + readonly id: string; + readonly provider: string; + readonly model: string; + readonly display_name: string; + readonly enabled: boolean; + readonly is_default: boolean; + readonly context_limit: number; + readonly compression_threshold: number; + readonly model_config?: ChatModelCallConfig; + readonly created_at: string; + readonly updated_at: string; +} + +// From codersdk/chats.go +/** + * ChatModelGoogleProviderOptions configures Google provider behavior. + */ +export interface ChatModelGoogleProviderOptions { + readonly thinking_config?: ChatModelGoogleThinkingConfig; + readonly cached_content?: string; + readonly safety_settings?: readonly ChatModelGoogleSafetySetting[]; + readonly threshold?: string; +} + +// From codersdk/chats.go +/** + * ChatModelGoogleSafetySetting configures Google safety filtering. + */ +export interface ChatModelGoogleSafetySetting { + readonly category?: string; + readonly threshold?: string; +} + +// From codersdk/chats.go +/** + * ChatModelGoogleThinkingConfig configures Google thinking behavior. + */ +export interface ChatModelGoogleThinkingConfig { + readonly thinking_budget?: number; + readonly include_thoughts?: boolean; +} + +// From codersdk/chats.go +/** + * ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. + */ +export interface ChatModelOpenAICompatProviderOptions { + readonly user?: string; + readonly reasoning_effort?: string; +} + +// From codersdk/chats.go +/** + * ChatModelOpenAIProviderOptions configures OpenAI provider behavior. + */ +export interface ChatModelOpenAIProviderOptions { + readonly include?: readonly string[]; + readonly instructions?: string; + readonly logit_bias?: Record; + readonly log_probs?: boolean; + readonly top_log_probs?: number; + readonly max_tool_calls?: number; + readonly parallel_tool_calls?: boolean; + readonly user?: string; + readonly reasoning_effort?: string; + readonly reasoning_summary?: string; + readonly max_completion_tokens?: number; + readonly text_verbosity?: string; + // empty interface{} type, falling back to unknown + readonly prediction?: Record; + readonly store?: boolean; + // empty interface{} type, falling back to unknown + readonly metadata?: Record; + readonly prompt_cache_key?: string; + readonly safety_identifier?: string; + readonly service_tier?: string; + readonly structured_outputs?: boolean; + readonly strict_json_schema?: boolean; +} + +// From codersdk/chats.go +/** + * ChatModelOpenRouterProvider configures OpenRouter routing preferences. + */ +export interface ChatModelOpenRouterProvider { + readonly order?: readonly string[]; + readonly allow_fallbacks?: boolean; + readonly require_parameters?: boolean; + readonly data_collection?: string; + readonly only?: readonly string[]; + readonly ignore?: readonly string[]; + readonly quantizations?: readonly string[]; + readonly sort?: string; +} + +// From codersdk/chats.go +/** + * ChatModelOpenRouterProviderOptions configures OpenRouter provider behavior. + */ +export interface ChatModelOpenRouterProviderOptions { + readonly reasoning?: ChatModelOpenRouterReasoningOptions; + // empty interface{} type, falling back to unknown + readonly extra_body?: Record; + readonly include_usage?: boolean; + readonly logit_bias?: Record; + readonly log_probs?: boolean; + readonly parallel_tool_calls?: boolean; + readonly user?: string; + readonly provider?: ChatModelOpenRouterProvider; +} + +// From codersdk/chats.go +/** + * ChatModelOpenRouterReasoningOptions configures OpenRouter reasoning behavior. + */ +export interface ChatModelOpenRouterReasoningOptions { + readonly enabled?: boolean; + readonly exclude?: boolean; + readonly max_tokens?: number; + readonly effort?: string; +} + +// From codersdk/chats.go +/** + * ChatModelProvider represents provider availability and model results. + */ +export interface ChatModelProvider { + readonly provider: string; + readonly available: boolean; + readonly unavailable_reason?: ChatModelProviderUnavailableReason; + readonly models: readonly ChatModel[]; +} + +// From codersdk/chats.go +/** + * ChatModelProviderOptions contains typed provider-specific options. + * + * Note: Azure models use the `openai` options shape. + * Note: Bedrock models use the `anthropic` options shape. + */ +export interface ChatModelProviderOptions { + readonly openai?: ChatModelOpenAIProviderOptions; + readonly anthropic?: ChatModelAnthropicProviderOptions; + readonly google?: ChatModelGoogleProviderOptions; + readonly openaicompat?: ChatModelOpenAICompatProviderOptions; + readonly openrouter?: ChatModelOpenRouterProviderOptions; + readonly vercel?: ChatModelVercelProviderOptions; +} + +// From codersdk/chats.go +export type ChatModelProviderUnavailableReason = + | "fetch_failed" + | "missing_api_key"; + +export const ChatModelProviderUnavailableReasons: ChatModelProviderUnavailableReason[] = + ["fetch_failed", "missing_api_key"]; + +// From codersdk/chats.go +/** + * ChatModelVercelGatewayProviderOptions configures Vercel routing behavior. + */ +export interface ChatModelVercelGatewayProviderOptions { + readonly order?: readonly string[]; + readonly models?: readonly string[]; +} + +// From codersdk/chats.go +/** + * ChatModelVercelProviderOptions configures Vercel provider behavior. + */ +export interface ChatModelVercelProviderOptions { + readonly reasoning?: ChatModelVercelReasoningOptions; + readonly providerOptions?: ChatModelVercelGatewayProviderOptions; + readonly user?: string; + readonly logit_bias?: Record; + readonly logprobs?: boolean; + readonly top_logprobs?: number; + readonly parallel_tool_calls?: boolean; + // empty interface{} type, falling back to unknown + readonly extra_body?: Record; +} + +// From codersdk/chats.go +/** + * ChatModelVercelReasoningOptions configures Vercel reasoning behavior. + */ +export interface ChatModelVercelReasoningOptions { + readonly enabled?: boolean; + readonly max_tokens?: number; + readonly effort?: string; + readonly exclude?: boolean; +} + +// From codersdk/chats.go +/** + * ChatModelsResponse is the catalog returned from chat model discovery. + */ +export interface ChatModelsResponse { + readonly providers: readonly ChatModelProvider[]; +} + +// From codersdk/chats.go +/** + * ChatProviderConfig is an admin-managed provider configuration. + */ +export interface ChatProviderConfig { + readonly id: string; + readonly provider: string; + readonly display_name: string; + readonly enabled: boolean; + readonly has_api_key: boolean; + readonly base_url?: string; + readonly source: ChatProviderConfigSource; + readonly created_at?: string; + readonly updated_at?: string; +} + +// From codersdk/chats.go +export type ChatProviderConfigSource = "database" | "env_preset" | "supported"; + +export const ChatProviderConfigSources: ChatProviderConfigSource[] = [ + "database", + "env_preset", + "supported", +]; + +// From codersdk/chats.go +/** + * ChatQueuedMessage represents a queued message waiting to be processed. + */ +export interface ChatQueuedMessage { + readonly id: number; + readonly chat_id: string; + readonly content: readonly ChatMessagePart[]; + readonly created_at: string; +} + +// From codersdk/chats.go +export type ChatStatus = + | "completed" + | "error" + | "paused" + | "pending" + | "running" + | "waiting"; + +export const ChatStatuses: ChatStatus[] = [ + "completed", + "error", + "paused", + "pending", + "running", + "waiting", +]; + +// From codersdk/chats.go +/** + * ChatStreamError represents an error event in the stream. + */ +export interface ChatStreamError { + readonly message: string; +} + +// From codersdk/chats.go +/** + * ChatStreamEvent represents a real-time update for chat streaming. + */ +export interface ChatStreamEvent { + readonly type: ChatStreamEventType; + readonly chat_id: string; + readonly message?: ChatMessage; + readonly message_part?: ChatStreamMessagePart; + readonly status?: ChatStreamStatus; + readonly error?: ChatStreamError; + readonly queued_messages?: readonly ChatQueuedMessage[]; +} + +// From codersdk/chats.go +export type ChatStreamEventType = + | "error" + | "message" + | "message_part" + | "queue_update" + | "status"; + +export const ChatStreamEventTypes: ChatStreamEventType[] = [ + "error", + "message", + "message_part", + "queue_update", + "status", +]; + +// From codersdk/chats.go +/** + * ChatStreamMessagePart is a streamed message part update. + */ +export interface ChatStreamMessagePart { + readonly role?: string; + readonly part: ChatMessagePart; +} + +// From codersdk/chats.go +/** + * ChatStreamStatus represents an updated chat status. + */ +export interface ChatStreamStatus { + readonly status: ChatStatus; +} + +// From codersdk/chats.go +/** + * ChatWithMessages is a chat along with its messages. + */ +export interface ChatWithMessages { + readonly chat: Chat; + readonly messages: readonly ChatMessage[]; + readonly queued_messages: readonly ChatQueuedMessage[]; +} + // From codersdk/client.go /** * CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry @@ -1166,6 +1691,62 @@ export interface ConvertLoginRequest { readonly password: string; } +// From codersdk/chats.go +/** + * CreateChatMessageRequest is the request to add a message to a chat. + */ +export interface CreateChatMessageRequest { + readonly content: readonly ChatInputPart[]; + readonly model_config_id?: string; +} + +// From codersdk/chats.go +/** + * CreateChatMessageResponse is the response from adding a message to a chat. + */ +export interface CreateChatMessageResponse { + readonly message?: ChatMessage; + readonly queued_message?: ChatQueuedMessage; + readonly queued: boolean; +} + +// From codersdk/chats.go +/** + * CreateChatModelConfigRequest creates a chat model config. + */ +export interface CreateChatModelConfigRequest { + readonly provider: string; + readonly model: string; + readonly display_name?: string; + readonly enabled?: boolean; + readonly is_default?: boolean; + readonly context_limit?: number; + readonly compression_threshold?: number; + readonly model_config?: ChatModelCallConfig; +} + +// From codersdk/chats.go +/** + * CreateChatProviderConfigRequest creates a chat provider config. + */ +export interface CreateChatProviderConfigRequest { + readonly provider: string; + readonly display_name?: string; + readonly api_key?: string; + readonly base_url?: string; + readonly enabled?: boolean; +} + +// From codersdk/chats.go +/** + * CreateChatRequest is the request to create a new chat. + */ +export interface CreateChatRequest { + readonly content: readonly ChatInputPart[]; + readonly workspace_id?: string; + readonly model_config_id?: string; +} + // From codersdk/users.go export interface CreateFirstUserRequest { readonly email: string; @@ -1809,6 +2390,7 @@ export interface DeploymentValues { readonly support?: SupportConfig; readonly enable_authz_recording?: boolean; readonly external_auth?: SerpentStruct; + readonly external_auth_github_default_provider_enable?: boolean; readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; @@ -1887,6 +2469,14 @@ export interface DynamicParametersResponse { readonly parameters: readonly PreviewParameter[]; } +// From codersdk/chats.go +/** + * EditChatMessageRequest is the request to edit a user message in a chat. + */ +export interface EditChatMessageRequest { + readonly content: readonly ChatInputPart[]; +} + // From codersdk/externalauth.go export type EnhancedExternalAuthProvider = | "azure-devops" @@ -1933,6 +2523,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = + | "agents" | "auto-fill-parameters" | "example" | "mcp-server-http" @@ -1942,6 +2533,7 @@ export type Experiment = | "workspace-usage"; export const Experiments: Experiment[] = [ + "agents", "auto-fill-parameters", "example", "mcp-server-http", @@ -4094,6 +4686,7 @@ export type RBACResource = | "assign_role" | "audit_log" | "boundary_usage" + | "chat" | "connection_log" | "crypto_key" | "debug_info" @@ -4139,6 +4732,7 @@ export const RBACResources: RBACResource[] = [ "assign_role", "audit_log", "boundary_usage", + "chat", "connection_log", "crypto_key", "debug_info", @@ -5591,6 +6185,40 @@ export interface UpdateAppearanceConfig { readonly announcement_banners: readonly BannerConfig[]; } +// From codersdk/chats.go +/** + * UpdateChatModelConfigRequest updates a chat model config. + */ +export interface UpdateChatModelConfigRequest { + readonly provider?: string; + readonly model?: string; + readonly display_name?: string; + readonly enabled?: boolean; + readonly is_default?: boolean; + readonly context_limit?: number; + readonly compression_threshold?: number; + readonly model_config?: ChatModelCallConfig; +} + +// From codersdk/chats.go +/** + * UpdateChatProviderConfigRequest updates a chat provider config. + */ +export interface UpdateChatProviderConfigRequest { + readonly display_name?: string; + readonly api_key?: string; + readonly base_url?: string; + readonly enabled?: boolean; +} + +// From codersdk/chats.go +/** + * UpdateChatRequest is the request to update a chat. + */ +export interface UpdateChatRequest { + readonly title: string; +} + // From codersdk/updatecheck.go /** * UpdateCheckResponse contains information on the latest release of Coder. diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 2e9cbefc6a..76e6abb305 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -33,7 +33,7 @@ const DialogOverlay: React.FC< const dialogVariants = cva( `fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6 border border-solid bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg - translate-x-[-50%] translate-y-[-50%] + translate-x-[-50%] translate-y-[-50%] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index 10ab700b59..423a74645e 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -406,7 +406,10 @@ const markdownStyles: Interpolation = (theme: Theme) => ({ }, "& .prismjs": { - background: theme.palette.background.paper, + background: + theme.palette.mode === "dark" + ? colors.zinc[950] + : theme.palette.background.paper, borderRadius: 8, padding: "16px 24px", overflowX: "auto", diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index 8599a471d4..66e6c951b6 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -5,18 +5,30 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "utils/cn"; -export const ScrollArea: React.FC< - React.ComponentPropsWithRef -> = ({ className, children, ...props }) => { +interface ScrollAreaProps + extends React.ComponentPropsWithRef { + scrollBarClassName?: string; + viewportClassName?: string; +} + +export const ScrollArea: React.FC = ({ + className, + scrollBarClassName, + viewportClassName, + children, + ...props +}) => { return ( - + {children} - + ); diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index f0798b24c2..2514d41000 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -39,7 +39,7 @@ export const SelectTrigger: React.FC = ({ > {children} - + ); diff --git a/site/src/components/ai-elements/conversation.stories.tsx b/site/src/components/ai-elements/conversation.stories.tsx new file mode 100644 index 0000000000..d9eb86993c --- /dev/null +++ b/site/src/components/ai-elements/conversation.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Conversation, ConversationItem } from "./conversation"; +import { Message, MessageContent } from "./message"; +import { Shimmer } from "./shimmer"; +import { Thinking } from "./thinking"; + +const meta: Meta = { + title: "components/ai-elements/Conversation", + component: Conversation, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const ConversationWithMessages: Story = { + render: () => { + const userItemProps = { role: "user" as const }; + const assistantItemProps = { role: "assistant" as const }; + + return ( + + + + + Check why `git fetch` is failing in this workspace. + + + + + + +
+ + Inspecting auth state and recent command output before + suggesting a fix. + +
+ The remote command failed because external auth needs to be + refreshed. +
+
+
+
+
+
+ ); + }, +}; + +export const LoadingState: Story = { + render: () => { + const assistantItemProps = { role: "assistant" as const }; + + return ( + + + + + + Thinking... + + + + + + ); + }, +}; diff --git a/site/src/components/ai-elements/conversation.tsx b/site/src/components/ai-elements/conversation.tsx new file mode 100644 index 0000000000..561f8e8df3 --- /dev/null +++ b/site/src/components/ai-elements/conversation.tsx @@ -0,0 +1,42 @@ +import type { ComponentPropsWithRef } from "react"; +import { cn } from "utils/cn"; + +type ConversationProps = ComponentPropsWithRef<"div">; + +export const Conversation = ({ + className, + ref, + ...props +}: ConversationProps) => { + return ( +
+ ); +}; + +type ConversationItemProps = Omit, "role"> & { + role: "user" | "assistant"; +}; + +export const ConversationItem = ({ + className, + role, + ref, + ...props +}: ConversationItemProps) => { + return ( +
+ ); +}; diff --git a/site/src/components/ai-elements/index.ts b/site/src/components/ai-elements/index.ts new file mode 100644 index 0000000000..78860414d4 --- /dev/null +++ b/site/src/components/ai-elements/index.ts @@ -0,0 +1,7 @@ +export { ConversationItem } from "./conversation"; +export { Message, MessageContent } from "./message"; +export type { ModelSelectorOption } from "./model-selector"; +export { ModelSelector } from "./model-selector"; +export { Response } from "./response"; +export { Shimmer } from "./shimmer"; +export { Tool } from "./tool"; diff --git a/site/src/components/ai-elements/message.tsx b/site/src/components/ai-elements/message.tsx new file mode 100644 index 0000000000..5a50ca7ae2 --- /dev/null +++ b/site/src/components/ai-elements/message.tsx @@ -0,0 +1,29 @@ +import type { ComponentPropsWithRef } from "react"; +import { cn } from "utils/cn"; + +type MessageProps = ComponentPropsWithRef<"div">; + +export const Message = ({ className, ref, ...props }: MessageProps) => { + return ( +
+ ); +}; + +type MessageContentProps = ComponentPropsWithRef<"div">; + +export const MessageContent = ({ + className, + ref, + ...props +}: MessageContentProps) => { + return ( +
+ ); +}; diff --git a/site/src/components/ai-elements/model-selector.stories.tsx b/site/src/components/ai-elements/model-selector.stories.tsx new file mode 100644 index 0000000000..331ae15d19 --- /dev/null +++ b/site/src/components/ai-elements/model-selector.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { ModelSelector, type ModelSelectorOption } from "./model-selector"; + +const openAIModels: ModelSelectorOption[] = [ + { + id: "openai/gpt-4o", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + contextLimit: 128_000, + }, + { + id: "openai/gpt-4o-mini", + provider: "openai", + model: "gpt-4o-mini", + displayName: "GPT-4o Mini", + contextLimit: 128_000, + }, + { + id: "openai/o3-mini", + provider: "openai", + model: "o3-mini", + displayName: "o3-mini", + contextLimit: 200_000, + }, +]; + +const anthropicModels: ModelSelectorOption[] = [ + { + id: "anthropic/claude-sonnet-4", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + displayName: "Claude Sonnet 4", + contextLimit: 200_000, + }, + { + id: "anthropic/claude-haiku-3.5", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + displayName: "Claude 3.5 Haiku", + contextLimit: 200_000, + }, +]; + +const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels]; + +const meta: Meta = { + title: "components/ai-elements/ModelSelector", + component: ModelSelector, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + options: openAIModels, + value: "", + onValueChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Single provider stories +// --------------------------------------------------------------------------- + +export const Default: Story = {}; + +export const WithSelectedValue: Story = { + args: { + value: "openai/gpt-4o", + }, +}; + +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a model…", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + value: "openai/gpt-4o", + }, +}; + +// --------------------------------------------------------------------------- +// Multiple providers (grouped) +// --------------------------------------------------------------------------- + +export const MultipleProviders: Story = { + args: { + options: allModels, + value: "anthropic/claude-sonnet-4", + }, +}; + +export const MultipleProvidersWithCustomLabel: Story = { + args: { + options: allModels, + value: "", + formatProviderLabel: (provider: string) => { + const labels: Record = { + openai: "OpenAI", + anthropic: "Anthropic", + }; + return labels[provider] ?? provider; + }, + }, +}; + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +export const NoOptions: Story = { + args: { + options: [], + value: "", + }, +}; + +// --------------------------------------------------------------------------- +// Play function – selection interaction +// --------------------------------------------------------------------------- + +export const SelectsModel: Story = { + args: { + options: openAIModels, + value: "", + onValueChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Open the popover by clicking the trigger. + const trigger = canvas.getByRole("combobox"); + await userEvent.click(trigger); + + // The dropdown should appear with model options. + const listbox = await within(document.body).findByRole("listbox"); + const option = within(listbox).getByText("GPT-4o Mini"); + await userEvent.click(option); + + expect(args.onValueChange).toHaveBeenCalledWith("openai/gpt-4o-mini"); + }, +}; diff --git a/site/src/components/ai-elements/model-selector.tsx b/site/src/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000000..5f7d2f5148 --- /dev/null +++ b/site/src/components/ai-elements/model-selector.tsx @@ -0,0 +1,164 @@ +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { type FC, useMemo } from "react"; +import { cn } from "utils/cn"; + +export interface ModelSelectorOption { + id: string; + provider: string; + model: string; + displayName: string; + contextLimit?: number; +} + +interface ModelSelectorProps { + options: readonly ModelSelectorOption[]; + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; + emptyMessage?: string; + formatProviderLabel?: (provider: string) => string; + className?: string; + dropdownSide?: "top" | "bottom" | "left" | "right"; + dropdownAlign?: "start" | "center" | "end"; + contentClassName?: string; +} + +const defaultFormatProviderLabel = (provider: string): string => { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return "Unknown"; + } + return `${normalized[0].toUpperCase()}${normalized.slice(1)}`; +}; + +const formatContextLimit = (tokens: number): string => { + if (tokens >= 1_000_000) { + const m = tokens / 1_000_000; + return `${Number.isInteger(m) ? m : m.toFixed(1)}M context window`; + } + const k = Math.round(tokens / 1_000); + return `${k}K context window`; +}; + +export const ModelSelector: FC = ({ + options, + value, + onValueChange, + disabled = false, + placeholder = "Select model", + emptyMessage = "No models found.", + formatProviderLabel = defaultFormatProviderLabel, + className, + dropdownSide = "bottom", + dropdownAlign = "start", + contentClassName, +}) => { + const selectedModel = useMemo( + () => options.find((option) => option.id === value), + [options, value], + ); + const optionsByProvider = useMemo(() => { + const grouped = new Map(); + + for (const option of options) { + const providerOptions = grouped.get(option.provider); + if (providerOptions) { + providerOptions.push(option); + continue; + } + grouped.set(option.provider, [option]); + } + + return Array.from(grouped.entries()); + }, [options]); + const showProviderHeading = optionsByProvider.length > 1; + const isDisabled = disabled || options.length === 0; + + return ( + + ); +}; + +interface ModelOptionItemProps { + option: ModelSelectorOption; + providerLabel: string; +} + +const ModelOptionItem: FC = ({ + option, + providerLabel, +}) => { + return ( + + + {option.displayName} + + + + {option.displayName} via {providerLabel} + + {option.contextLimit != null && option.contextLimit > 0 && ( + + {formatContextLimit(option.contextLimit)} + + )} + + + ); +}; diff --git a/site/src/components/ai-elements/response.stories.tsx b/site/src/components/ai-elements/response.stories.tsx new file mode 100644 index 0000000000..cef2259010 --- /dev/null +++ b/site/src/components/ai-elements/response.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Response } from "./response"; + +const sampleMarkdown = ` +## Plan update + +I checked the auth flow and found two issues: + +1. Missing provider fallback for unknown IDs. +2. Error text was not surfaced in the UI. + +See [external auth docs](https://coder.com/docs) for expected behavior. + +Inline command example: \`git fetch origin\`. + +\`\`\`ts +export const ensureProviderLabel = (provider: string) => { + return provider.trim() || "Git provider"; +}; +\`\`\` +`; + +const sampleFileMarkdown = ` +\`\`\`go +package auth + +import "errors" + +func ValidateToken(token string) error { + if token == "" { + return errors.New("token is empty") + } + return nil +} +\`\`\` +`; + +const meta: Meta = { + title: "components/ai-elements/Response", + component: Response, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + children: sampleMarkdown, + }, +}; + +export default meta; +type Story = StoryObj; + +export const MarkdownAndLinks: Story = {}; + +export const FencedFileBlock: Story = { + args: { + children: sampleFileMarkdown, + }, +}; + +export const MarkdownAndLinksLight: Story = { + globals: { + theme: "light", + }, +}; diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx new file mode 100644 index 0000000000..1866d22a7e --- /dev/null +++ b/site/src/components/ai-elements/response.tsx @@ -0,0 +1,155 @@ +import { useTheme } from "@emotion/react"; +import { + File as FileViewer, + type SupportedLanguages, +} from "@pierre/diffs/react"; +import type { ComponentPropsWithRef, ReactNode } from "react"; +import { useMemo } from "react"; +import { type Components, Streamdown } from "streamdown"; +import { cn } from "utils/cn"; + +interface ResponseProps extends Omit, "children"> { + children: string; +} + +const fileViewerCSS = + "pre, [data-line], [data-diffs-header] { background-color: transparent !important; }"; + +const fileViewerTheme = { + light: "github-light", + dark: "github-dark-high-contrast", +} as const; + +type HastNode = { + type?: string; + value?: string; + children?: HastNode[]; + tagName?: string; + properties?: { + className?: string[] | string; + }; +}; + +type MarkdownComponentProps = { + href?: string; + children?: ReactNode; + node?: HastNode; +}; + +type FileViewerThemeType = "light" | "dark"; + +/** + * Recursively extracts text from a HAST node tree. This is plain + * data (not React elements), so it's reliable to traverse. + */ +const getHastText = (node: HastNode | null | undefined): string => { + if (!node) { + return ""; + } + if (node.type === "text") return node.value ?? ""; + if (node.children) return node.children.map(getHastText).join(""); + return ""; +}; + +const getClassNames = (className: string[] | string | undefined): string[] => { + if (typeof className === "string") { + return className.split(/\s+/).filter(Boolean); + } + if (!Array.isArray(className)) { + return []; + } + return className.filter( + (classToken): classToken is string => typeof classToken === "string", + ); +}; + +const createComponents = ( + fileViewerThemeType: FileViewerThemeType, + viewerTheme: (typeof fileViewerTheme)[FileViewerThemeType], +): Components => { + return { + a: ({ href, children }: MarkdownComponentProps) => ( + + {children} + + ), + // Inline code only β€” fenced blocks are handled by the pre override. + code: ({ children }: MarkdownComponentProps) => ( + + {children} + + ), + // Fenced code blocks: extract language and content from the HAST + // node directly (plain data), then render with FileViewer. + pre: ({ node }: MarkdownComponentProps) => { + const codeChild = node?.children?.[0]; + if (codeChild?.tagName === "code") { + const classes = getClassNames(codeChild.properties?.className); + const langClass = classes.find((c: string) => + c.startsWith("language-"), + ); + const lang = langClass ? langClass.replace("language-", "") : "text"; + const content = getHastText(codeChild).trimEnd(); + if (content) { + return ( +
+ +
+ ); + } + } + return
{node?.children?.map?.(() => null)}
; + }, + }; +}; + +export const Response = ({ + className, + children, + ref, + ...props +}: ResponseProps) => { + const theme = useTheme(); + const fileViewerThemeType: FileViewerThemeType = + theme.palette.mode === "dark" ? "dark" : "light"; + const viewerTheme = fileViewerTheme[fileViewerThemeType]; + const components = useMemo( + () => createComponents(fileViewerThemeType, viewerTheme), + [fileViewerThemeType, viewerTheme], + ); + + return ( +
+ + {children} + +
+ ); +}; diff --git a/site/src/components/ai-elements/runtimeTypeUtils.ts b/site/src/components/ai-elements/runtimeTypeUtils.ts new file mode 100644 index 0000000000..50f47e6c47 --- /dev/null +++ b/site/src/components/ai-elements/runtimeTypeUtils.ts @@ -0,0 +1,25 @@ +export const asRecord = (value: unknown): Record | null => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +}; + +export const asString = (value: unknown): string => + typeof value === "string" ? value : ""; + +export const asNumber = ( + value: unknown, + options?: { readonly parseString?: boolean }, +): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (options?.parseString && typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +}; diff --git a/site/src/components/ai-elements/shimmer.tsx b/site/src/components/ai-elements/shimmer.tsx new file mode 100644 index 0000000000..d40fe1a603 --- /dev/null +++ b/site/src/components/ai-elements/shimmer.tsx @@ -0,0 +1,77 @@ +import type { MotionProps } from "motion/react"; +import { MotionConfig, motion } from "motion/react"; +import type { CSSProperties, ElementType, JSX } from "react"; +import { memo, useMemo } from "react"; +import { cn } from "utils/cn"; + +type MotionHTMLProps = MotionProps & Record; + +// Cache motion components at module level to avoid creating during render +const motionComponentCache = new Map< + keyof JSX.IntrinsicElements, + React.ComponentType +>(); + +const getMotionComponent = (element: keyof JSX.IntrinsicElements) => { + let component = motionComponentCache.get(element); + if (!component) { + component = motion.create(element); + motionComponentCache.set(element, component); + } + return component; +}; + +interface TextShimmerProps { + children: string; + as?: ElementType; + className?: string; + duration?: number; + spread?: number; +} + +const ShimmerComponent = ({ + children, + as: Component = "p", + className, + duration = 2, + spread = 2, +}: TextShimmerProps) => { + const MotionComponent = getMotionComponent( + Component as keyof JSX.IntrinsicElements, + ); + + const dynamicSpread = useMemo( + () => (children?.length ?? 0) * spread, + [children, spread], + ); + + return ( + + + {children} + + + ); +}; + +export const Shimmer = memo(ShimmerComponent); diff --git a/site/src/components/ai-elements/thinking.tsx b/site/src/components/ai-elements/thinking.tsx new file mode 100644 index 0000000000..fb506ef282 --- /dev/null +++ b/site/src/components/ai-elements/thinking.tsx @@ -0,0 +1,17 @@ +import type { ComponentPropsWithRef } from "react"; +import { cn } from "utils/cn"; + +type ThinkingProps = ComponentPropsWithRef<"div">; + +export const Thinking = ({ className, ref, ...props }: ThinkingProps) => { + return ( +
+ ); +}; diff --git a/site/src/components/ai-elements/tool.stories.tsx b/site/src/components/ai-elements/tool.stories.tsx new file mode 100644 index 0000000000..fc210dd966 --- /dev/null +++ b/site/src/components/ai-elements/tool.stories.tsx @@ -0,0 +1,470 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { Tool } from "./tool"; + +const executeCommand = "git fetch origin"; +const meta: Meta = { + title: "components/ai-elements/Tool", + component: Tool, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + name: "execute", + args: { command: executeCommand }, + status: "completed", + }, + parameters: { + reactRouter: reactRouterParameters({ + routing: { path: "/" }, + }), + }, +}; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Execute stories +// --------------------------------------------------------------------------- + +export const ExecuteRunning: Story = { + args: { + status: "running", + result: { + output: "remote: Enumerating objects: 12, done.\nFetching origin...", + }, + }, +}; + +export const ExecuteSuccess: Story = { + args: { + result: { + output: + "From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui", + }, + }, +}; + +export const ExecuteAuthRequired: Story = { + args: { + result: { + auth_required: true, + provider_display_name: "GitHub", + authenticate_url: "https://coder.example.com/external-auth/github", + output: + "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { + name: "Authenticate with GitHub", + }); + expect(button).toBeInTheDocument(); + expect( + canvas.getByRole("link", { name: "Open authentication link" }), + ).toHaveAttribute("href", "https://coder.example.com/external-auth/github"); + + const openSpy = spyOn(window, "open").mockImplementation(() => null); + await userEvent.click(button); + expect(openSpy).toHaveBeenCalledWith( + "https://coder.example.com/external-auth/github", + "_blank", + "width=900,height=600", + ); + openSpy.mockRestore(); + }, +}; + +// --------------------------------------------------------------------------- +// WaitForExternalAuth stories +// --------------------------------------------------------------------------- + +export const WaitForExternalAuthRunning: Story = { + args: { + name: "wait_for_external_auth", + status: "running", + result: { + provider_display_name: "GitHub", + authenticated: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("Waiting for GitHub authentication..."), + ).toBeInTheDocument(); + }, +}; + +export const WaitForExternalAuthAuthenticated: Story = { + args: { + name: "wait_for_external_auth", + status: "completed", + result: { + provider_display_name: "GitHub", + authenticated: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Authenticated with GitHub")).toBeInTheDocument(); + }, +}; + +export const WaitForExternalAuthTimedOut: Story = { + args: { + name: "wait_for_external_auth", + status: "completed", + result: { + provider_display_name: "GitHub", + timed_out: true, + }, + }, +}; + +export const WaitForExternalAuthError: Story = { + args: { + name: "wait_for_external_auth", + status: "error", + isError: true, + result: { + provider_display_name: "GitHub", + error: "Authentication failed: token exchange was rejected.", + }, + }, +}; + +// --------------------------------------------------------------------------- +// Subagent stories +// --------------------------------------------------------------------------- + +export const SubagentRunning: Story = { + args: { + name: "spawn_agent", + status: "running", + args: { + title: "Workspace diagnostics", + prompt: "Collect logs and summarize why startup failed.", + }, + result: { + chat_id: "child-chat-id", + title: "Workspace diagnostics", + status: "pending", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/child-chat-id", + ); + }, +}; + +export const SubagentAwaitLinkCard: Story = { + args: { + name: "wait_agent", + args: { title: "Sub-agent" }, + result: { chat_id: "child-chat-id", status: "pending" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/child-chat-id", + ); + }, +}; + +export const SubagentMessageLinkCard: Story = { + args: { + name: "message_agent", + args: { title: "Sub-agent" }, + result: { chat_id: "child-chat-id", status: "pending" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/child-chat-id", + ); + }, +}; + +export const SubagentCompletedDelegatedPending: Story = { + args: { + name: "spawn_agent", + args: undefined, + result: { chat_id: "child-chat-id", status: "pending" }, + status: "completed", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/child-chat-id", + ); + expect( + canvas.getByRole("button", { name: /Spawned Sub-agent/ }), + ).toBeInTheDocument(); + expect(canvasElement.querySelector(".animate-spin")).toBeNull(); + }, +}; + +export const SubagentStreamOverrideStatus: Story = { + args: { + name: "spawn_agent", + args: undefined, + result: { chat_id: "child-chat-id", status: "pending" }, + status: "completed", + subagentStatusOverrides: new Map([["child-chat-id", "completed"]]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawned Sub-agent/ }), + ).toBeInTheDocument(); + expect(canvasElement.querySelector(".animate-spin")).toBeNull(); + }, +}; + +export const SubagentNoErrorWhenCompleted: Story = { + args: { + name: "spawn_agent", + args: undefined, + result: { + chat_id: "child-chat-id", + status: "completed", + error: "provider metadata noise", + }, + status: "error", + isError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvasElement.querySelector(".animate-spin")).toBeNull(); + expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull(); + expect( + canvas.getByRole("button", { name: /Spawned Sub-agent/ }), + ).toBeInTheDocument(); + }, +}; + +export const SubagentAwaitPreferredTitle: Story = { + args: { + name: "wait_agent", + args: { title: "Fallback title" }, + result: { + chat_id: "child-chat-id", + title: "Delegated child title", + status: "completed", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Delegated child title")).toBeInTheDocument(); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/child-chat-id", + ); + expect(canvas.queryByText("Fallback title")).toBeNull(); + }, +}; + +export const SubagentRequestMetadata: Story = { + args: { + name: "spawn_agent", + args: undefined, + result: { + chat_id: "child-chat-id", + status: "completed", + request_id: "request-123", + duration_ms: 1530, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Worked for 2s")).toBeInTheDocument(); + }, +}; + +export const SubagentAwaitRequestMetadata: Story = { + args: { + name: "wait_agent", + args: undefined, + result: { + chat_id: "child-chat-id", + status: "completed", + request_id: "request-123", + duration_ms: 1530, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Worked for 2s")).toBeInTheDocument(); + }, +}; + +export const SubagentMessageRequestMetadata: Story = { + args: { + name: "message_agent", + args: undefined, + result: { + chat_id: "child-chat-id", + status: "completed", + request_id: "request-123", + duration_ms: 1530, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Worked for 2s")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// ListTemplates stories +// --------------------------------------------------------------------------- + +export const ListTemplatesRunning: Story = { + args: { + name: "list_templates", + status: "running", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listing templates…")).toBeInTheDocument(); + }, +}; + +export const ListTemplatesSuccess: Story = { + args: { + name: "list_templates", + status: "completed", + result: { + templates: [ + { + id: "template-1", + name: "go-template", + display_name: "Go Development", + description: "A template for Go development with VS Code", + }, + { + id: "template-2", + name: "python-template", + description: "Python development environment", + }, + ], + count: 2, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 2 templates")).toBeInTheDocument(); + const toggle = canvas.getByRole("button"); + await userEvent.click(toggle); + expect(canvas.getByText("Go Development")).toBeInTheDocument(); + expect(canvas.getByText("python-template")).toBeInTheDocument(); + }, +}; + +export const ListTemplatesSingle: Story = { + args: { + name: "list_templates", + status: "completed", + result: { + templates: [ + { + id: "template-1", + name: "go-template", + description: "Go development template", + }, + ], + count: 1, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 1 template")).toBeInTheDocument(); + }, +}; + +export const ListTemplatesEmpty: Story = { + args: { + name: "list_templates", + status: "completed", + result: { + templates: [], + count: 0, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listing templates…")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// ChatSummarized stories +// --------------------------------------------------------------------------- + +export const ChatSummarized: Story = { + args: { + name: "chat_summarized", + args: undefined, + result: { summary: "Compaction summary text." }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByRole("button", { name: "Summarized" }); + expect(toggle).toBeInTheDocument(); + expect(canvas.queryByText("Compaction summary text.")).toBeNull(); + + await userEvent.click(toggle); + + expect( + await canvas.findByText((text) => + text.includes("Compaction summary text."), + ), + ).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// SubagentTerminate stories +// --------------------------------------------------------------------------- + +export const SubagentTerminate: Story = { + args: { + name: "close_agent", + args: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Terminated/)).toBeInTheDocument(); + expect(canvas.getByText("Sub-agent")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Generic fallback stories +// --------------------------------------------------------------------------- + +export const TaskNameGenericRendering: Story = { + args: { + name: "task", + args: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("task")).toBeInTheDocument(); + expect(canvas.queryByRole("link", { name: "View agent" })).toBeNull(); + }, +}; diff --git a/site/src/components/ai-elements/tool/ChatSummarizedTool.tsx b/site/src/components/ai-elements/tool/ChatSummarizedTool.tsx new file mode 100644 index 0000000000..33baf8a072 --- /dev/null +++ b/site/src/components/ai-elements/tool/ChatSummarizedTool.tsx @@ -0,0 +1,68 @@ +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; +import { Response } from "../response"; +import { ToolCollapsible } from "./ToolCollapsible"; +import type { ToolStatus } from "./utils"; + +/** + * Collapsed-by-default rendering for `chat_summarized` tool calls. + * Shows "Summarized" and reveals the summary only when expanded. + */ +export const ChatSummarizedTool: React.FC<{ + summary: string; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ summary, status, isError, errorMessage }) => { + const hasSummary = summary.trim().length > 0; + const isRunning = status === "running"; + + return ( + + + {isRunning ? "Summarizing…" : "Summarized"} + + {isError && ( + + + + + + {errorMessage || "Failed to summarize chat"} + + + )} + {isRunning && ( + + )} + + } + > + +
+ {summary} +
+
+
+ ); +}; diff --git a/site/src/components/ai-elements/tool/CreateWorkspaceTool.tsx b/site/src/components/ai-elements/tool/CreateWorkspaceTool.tsx new file mode 100644 index 0000000000..62983b2253 --- /dev/null +++ b/site/src/components/ai-elements/tool/CreateWorkspaceTool.tsx @@ -0,0 +1,83 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { Link } from "react-router"; +import { cn } from "utils/cn"; +import { asRecord, asString, type ToolStatus } from "./utils"; + +/** + * Rendering for `create_workspace` tool calls. + * + * Shows "Creating workspace…" while running, and "Created " when + * complete with a link to view the workspace. + */ +export const CreateWorkspaceTool: React.FC<{ + workspaceName: string; + resultJson: string; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ workspaceName, resultJson, status, isError, errorMessage }) => { + const isRunning = status === "running"; + let rec: Record | null = null; + if (resultJson) { + try { + const parsed = JSON.parse(resultJson); + rec = asRecord(parsed); + } catch { + // resultJson might already be an object or invalid JSON + rec = asRecord(resultJson); + } + } + const ownerName = rec ? asString(rec.owner_name) : ""; + const wsName = rec ? asString(rec.workspace_name) : workspaceName; + const workspaceLink = ownerName && wsName ? `/@${ownerName}/${wsName}` : null; + + const label = isRunning + ? "Creating workspace…" + : wsName + ? `Created ${wsName}` + : "Created workspace"; + + return ( +
+
+ + {label} + + {isError && ( + + + + + + {errorMessage || "Failed to create workspace"} + + + )} + {isRunning && ( + + )} + {workspaceLink && !isRunning && ( + e.stopPropagation()} + className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" + aria-label="View workspace" + > + + + )} +
+
+ ); +}; diff --git a/site/src/components/ai-elements/tool/EditFilesTool.tsx b/site/src/components/ai-elements/tool/EditFilesTool.tsx new file mode 100644 index 0000000000..11825c4900 --- /dev/null +++ b/site/src/components/ai-elements/tool/EditFilesTool.tsx @@ -0,0 +1,107 @@ +import { useTheme } from "@emotion/react"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; +import { + DIFFS_FONT_STYLE, + type EditFilesFileEntry, + getDiffViewerOptions, + type ToolStatus, +} from "./utils"; + +/** + * Collapsed-by-default rendering for `edit_files` tool calls. + * Shows "Edited " (or "Edited N files") with a chevron; + * expanding reveals a unified diff for each file. + */ +export const EditFilesTool: React.FC<{ + files: EditFilesFileEntry[]; + diffs: (FileDiffMetadata | null)[]; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ files, diffs, status, isError, errorMessage }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const isRunning = status === "running"; + const hasDiffs = diffs.some((d) => d !== null); + + let label: string; + if (isRunning) { + if (files.length === 1) { + label = `Editing ${files[0].path.split("/").pop() || files[0].path}…`; + } else if (files.length > 1) { + label = `Editing ${files.length} files…`; + } else { + label = "Editing files…"; + } + } else if (files.length === 1) { + const filename = files[0].path.split("/").pop() || files[0].path; + label = `Edited ${filename}`; + } else if (files.length > 1) { + label = `Edited ${files.length} files`; + } else { + label = "Edited files"; + } + + return ( + + + {label} + + {isError && ( + + + + + + {errorMessage || "Failed to edit files"} + + + )} + {isRunning && ( + + )} + + } + > +
+ {diffs.map((diff, i) => + diff ? ( + + + + ) : null, + )} +
+
+ ); +}; diff --git a/site/src/components/ai-elements/tool/ExecuteTool.tsx b/site/src/components/ai-elements/tool/ExecuteTool.tsx new file mode 100644 index 0000000000..2ac2478524 --- /dev/null +++ b/site/src/components/ai-elements/tool/ExecuteTool.tsx @@ -0,0 +1,228 @@ +import { Button } from "components/Button/Button"; +import { CopyButton } from "components/CopyButton/CopyButton"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + CheckIcon, + ChevronDownIcon, + CircleAlertIcon, + ExternalLinkIcon, + LoaderIcon, +} from "lucide-react"; +import type React from "react"; +import { useRef, useState } from "react"; +import { cn } from "utils/cn"; +import { + BORDER_BG_STYLE, + COLLAPSED_OUTPUT_HEIGHT, + type ToolStatus, +} from "./utils"; + +/** + * Specialized rendering for `execute` tool calls. Shows the command + * in a terminal-style block with a copy button. Output is shown in a + * collapsed preview (~3 lines) with an expand chevron at the bottom. + */ +export const ExecuteTool: React.FC<{ + command: string; + output: string; + status: ToolStatus; + isError: boolean; +}> = ({ command, output, status, isError }) => { + const [expanded, setExpanded] = useState(false); + const outputRef = useRef(null); + const hasOutput = output.length > 0; + const isRunning = status === "running"; + + // Check whether the output overflows the collapsed height so we + // know if we need to show the expand toggle at all. + const [overflows, setOverflows] = useState(false); + const measureRef = (node: HTMLPreElement | null) => { + outputRef.current = node; + if (node) { + setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT); + } + }; + + return ( +
+ {/* Header: $ command + copy button */} +
+
+ + $ + + + {command} + +
+
+ {isRunning && ( + + )} + + + +
+
+ + {/* Output preview / expanded */} + {hasOutput && ( + <> +
+ +
+							{output}
+						
+
+ + {/* Expand / collapse toggle at the bottom */} + {overflows && ( +
setExpanded((v) => !v)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setExpanded((v) => !v); + } + }} + className="flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary" + aria-label={expanded ? "Collapse output" : "Expand output"} + > + +
+ )} + + )} +
+ ); +}; + +export const ExecuteAuthRequiredTool: React.FC<{ + command: string; + output: string; + authenticateURL: string; + providerLabel: string; +}> = ({ command, output, authenticateURL, providerLabel }) => { + const hasCommand = command.trim().length > 0; + const hasOutput = output.trim().length > 0; + + return ( +
+
+ + + Authenticate with {providerLabel} to continue this command. + +
+
+ + + + Open authentication link + +
+ {hasCommand && ( +
+ + $ {command} + +
+ )} + {hasOutput && ( + +
+						{output}
+					
+
+ )} +
+ ); +}; + +export const WaitForExternalAuthTool: React.FC<{ + providerLabel: string; + status: ToolStatus; + authenticated: boolean; + timedOut: boolean; + isError: boolean; + errorMessage?: string; +}> = ({ + providerLabel, + status, + authenticated, + timedOut, + isError, + errorMessage, +}) => { + const isRunning = status === "running"; + let label = `Waiting for ${providerLabel} authentication...`; + let icon: React.ReactNode = ( + + ); + if (isError) { + label = + errorMessage || + `Failed while waiting for ${providerLabel} authentication`; + icon = ( + + ); + } else if (timedOut) { + label = `Timed out waiting for ${providerLabel} authentication`; + icon = ( + + ); + } else if (authenticated && !isRunning) { + label = `Authenticated with ${providerLabel}`; + icon = ; + } + + return ( +
+
+ {icon} + {label} +
+
+ ); +}; diff --git a/site/src/components/ai-elements/tool/ListTemplatesTool.tsx b/site/src/components/ai-elements/tool/ListTemplatesTool.tsx new file mode 100644 index 0000000000..0a90b3787f --- /dev/null +++ b/site/src/components/ai-elements/tool/ListTemplatesTool.tsx @@ -0,0 +1,98 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { Link } from "react-router"; +import { cn } from "utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; +import { asRecord, asString, type ToolStatus } from "./utils"; + +/** + * Collapsed-by-default rendering for `list_templates` tool calls. Shows + * "Listed N templates" with a chevron; expanding reveals the template list. + */ +export const ListTemplatesTool: React.FC<{ + templates: unknown[]; + count: number; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ templates, count, status, isError, errorMessage }) => { + const hasContent = templates.length > 0; + const isRunning = status === "running"; + + const label = + isRunning || count === 0 + ? "Listing templates…" + : count === 1 + ? "Listed 1 template" + : `Listed ${count} templates`; + + return ( + + + {label} + + {isError && ( + + + + + + {errorMessage || "Failed to list templates"} + + + )} + {isRunning && ( + + )} + + } + > +
+ {templates.map((template, index) => { + const rec = asRecord(template); + if (!rec) { + return null; + } + const name = asString(rec.name); + const displayName = asString(rec.display_name); + const templateName = displayName || name || `Template ${index + 1}`; + + if (!name) { + return ( +
+ {templateName} +
+ ); + } + + return ( +
+ e.stopPropagation()} + className="flex items-center gap-1.5 text-sm text-content-secondary opacity-50 transition-opacity hover:opacity-100" + > + {templateName} + + +
+ ); + })} +
+
+ ); +}; diff --git a/site/src/components/ai-elements/tool/ReadFileTool.tsx b/site/src/components/ai-elements/tool/ReadFileTool.tsx new file mode 100644 index 0000000000..3e47f6f8ed --- /dev/null +++ b/site/src/components/ai-elements/tool/ReadFileTool.tsx @@ -0,0 +1,81 @@ +import { useTheme } from "@emotion/react"; +import { File as FileViewer } from "@pierre/diffs/react"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; +import { + DIFFS_FONT_STYLE, + getFileViewerOptionsMinimal, + type ToolStatus, +} from "./utils"; + +/** + * Collapsed-by-default rendering for `read_file` tool calls. Shows + * "Read " with a chevron; expanding reveals the file viewer. + */ +export const ReadFileTool: React.FC<{ + path: string; + content: string; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ path, content, status, isError, errorMessage }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const hasContent = content.length > 0; + const isRunning = status === "running"; + + return ( + + + Read {path.split("/").pop() || path} + + {isError && ( + + + + + + {errorMessage || "Failed to read file"} + + + )} + {isRunning && ( + + )} + + } + > + + + + + ); +}; diff --git a/site/src/components/ai-elements/tool/ReadTemplateTool.tsx b/site/src/components/ai-elements/tool/ReadTemplateTool.tsx new file mode 100644 index 0000000000..8b86b568df --- /dev/null +++ b/site/src/components/ai-elements/tool/ReadTemplateTool.tsx @@ -0,0 +1,54 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; +import type { ToolStatus } from "./utils"; + +/** + * Simple inline rendering for `read_template` tool calls. + * Shows "Read template " with no expandable content. + */ +export const ReadTemplateTool: React.FC<{ + templateName: string; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ templateName, status, isError, errorMessage }) => { + const isRunning = status === "running"; + + const label = isRunning + ? "Reading template…" + : templateName + ? `Read template ${templateName}` + : "Read template"; + + return ( +
+ + {label} + + {isError && ( + + + + + + {errorMessage || "Failed to read template"} + + + )} + {isRunning && ( + + )} +
+ ); +}; diff --git a/site/src/components/ai-elements/tool/SubagentTool.tsx b/site/src/components/ai-elements/tool/SubagentTool.tsx new file mode 100644 index 0000000000..d9ae0d3c92 --- /dev/null +++ b/site/src/components/ai-elements/tool/SubagentTool.tsx @@ -0,0 +1,184 @@ +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + BotIcon, + ChevronDownIcon, + CircleAlertIcon, + ExternalLinkIcon, + LoaderIcon, +} from "lucide-react"; +import type React from "react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { cn } from "utils/cn"; +import { Response } from "../response"; +import { + isSubagentSuccessStatus, + shortDurationMs, + type ToolStatus, +} from "./utils"; + +const SUBAGENT_VERBS: Record = { + spawn_agent: { completed: "Spawned ", running: "Spawning " }, + wait_agent: { completed: "Waited for ", running: "Waiting for " }, + message_agent: { completed: "Messaged ", running: "Messaging " }, + close_agent: { completed: "Terminated ", running: "Terminating " }, +}; + +/** + * Resolves a sub-agent status string and tool-level status into a + * display icon. The sub-agent status in the tool result is a + * snapshot from when the tool returned and may be stale (e.g. a + * background sub-agent records "pending" forever). The icon is + * therefore driven primarily by the tool-call status itself. + */ +const SubagentStatusIcon: React.FC<{ + subagentStatus: string; + toolStatus: ToolStatus; + isError: boolean; +}> = ({ subagentStatus, toolStatus, isError }) => { + const subagentCompleted = isSubagentSuccessStatus(subagentStatus); + if (isError && !subagentCompleted) { + return ( + + ); + } + if (toolStatus === "error") { + return ( + + ); + } + if (toolStatus === "running") { + return ( + + ); + } + return ; +}; + +/** + * Specialized rendering for delegated sub-agent tool calls. + * Shows a clickable header row with the sub-agent title, status + * icon, and a chevron to expand the prompt / report below. A + * "View Agent" link navigates to the sub-agent chat. + */ +export const SubagentTool: React.FC<{ + toolName: string; + title: string; + chatId: string; + subagentStatus: string; + prompt?: string; + message?: string; + durationMs?: number; + report?: string; + toolStatus: ToolStatus; + isError: boolean; +}> = ({ + toolName, + title, + chatId, + subagentStatus, + prompt, + message, + durationMs, + report, + toolStatus, + isError, +}) => { + const [expanded, setExpanded] = useState(false); + const hasPrompt = Boolean(prompt?.trim()); + const hasMessage = Boolean(message?.trim()); + const hasReport = Boolean(report?.trim()); + const hasExpandableContent = hasPrompt || hasMessage || hasReport; + const durationLabel = shortDurationMs(durationMs); + + return ( +
+
hasExpandableContent && setExpanded((v) => !v)} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && hasExpandableContent) { + setExpanded((v) => !v); + } + }} + className={cn( + "flex items-center gap-2", + hasExpandableContent && "cursor-pointer", + )} + > + + + {SUBAGENT_VERBS[toolName]?.[ + toolStatus === "completed" ? "completed" : "running" + ] ?? ""} + {title} + {chatId && ( + e.stopPropagation()} + className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" + aria-label="View agent" + > + + + )} + + {durationLabel && ( + + Worked for {durationLabel} + + )} + {hasExpandableContent && ( + + )} +
+ + {expanded && hasPrompt && ( + +
+ {prompt ?? ""} +
+
+ )} + + {expanded && hasMessage && ( + +
+ {message ?? ""} +
+
+ )} + + {expanded && hasReport && ( + +
+ {report ?? ""} +
+
+ )} +
+ ); +}; diff --git a/site/src/components/ai-elements/tool/Tool.tsx b/site/src/components/ai-elements/tool/Tool.tsx new file mode 100644 index 0000000000..29592a3edc --- /dev/null +++ b/site/src/components/ai-elements/tool/Tool.tsx @@ -0,0 +1,483 @@ +import { useTheme } from "@emotion/react"; +import { FileDiff, File as FileViewer } from "@pierre/diffs/react"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import type { ComponentPropsWithRef, FC } from "react"; +import { memo } from "react"; +import { cn } from "utils/cn"; +import { ChatSummarizedTool } from "./ChatSummarizedTool"; +import { CreateWorkspaceTool } from "./CreateWorkspaceTool"; +import { EditFilesTool } from "./EditFilesTool"; +import { + ExecuteAuthRequiredTool, + ExecuteTool as ExecuteToolComponent, + WaitForExternalAuthTool, +} from "./ExecuteTool"; +import { ListTemplatesTool } from "./ListTemplatesTool"; +import { ReadFileTool } from "./ReadFileTool"; +import { ReadTemplateTool } from "./ReadTemplateTool"; +import { SubagentTool } from "./SubagentTool"; +import { ToolIcon } from "./ToolIcon"; +import { ToolLabel } from "./ToolLabel"; +import { + asNumber, + asRecord, + asString, + buildEditDiff, + DIFFS_FONT_STYLE, + formatResultOutput, + getDiffViewerOptions, + getFileContentForViewer, + getFileViewerOptions, + getFileViewerOptionsNoHeader, + getWriteFileDiff, + isSubagentSuccessStatus, + mapSubagentStatusToToolStatus, + parseArgs, + parseEditFilesArgs, + type ToolStatus, + toProviderLabel, +} from "./utils"; +import { WriteFileTool } from "./WriteFileTool"; + +interface ToolProps extends Omit, "children"> { + name: string; + status?: ToolStatus; + args?: unknown; + result?: unknown; + isError?: boolean; + /** Maps sub-agent chat IDs to their titles, built from spawn tool results. */ + subagentTitles?: Map; + /** Maps sub-agent chat IDs to real-time status updates from stream events. */ + subagentStatusOverrides?: Map; +} + +// Props passed to each tool-specific renderer function. Each renderer +// only computes the expensive values it needs from the raw args/result. +type ToolRendererProps = { + name: string; + status: ToolStatus; + args: unknown; + result: unknown; + isError: boolean; + subagentTitles?: Map; + subagentStatusOverrides?: Map; +}; + +// --------------------------------------------------------------------------- +// Tool-specific renderer functions +// --------------------------------------------------------------------------- + +const ExecuteRenderer: FC = ({ + status, + args, + result, + isError, +}) => { + const parsedArgs = parseArgs(args); + const command = parsedArgs ? asString(parsedArgs.command) : ""; + const rec = asRecord(result); + const output = rec ? asString(rec.output).trim() : ""; + const authRequired = rec ? Boolean(rec.auth_required) : false; + const authenticateURL = rec ? asString(rec.authenticate_url).trim() : ""; + const providerLabel = toProviderLabel( + rec ? asString(rec.provider_display_name).trim() : "", + rec ? asString(rec.provider_id).trim() : "", + rec ? asString(rec.provider_type).trim() : "", + ); + + if (authRequired && authenticateURL) { + return ( + + ); + } + return ( + + ); +}; + +const WaitForExternalAuthRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const providerLabel = toProviderLabel( + rec ? asString(rec.provider_display_name).trim() : "", + rec ? asString(rec.provider_id).trim() : "", + rec ? asString(rec.provider_type).trim() : "", + ); + const authenticated = rec ? Boolean(rec.authenticated) : false; + const timedOut = rec ? Boolean(rec.timed_out) : false; + const errorMessage = rec ? asString(rec.error || rec.message) : ""; + + return ( + + ); +}; + +const ReadFileRenderer: FC = ({ + status, + args, + result, + isError, +}) => { + const parsedArgs = parseArgs(args); + const path = parsedArgs ? asString(parsedArgs.path).trim() : ""; + const rec = asRecord(result); + const content = rec ? asString(rec.content).trim() : ""; + + return ( + + ); +}; + +const WriteFileRenderer: FC = ({ + status, + args, + result, + isError, +}) => { + const parsedArgs = parseArgs(args); + const path = parsedArgs ? asString(parsedArgs.path).trim() : ""; + const rec = asRecord(result); + const writeFileDiff = getWriteFileDiff("write_file", args); + + return ( + + ); +}; + +const EditFilesRenderer: FC = ({ + status, + args, + result, + isError, +}) => { + const rec = asRecord(result); + const editFiles = parseEditFilesArgs(args); + const editDiffs = editFiles.map((file) => + buildEditDiff(file.path, file.edits), + ); + + return ( + + ); +}; + +// Once the tool finishes, the result becomes a JSON object +// with workspace metadata. +const CreateWorkspaceRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const wsName = rec ? asString(rec.workspace_name) : ""; + const resultJson = rec ? JSON.stringify(rec, null, 2) : ""; + + return ( + + ); +}; + +const SubagentRenderer: FC = ({ + name, + status, + args, + result, + isError, + subagentTitles, + subagentStatusOverrides, +}) => { + const parsedArgs = parseArgs(args); + const rec = asRecord(result); + // wait_agent and message_agent have chat_id in args, so + // check both result and args. + const chatId = + (rec ? asString(rec.chat_id) : "") || + (parsedArgs ? asString(parsedArgs.chat_id) : ""); + const resultSubagentStatus = rec + ? asString(rec.status || rec.subagent_status) + : ""; + const streamSubagentStatus = + (chatId && subagentStatusOverrides?.get(chatId)) || ""; + const subagentStatus = streamSubagentStatus || resultSubagentStatus; + const durationMs = rec + ? asNumber(rec.duration_ms, { parseString: true }) + : undefined; + const report = rec ? asString(rec.report) : ""; + const prompt = parsedArgs ? asString(parsedArgs.prompt) : ""; + const subagentMessage = parsedArgs ? asString(parsedArgs.message) : ""; + const title = + (rec ? asString(rec.title) : "") || + (parsedArgs ? asString(parsedArgs.title) : "") || + (chatId && subagentTitles?.get(chatId)) || + "Sub-agent"; + const subagentCompleted = isSubagentSuccessStatus(subagentStatus); + const subagentToolStatus = mapSubagentStatusToToolStatus( + subagentStatus, + status, + ); + const subagentIsError = + subagentToolStatus === "error" || + ((status === "error" || isError) && !subagentCompleted); + + return ( + + ); +}; + +const ListTemplatesRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const templates = rec && Array.isArray(rec.templates) ? rec.templates : []; + const count = rec + ? (asNumber(rec.count, { parseString: true }) ?? templates.length) + : 0; + + return ( + + ); +}; + +const ReadTemplateRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const templateRec = rec ? asRecord(rec.template) : undefined; + const name = templateRec + ? asString(templateRec.display_name) || asString(templateRec.name) + : ""; + + return ( + + ); +}; + +const ChatSummarizedRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const summary = + (rec ? asString(rec.summary) : "") || + (typeof result === "string" ? result : ""); + + return ( + + ); +}; + +// Generic fallback renderer β€” only path that needs theme, diff +// viewers, and file content helpers. +const GenericToolRenderer: FC = ({ + name, + status, + args, + result, + isError, +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const resultOutput = formatResultOutput(result); + const fileContent = getFileContentForViewer(name, args, result); + const writeFileDiff = getWriteFileDiff(name, args); + const fileViewerOpts = getFileViewerOptions(isDark); + const fileContentOptions = fileContent + ? { + ...fileViewerOpts, + disableFileHeader: fileContent.disableHeader, + disableLineNumbers: fileContent.disableLineNumbers, + } + : fileViewerOpts; + + return ( + <> +
+ + +
+ {writeFileDiff ? ( + + + + ) : fileContent ? ( + + + + ) : ( + resultOutput && ( + + + + ) + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Renderer lookup map β€” maps tool names to their specialized renderers. +// --------------------------------------------------------------------------- + +const toolRenderers: Record> = { + execute: ExecuteRenderer, + wait_for_external_auth: WaitForExternalAuthRenderer, + read_file: ReadFileRenderer, + write_file: WriteFileRenderer, + edit_files: EditFilesRenderer, + create_workspace: CreateWorkspaceRenderer, + list_templates: ListTemplatesRenderer, + read_template: ReadTemplateRenderer, + spawn_agent: SubagentRenderer, + wait_agent: SubagentRenderer, + message_agent: SubagentRenderer, + close_agent: SubagentRenderer, + chat_summarized: ChatSummarizedRenderer, +}; + +// --------------------------------------------------------------------------- +// Public Tool component β€” single wrapper div + map dispatch. +// --------------------------------------------------------------------------- + +export const Tool = memo( + ({ + className, + name, + status = "completed", + args, + result, + isError = false, + subagentTitles, + subagentStatusOverrides, + ref, + ...props + }: ToolProps) => { + const Renderer = toolRenderers[name] ?? GenericToolRenderer; + + return ( +
+ +
+ ); + }, +); + +Tool.displayName = "Tool"; diff --git a/site/src/components/ai-elements/tool/ToolCollapsible.tsx b/site/src/components/ai-elements/tool/ToolCollapsible.tsx new file mode 100644 index 0000000000..b10ef634b7 --- /dev/null +++ b/site/src/components/ai-elements/tool/ToolCollapsible.tsx @@ -0,0 +1,59 @@ +import { ChevronDownIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { useState } from "react"; +import { cn } from "utils/cn"; + +interface ToolCollapsibleProps { + children: ReactNode; + header: ReactNode; + hasContent?: boolean; + defaultExpanded?: boolean; + className?: string; + headerClassName?: string; +} + +export const ToolCollapsible: FC = ({ + children, + header, + hasContent = true, + defaultExpanded = false, + className, + headerClassName, +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + return ( +
+ {hasContent ? ( +
setExpanded(!expanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpanded(!expanded); + } + }} + className={cn( + "flex items-center gap-2 cursor-pointer", + headerClassName, + )} + > + {header} + +
+ ) : ( +
+ {header} +
+ )} + {expanded && hasContent && children} +
+ ); +}; diff --git a/site/src/components/ai-elements/tool/ToolIcon.tsx b/site/src/components/ai-elements/tool/ToolIcon.tsx new file mode 100644 index 0000000000..94c5601746 --- /dev/null +++ b/site/src/components/ai-elements/tool/ToolIcon.tsx @@ -0,0 +1,35 @@ +import { + BotIcon, + FileIcon, + FilePenIcon, + PlusCircleIcon, + TerminalIcon, + WrenchIcon, +} from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; + +export const ToolIcon: React.FC<{ name: string; isError: boolean }> = ({ + name, + isError, +}) => { + const color = isError ? "text-content-destructive" : "text-content-secondary"; + const base = cn("h-4 w-4 shrink-0", color); + switch (name) { + case "execute": + return ; + case "read_file": + case "list_templates": + case "read_template": + return ; + case "write_file": + case "edit_files": + return ; + case "create_workspace": + return ; + case "chat_summarized": + return ; + default: + return ; + } +}; diff --git a/site/src/components/ai-elements/tool/ToolLabel.tsx b/site/src/components/ai-elements/tool/ToolLabel.tsx new file mode 100644 index 0000000000..c5a8b4250f --- /dev/null +++ b/site/src/components/ai-elements/tool/ToolLabel.tsx @@ -0,0 +1,156 @@ +import type React from "react"; +import { asRecord, asString, parseArgs } from "./utils"; + +export const ToolLabel: React.FC<{ + name: string; + args: unknown; + result: unknown; +}> = ({ name, args, result }) => { + const parsed = parseArgs(args); + const parsedResult = asRecord(result); + + switch (name) { + case "execute": { + const command = parsed ? asString(parsed.command) : ""; + if (command) { + return ( + + {command} + + ); + } + return ( + + Running command + + ); + } + case "read_file": + return ( + + Reading file… + + ); + case "write_file": { + const path = parsed ? asString(parsed.path) : ""; + if (path) { + return ( + + {path} + + ); + } + return ( + + Writing file + + ); + } + case "edit_files": { + const files = parsed?.files; + if (Array.isArray(files) && files.length === 1) { + const path = asString((files[0] as Record)?.path); + if (path) { + return ( + + {path} + + ); + } + } + return ( + + Editing files + + ); + } + case "create_workspace": { + const wsName = parsedResult ? asString(parsedResult.workspace_name) : ""; + if (wsName) { + return ( + + Created {wsName} + + ); + } + return ( + + Creating workspace + + ); + } + case "list_templates": { + const count = parsedResult + ? ((parsedResult.count as number | undefined) ?? 0) + : 0; + return ( + + {count === 0 + ? "Listing templates…" + : count === 1 + ? "Listed 1 template" + : `Listed ${count} templates`} + + ); + } + case "read_template": { + const templateRec = parsedResult + ? asRecord(parsedResult.template) + : undefined; + const tmplName = templateRec + ? asString(templateRec.display_name) || asString(templateRec.name) + : ""; + return ( + + {tmplName ? `Read template ${tmplName}` : "Reading template…"} + + ); + } + case "spawn_agent": { + const spawnTitle = + (parsedResult ? asString(parsedResult.title) : "") || + (parsed ? asString(parsed.title) : ""); + return ( + + {spawnTitle ? `Spawning ${spawnTitle}` : "Spawning sub-agent…"} + + ); + } + case "wait_agent": { + const awaitTitle = + (parsedResult ? asString(parsedResult.title) : "") || + (parsed ? asString(parsed.title) : ""); + return ( + + {awaitTitle ? `Waiting for ${awaitTitle}` : "Waiting for sub-agent…"} + + ); + } + case "message_agent": { + const msgTitle = + (parsedResult ? asString(parsedResult.title) : "") || + (parsed ? asString(parsed.title) : ""); + return ( + + {msgTitle ? `Messaging ${msgTitle}` : "Messaging sub-agent…"} + + ); + } + case "close_agent": + return ( + + Terminating sub-agent + + ); + case "chat_summarized": + return ( + + Summarized + + ); + default: + return ( + {name} + ); + } +}; diff --git a/site/src/components/ai-elements/tool/WriteFileTool.tsx b/site/src/components/ai-elements/tool/WriteFileTool.tsx new file mode 100644 index 0000000000..8bf8e93c1e --- /dev/null +++ b/site/src/components/ai-elements/tool/WriteFileTool.tsx @@ -0,0 +1,84 @@ +import { useTheme } from "@emotion/react"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleAlertIcon, LoaderIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; +import { + DIFFS_FONT_STYLE, + getDiffViewerOptions, + type ToolStatus, +} from "./utils"; + +/** + * Collapsed-by-default rendering for `write_file` tool calls. Shows + * "Wrote " with a chevron; expanding reveals the unified diff. + */ +export const WriteFileTool: React.FC<{ + path: string; + diff: FileDiffMetadata | null; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ path, diff, status, isError, errorMessage }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const hasDiff = diff !== null; + const isRunning = status === "running"; + + const filename = path.split("/").pop() || path; + const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`; + + return ( + + + {label} + + {isError && ( + + + + + + {errorMessage || "Failed to write file"} + + + )} + {isRunning && ( + + )} + + } + > + {hasDiff && ( + + + + )} + + ); +}; diff --git a/site/src/components/ai-elements/tool/index.ts b/site/src/components/ai-elements/tool/index.ts new file mode 100644 index 0000000000..865a6403eb --- /dev/null +++ b/site/src/components/ai-elements/tool/index.ts @@ -0,0 +1 @@ +export { Tool } from "./Tool"; diff --git a/site/src/components/ai-elements/tool/utils.test.ts b/site/src/components/ai-elements/tool/utils.test.ts new file mode 100644 index 0000000000..fdb36a07a4 --- /dev/null +++ b/site/src/components/ai-elements/tool/utils.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from "vitest"; +import { + BORDER_BG_STYLE, + buildEditDiff, + buildWriteFileDiff, + COLLAPSED_OUTPUT_HEIGHT, + COLLAPSED_REPORT_HEIGHT, + DIFFS_FONT_STYLE, + diffViewerCSS, + fileViewerCSS, + formatResultOutput, + getDiffViewerOptions, + getFileContentForViewer, + getFileViewerOptions, + getFileViewerOptionsMinimal, + getFileViewerOptionsNoHeader, + getWriteFileDiff, + isSubagentRunningStatus, + isSubagentSuccessStatus, + mapSubagentStatusToToolStatus, + normalizeStatus, + parseArgs, + parseEditFilesArgs, + shortDurationMs, + toProviderLabel, +} from "./utils"; + +describe("toProviderLabel", () => { + it("returns displayName when provided", () => { + expect(toProviderLabel("GitHub", "gh-id", "oauth")).toBe("GitHub"); + }); + + it("falls back to providerID when displayName is empty", () => { + expect(toProviderLabel("", "gh-id", "oauth")).toBe("gh-id"); + }); + + it("falls back to providerType when displayName and ID are empty", () => { + expect(toProviderLabel("", "", "oauth")).toBe("oauth"); + }); + + it("returns default label when all are empty", () => { + expect(toProviderLabel("", "", "")).toBe("Git provider"); + }); +}); + +describe("shortDurationMs", () => { + it("returns empty string for undefined", () => { + expect(shortDurationMs(undefined)).toBe(""); + }); + + it("returns empty string for negative values", () => { + expect(shortDurationMs(-1)).toBe(""); + expect(shortDurationMs(-1000)).toBe(""); + }); + + it("returns 0s for zero milliseconds", () => { + expect(shortDurationMs(0)).toBe("0s"); + }); + + it("formats sub-second durations", () => { + expect(shortDurationMs(500)).toBe("1s"); + expect(shortDurationMs(100)).toBe("0s"); + }); + + it("formats seconds", () => { + expect(shortDurationMs(1000)).toBe("1s"); + expect(shortDurationMs(30_000)).toBe("30s"); + expect(shortDurationMs(59_000)).toBe("59s"); + }); + + it("formats minutes", () => { + expect(shortDurationMs(60_000)).toBe("1m"); + expect(shortDurationMs(300_000)).toBe("5m"); + expect(shortDurationMs(3_540_000)).toBe("59m"); + }); + + it("formats hours", () => { + expect(shortDurationMs(3_600_000)).toBe("1h"); + expect(shortDurationMs(7_200_000)).toBe("2h"); + }); +}); + +describe("normalizeStatus", () => { + it("lowercases and trims", () => { + expect(normalizeStatus(" COMPLETED ")).toBe("completed"); + }); + + it("handles already-normalized input", () => { + expect(normalizeStatus("running")).toBe("running"); + }); + + it("handles empty string", () => { + expect(normalizeStatus("")).toBe(""); + }); +}); + +describe("isSubagentSuccessStatus", () => { + it("returns true for completed", () => { + expect(isSubagentSuccessStatus("completed")).toBe(true); + }); + + it("returns true for reported", () => { + expect(isSubagentSuccessStatus("reported")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isSubagentSuccessStatus("COMPLETED")).toBe(true); + expect(isSubagentSuccessStatus(" Reported ")).toBe(true); + }); + + it("returns false for other statuses", () => { + expect(isSubagentSuccessStatus("running")).toBe(false); + expect(isSubagentSuccessStatus("error")).toBe(false); + expect(isSubagentSuccessStatus("")).toBe(false); + }); +}); + +describe("isSubagentRunningStatus", () => { + it("returns true for running statuses", () => { + expect(isSubagentRunningStatus("pending")).toBe(true); + expect(isSubagentRunningStatus("running")).toBe(true); + expect(isSubagentRunningStatus("awaiting")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isSubagentRunningStatus("RUNNING")).toBe(true); + expect(isSubagentRunningStatus(" Pending ")).toBe(true); + }); + + it("returns false for non-running statuses", () => { + expect(isSubagentRunningStatus("completed")).toBe(false); + expect(isSubagentRunningStatus("error")).toBe(false); + expect(isSubagentRunningStatus("")).toBe(false); + }); +}); + +describe("mapSubagentStatusToToolStatus", () => { + it("returns fallback for empty status", () => { + expect(mapSubagentStatusToToolStatus("", "running")).toBe("running"); + expect(mapSubagentStatusToToolStatus(" ", "error")).toBe("error"); + }); + + it("maps success statuses to completed", () => { + expect(mapSubagentStatusToToolStatus("completed", "running")).toBe( + "completed", + ); + expect(mapSubagentStatusToToolStatus("reported", "running")).toBe( + "completed", + ); + }); + + it("maps running statuses to running when fallback is not completed", () => { + expect(mapSubagentStatusToToolStatus("pending", "running")).toBe("running"); + expect(mapSubagentStatusToToolStatus("running", "error")).toBe("running"); + expect(mapSubagentStatusToToolStatus("awaiting", "running")).toBe( + "running", + ); + }); + + it("preserves completed fallback even with running subagent status", () => { + expect(mapSubagentStatusToToolStatus("pending", "completed")).toBe( + "completed", + ); + expect(mapSubagentStatusToToolStatus("running", "completed")).toBe( + "completed", + ); + }); + + it("maps waiting to completed", () => { + expect(mapSubagentStatusToToolStatus("waiting", "running")).toBe( + "completed", + ); + }); + + it("maps terminated to completed", () => { + expect(mapSubagentStatusToToolStatus("terminated", "running")).toBe( + "completed", + ); + }); + + it("maps error to error", () => { + expect(mapSubagentStatusToToolStatus("error", "running")).toBe("error"); + }); + + it("returns fallback for unknown statuses", () => { + expect(mapSubagentStatusToToolStatus("unknown-status", "running")).toBe( + "running", + ); + expect(mapSubagentStatusToToolStatus("banana", "error")).toBe("error"); + }); +}); + +describe("parseArgs", () => { + it("returns null for falsy values", () => { + expect(parseArgs(null)).toBeNull(); + expect(parseArgs(undefined)).toBeNull(); + expect(parseArgs("")).toBeNull(); + expect(parseArgs(0)).toBeNull(); + }); + + it("parses a JSON string into a record", () => { + expect(parseArgs('{"key": "value"}')).toEqual({ key: "value" }); + }); + + it("returns null for invalid JSON strings", () => { + expect(parseArgs("not json")).toBeNull(); + }); + + it("returns null for JSON strings that parse to non-objects", () => { + expect(parseArgs('"just a string"')).toBeNull(); + expect(parseArgs("42")).toBeNull(); + expect(parseArgs("[1, 2, 3]")).toBeNull(); + }); + + it("returns object args directly", () => { + const obj = { path: "/foo.ts", content: "hello" }; + expect(parseArgs(obj)).toEqual(obj); + }); + + it("returns null for arrays", () => { + expect(parseArgs([1, 2, 3])).toBeNull(); + }); +}); + +describe("formatResultOutput", () => { + it("returns null for null and undefined", () => { + expect(formatResultOutput(null)).toBeNull(); + expect(formatResultOutput(undefined)).toBeNull(); + }); + + it("returns trimmed string or null for empty", () => { + expect(formatResultOutput(" hello ")).toBe("hello"); + expect(formatResultOutput("")).toBeNull(); + expect(formatResultOutput(" ")).toBeNull(); + }); + + it("extracts output field from record", () => { + expect(formatResultOutput({ output: " some output " })).toBe( + "some output", + ); + }); + + it("extracts content field from record when output is empty", () => { + expect(formatResultOutput({ content: "file content" })).toBe( + "file content", + ); + }); + + it("prefers output over content", () => { + expect( + formatResultOutput({ output: "cmd output", content: "file content" }), + ).toBe("cmd output"); + }); + + it("falls back to JSON.stringify when output and content are empty", () => { + // Both output and content are empty strings after trim, so + // the function falls through to JSON.stringify the record. + const result = formatResultOutput({ output: "", content: "" }); + expect(result).toBe(JSON.stringify({ output: "", content: "" }, null, 2)); + }); + + it("falls back to JSON.stringify for objects without output/content", () => { + const result = formatResultOutput({ status: "ok", code: 0 }); + expect(result).toBe(JSON.stringify({ status: "ok", code: 0 }, null, 2)); + }); + + it("returns String representation for non-object/non-string primitives", () => { + expect(formatResultOutput(42)).toBe("42"); + expect(formatResultOutput(true)).toBe("true"); + }); +}); + +describe("getDiffViewerOptions", () => { + it("returns dark theme options", () => { + const opts = getDiffViewerOptions(true); + expect(opts.themeType).toBe("dark"); + expect(opts.theme).toBe("github-dark-high-contrast"); + expect(opts.diffStyle).toBe("unified"); + expect(opts.diffIndicators).toBe("bars"); + expect(opts.overflow).toBe("scroll"); + expect(opts.unsafeCSS).toBe(diffViewerCSS); + }); + + it("returns light theme options", () => { + const opts = getDiffViewerOptions(false); + expect(opts.themeType).toBe("light"); + expect(opts.theme).toBe("github-light"); + }); +}); + +describe("getFileViewerOptions", () => { + it("returns dark theme options", () => { + const opts = getFileViewerOptions(true); + expect(opts.themeType).toBe("dark"); + expect(opts.theme).toBe("github-dark-high-contrast"); + expect(opts.overflow).toBe("scroll"); + expect(opts.unsafeCSS).toBe(fileViewerCSS); + }); + + it("returns light theme options", () => { + const opts = getFileViewerOptions(false); + expect(opts.themeType).toBe("light"); + expect(opts.theme).toBe("github-light"); + }); +}); + +describe("getFileViewerOptionsNoHeader", () => { + it("extends base options with disableFileHeader", () => { + const opts = getFileViewerOptionsNoHeader(true); + expect(opts.disableFileHeader).toBe(true); + expect(opts.themeType).toBe("dark"); + }); +}); + +describe("getFileViewerOptionsMinimal", () => { + it("extends base options with disableFileHeader and disableLineNumbers", () => { + const opts = getFileViewerOptionsMinimal(false); + expect(opts.disableFileHeader).toBe(true); + expect(opts.disableLineNumbers).toBe(true); + expect(opts.themeType).toBe("light"); + }); +}); + +describe("getFileContentForViewer", () => { + it("returns null for unsupported tool names", () => { + expect(getFileContentForViewer("write_file", {}, {})).toBeNull(); + expect(getFileContentForViewer("search", {}, {})).toBeNull(); + }); + + describe("execute tool", () => { + it("returns output with shell path and disabled header/line numbers", () => { + const result = getFileContentForViewer( + "execute", + {}, + { output: "ls -la" }, + ); + expect(result).toEqual({ + path: "output.sh", + content: "ls -la", + disableHeader: true, + disableLineNumbers: true, + }); + }); + + it("returns null when result is not a record", () => { + expect(getFileContentForViewer("execute", {}, "string")).toBeNull(); + }); + + it("returns null when output is empty", () => { + expect( + getFileContentForViewer("execute", {}, { output: " " }), + ).toBeNull(); + }); + }); + + describe("read_file tool", () => { + it("returns path and content from args and result", () => { + const args = { path: "/src/main.ts" }; + const result = { content: "const x = 1;" }; + const out = getFileContentForViewer("read_file", args, result); + expect(out).toEqual({ + path: "/src/main.ts", + content: "const x = 1;", + }); + }); + + it("parses JSON string args", () => { + const args = JSON.stringify({ path: "/foo.ts" }); + const result = { content: "hello" }; + expect(getFileContentForViewer("read_file", args, result)).toEqual({ + path: "/foo.ts", + content: "hello", + }); + }); + + it("returns null when path is missing", () => { + expect( + getFileContentForViewer("read_file", {}, { content: "hello" }), + ).toBeNull(); + }); + + it("returns null when content is empty", () => { + expect( + getFileContentForViewer("read_file", { path: "/x" }, { content: "" }), + ).toBeNull(); + }); + + it("returns null when result is not a record", () => { + expect( + getFileContentForViewer("read_file", { path: "/x" }, "not record"), + ).toBeNull(); + }); + }); +}); + +describe("buildWriteFileDiff", () => { + it("returns a FileDiffMetadata for new file content", () => { + const diff = buildWriteFileDiff( + "src/hello.ts", + "const x = 1;\nconst y = 2;\n", + ); + expect(diff).not.toBeNull(); + expect(diff!.name).toContain("hello.ts"); + }); + + it("returns null for empty content", () => { + expect(buildWriteFileDiff("foo.ts", "")).toBeNull(); + }); + + it("handles content without trailing newline", () => { + const diff = buildWriteFileDiff("foo.ts", "single line"); + expect(diff).not.toBeNull(); + }); + + it("handles content that is only a newline", () => { + // A single newline splits into ["", ""], trailing empty is popped, + // leaving [""] which is one line. + const diff = buildWriteFileDiff("foo.ts", "\n"); + // After split: ["", ""], pop trailing empty -> [""] + // That's 1 empty-string line, which is still a valid line. + expect(diff).not.toBeNull(); + }); +}); + +describe("getWriteFileDiff", () => { + it("returns null for non-write_file tools", () => { + expect( + getWriteFileDiff("read_file", { path: "x", content: "y" }), + ).toBeNull(); + expect(getWriteFileDiff("execute", { path: "x", content: "y" })).toBeNull(); + }); + + it("returns null when args cannot be parsed", () => { + expect(getWriteFileDiff("write_file", null)).toBeNull(); + }); + + it("returns null when path is missing", () => { + expect(getWriteFileDiff("write_file", { content: "hello" })).toBeNull(); + }); + + it("returns null when content is missing", () => { + expect(getWriteFileDiff("write_file", { path: "/foo.ts" })).toBeNull(); + }); + + it("returns a diff for valid write_file args", () => { + const diff = getWriteFileDiff("write_file", { + path: "src/main.ts", + content: "export default 42;\n", + }); + expect(diff).not.toBeNull(); + expect(diff!.name).toContain("main.ts"); + }); + + it("parses JSON string args", () => { + const diff = getWriteFileDiff( + "write_file", + JSON.stringify({ path: "x.ts", content: "y" }), + ); + expect(diff).not.toBeNull(); + }); +}); + +describe("parseEditFilesArgs", () => { + it("returns empty array for null args", () => { + expect(parseEditFilesArgs(null)).toEqual([]); + }); + + it("returns empty array when files is not an array", () => { + expect(parseEditFilesArgs({ files: "not array" })).toEqual([]); + }); + + it("returns empty array when files key is missing", () => { + expect(parseEditFilesArgs({ other: "value" })).toEqual([]); + }); + + it("filters out invalid entries", () => { + const args = { + files: [ + { path: "a.ts", edits: [{ search: "x", replace: "y" }] }, + { path: 42, edits: [] }, // invalid: path not string + null, // invalid: null + { path: "b.ts" }, // invalid: no edits array + { path: "c.ts", edits: [{ search: "a", replace: "b" }] }, + ], + }; + const result = parseEditFilesArgs(args); + expect(result).toHaveLength(2); + expect(result[0].path).toBe("a.ts"); + expect(result[1].path).toBe("c.ts"); + }); + + it("parses JSON string args", () => { + const args = JSON.stringify({ + files: [{ path: "test.ts", edits: [{ search: "old", replace: "new" }] }], + }); + const result = parseEditFilesArgs(args); + expect(result).toHaveLength(1); + expect(result[0].path).toBe("test.ts"); + }); +}); + +describe("buildEditDiff", () => { + it("returns null for empty edits array", () => { + expect(buildEditDiff("file.ts", [])).toBeNull(); + }); + + it("builds a diff from a single search/replace pair", () => { + const diff = buildEditDiff("src/index.ts", [ + { search: "const x = 1;", replace: "const x = 2;" }, + ]); + expect(diff).not.toBeNull(); + expect(diff!.name).toContain("index.ts"); + }); + + it("builds a diff from multiple search/replace pairs", () => { + const diff = buildEditDiff("src/index.ts", [ + { search: "const x = 1;", replace: "const x = 2;" }, + { search: "const y = 3;", replace: "const y = 4;" }, + ]); + expect(diff).not.toBeNull(); + }); + + it("strips leading slash from path", () => { + const diff = buildEditDiff("/src/index.ts", [ + { search: "old", replace: "new" }, + ]); + expect(diff).not.toBeNull(); + // The diff path should not have a double slash. + expect(diff!.name).not.toContain("//"); + }); + + it("skips edits with empty search", () => { + const diff = buildEditDiff("file.ts", [{ search: "", replace: "new" }]); + // All edits skipped β†’ only header lines remain. The parser + // still returns a file entry but with no hunks. + expect(diff).not.toBeNull(); + expect(diff!.hunks).toHaveLength(0); + }); + + it("handles multi-line search and replace", () => { + const diff = buildEditDiff("file.ts", [ + { + search: "line1\nline2\nline3", + replace: "newLine1\nnewLine2", + }, + ]); + expect(diff).not.toBeNull(); + }); + + it("handles replace with trailing newline (trailing empty popped)", () => { + const diff = buildEditDiff("file.ts", [ + { search: "old\n", replace: "new\n" }, + ]); + expect(diff).not.toBeNull(); + }); +}); + +describe("constants", () => { + it("COLLAPSED_OUTPUT_HEIGHT is 54", () => { + expect(COLLAPSED_OUTPUT_HEIGHT).toBe(54); + }); + + it("COLLAPSED_REPORT_HEIGHT is 72", () => { + expect(COLLAPSED_REPORT_HEIGHT).toBe(72); + }); + + it("DIFFS_FONT_STYLE has expected CSS properties", () => { + expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-font-size", "11px"); + expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5"); + }); + + it("BORDER_BG_STYLE has expected background", () => { + expect(BORDER_BG_STYLE).toHaveProperty( + "background", + "hsl(var(--border-default))", + ); + }); + + it("fileViewerCSS is a non-empty string", () => { + expect(typeof fileViewerCSS).toBe("string"); + expect(fileViewerCSS.length).toBeGreaterThan(0); + }); + + it("diffViewerCSS includes border-left style", () => { + expect(diffViewerCSS).toContain("border-left"); + }); +}); diff --git a/site/src/components/ai-elements/tool/utils.ts b/site/src/components/ai-elements/tool/utils.ts new file mode 100644 index 0000000000..4aaf2e92a7 --- /dev/null +++ b/site/src/components/ai-elements/tool/utils.ts @@ -0,0 +1,389 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs"; +import type React from "react"; +import { asRecord, asString } from "../runtimeTypeUtils"; + +export type ToolStatus = "completed" | "error" | "running"; + +export interface EditFilesFileEntry { + path: string; + edits: Array<{ search: string; replace: string }>; +} + +export const toProviderLabel = ( + providerDisplayName: string, + providerID: string, + providerType: string, +): string => { + if (providerDisplayName) { + return providerDisplayName; + } + if (providerID) { + return providerID; + } + if (providerType) { + return providerType; + } + return "Git provider"; +}; + +/** + * Formats a duration in milliseconds into a compact label using + * the same style as {@link shortRelativeTime} in utils/time. + */ +export const shortDurationMs = (durationMs: number | undefined): string => { + if (durationMs === undefined || durationMs < 0) { + return ""; + } + const seconds = Math.round(durationMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.round(minutes / 60); + return `${hours}h`; +}; + +export const normalizeStatus = (status: string): string => + status.trim().toLowerCase(); + +export const isSubagentSuccessStatus = (status: string): boolean => { + switch (normalizeStatus(status)) { + case "completed": + case "reported": + return true; + default: + return false; + } +}; + +export const isSubagentRunningStatus = (status: string): boolean => { + switch (normalizeStatus(status)) { + case "pending": + case "running": + case "awaiting": + return true; + default: + return false; + } +}; + +export const mapSubagentStatusToToolStatus = ( + subagentStatus: string, + fallback: ToolStatus, +): ToolStatus => { + const normalized = normalizeStatus(subagentStatus); + if (!normalized) { + return fallback; + } + if (isSubagentSuccessStatus(normalized)) { + return "completed"; + } + if (isSubagentRunningStatus(normalized)) { + // If the tool call itself has already completed, don't + // override to "running". The spawn/await tool is done; + // the sub-agent may still be working in the background + // but that doesn't mean the tool call is still running. + return fallback === "completed" ? "completed" : "running"; + } + switch (normalized) { + case "waiting": + case "terminated": + return "completed"; + case "error": + return "error"; + default: + return fallback; + } +}; + +export const parseArgs = (args: unknown): Record | null => { + if (!args) { + return null; + } + if (typeof args === "string") { + try { + const parsed = JSON.parse(args); + return asRecord(parsed); + } catch { + return null; + } + } + return asRecord(args); +}; + +export const formatResultOutput = (result: unknown): string | null => { + if (result === undefined || result === null) { + return null; + } + if (typeof result === "string") { + const trimmed = result.trim(); + return trimmed || null; + } + const rec = asRecord(result); + if (rec) { + // For execute tool, show the output field. + const output = asString(rec.output).trim(); + if (output) { + return output; + } + // For read_file, show the content field. + const content = asString(rec.content).trim(); + if (content) { + return content; + } + } + if (typeof result === "object") { + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } + } + return String(result); +}; + +export const fileViewerCSS = + "pre, [data-line], [data-diffs-header] { background-color: transparent !important; }"; + +export const diffViewerCSS = + "pre, [data-line], [data-diffs-header] { background-color: transparent !important; } [data-diffs-header] { border-left: 1px solid var(--border); }"; + +// Theme-aware option factories shared across tool renderers. +export function getDiffViewerOptions(isDark: boolean) { + return { + diffStyle: "unified" as const, + diffIndicators: "bars" as const, + overflow: "scroll" as const, + themeType: (isDark ? "dark" : "light") as "dark" | "light", + theme: isDark ? "github-dark-high-contrast" : "github-light", + unsafeCSS: diffViewerCSS, + }; +} + +export function getFileViewerOptions(isDark: boolean) { + return { + overflow: "scroll" as const, + themeType: (isDark ? "dark" : "light") as "dark" | "light", + theme: isDark ? "github-dark-high-contrast" : "github-light", + unsafeCSS: fileViewerCSS, + }; +} + +export function getFileViewerOptionsNoHeader(isDark: boolean) { + return { + ...getFileViewerOptions(isDark), + disableFileHeader: true, + }; +} + +export function getFileViewerOptionsMinimal(isDark: boolean) { + return { + ...getFileViewerOptions(isDark), + disableFileHeader: true, + disableLineNumbers: true, + }; +} + +export const DIFFS_FONT_STYLE = { + "--diffs-font-size": "11px", + "--diffs-line-height": "1.5", +} as React.CSSProperties; + +export const BORDER_BG_STYLE = { + background: "hsl(var(--border-default))", +}; + +/** + * Checks whether a tool result should be rendered as a syntax-highlighted + * file viewer. Returns the file path, content, and whether the header + * should be hidden. + */ +export const getFileContentForViewer = ( + toolName: string, + args: unknown, + result: unknown, +): { + path: string; + content: string; + disableHeader?: boolean; + disableLineNumbers?: boolean; +} | null => { + if (toolName === "execute") { + const rec = asRecord(result); + if (!rec) { + return null; + } + const output = asString(rec.output).trim(); + if (!output) { + return null; + } + return { + path: "output.sh", + content: output, + disableHeader: true, + disableLineNumbers: true, + }; + } + if (toolName !== "read_file") { + return null; + } + const parsed = parseArgs(args); + const path = parsed ? asString(parsed.path).trim() : ""; + if (!path) { + return null; + } + const rec = asRecord(result); + if (!rec) { + return null; + } + const content = asString(rec.content).trim(); + if (!content) { + return null; + } + return { path, content }; +}; + +/** + * Builds a FileDiffMetadata representing a new-file diff (all lines + * are additions) from the content written by a write_file tool call. + * Returns null when the content is empty or unparsable. + */ +export const buildWriteFileDiff = ( + path: string, + content: string, +): FileDiffMetadata | null => { + const lines = content.split("\n"); + // Remove trailing empty line produced by a final newline. + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + if (lines.length === 0) { + return null; + } + + const patchLines = [ + `diff --git a/${path} b/${path}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${path}`, + `@@ -0,0 +1,${lines.length} @@`, + ...lines.map((l) => `+${l}`), + ]; + const patch = `${patchLines.join("\n")}\n`; + + const parsed = parsePatchFiles(patch); + if (!parsed.length || !parsed[0].files.length) { + return null; + } + return parsed[0].files[0]; +}; + +/** + * For write_file tool calls, extracts the path and content from args + * and builds a FileDiffMetadata showing all lines as additions. + */ +export const getWriteFileDiff = ( + toolName: string, + args: unknown, +): FileDiffMetadata | null => { + if (toolName !== "write_file") { + return null; + } + const parsed = parseArgs(args); + if (!parsed) { + return null; + } + const path = asString(parsed.path).trim(); + const content = asString(parsed.content).trim(); + if (!path || !content) { + return null; + } + return buildWriteFileDiff(path, content); +}; + +/** Height that fits roughly 3 lines of monospace text-xs output. */ +export const COLLAPSED_OUTPUT_HEIGHT = 54; + +/** Height for the collapsed report preview (~3 lines of rendered markdown). */ +export const COLLAPSED_REPORT_HEIGHT = 72; + +/** + * Parses the args of an edit_files tool call into a typed array + * of file entries. + */ +export const parseEditFilesArgs = (args: unknown): EditFilesFileEntry[] => { + const parsed = parseArgs(args); + if (!parsed) return []; + const files = parsed.files; + if (!Array.isArray(files)) return []; + return files.filter( + (f): f is EditFilesFileEntry => + f !== null && + typeof f === "object" && + typeof (f as Record).path === "string" && + Array.isArray((f as Record).edits), + ); +}; + +/** + * Builds a synthetic unified diff from search/replace edit pairs + * for a single file. Each pair becomes a separate hunk in the + * diff. Line numbers are synthetic since we don't have the full + * file content. + */ +export const buildEditDiff = ( + path: string, + edits: Array<{ search: string; replace: string }>, +): FileDiffMetadata | null => { + if (!edits.length) return null; + + // Strip leading slash so the a/ and b/ prefixes don't + // produce a double-slash that confuses the diff parser. + const diffPath = path.startsWith("/") ? path.slice(1) : path; + + const patchLines: string[] = [ + `diff --git a/${diffPath} b/${diffPath}`, + `--- a/${diffPath}`, + `+++ b/${diffPath}`, + ]; + + let lineOffset = 1; + for (const edit of edits) { + if (!edit.search) continue; + const searchLines = edit.search.split("\n"); + const replaceLines = edit.replace.split("\n"); + + // Remove trailing empty line produced by a final newline. + if (searchLines.length > 0 && searchLines[searchLines.length - 1] === "") { + searchLines.pop(); + } + if ( + replaceLines.length > 0 && + replaceLines[replaceLines.length - 1] === "" + ) { + replaceLines.pop(); + } + if (searchLines.length === 0 && replaceLines.length === 0) continue; + + patchLines.push( + `@@ -${lineOffset},${searchLines.length} +${lineOffset},${replaceLines.length} @@`, + ); + for (const l of searchLines) patchLines.push(`-${l}`); + for (const l of replaceLines) patchLines.push(`+${l}`); + + lineOffset += Math.max(searchLines.length, replaceLines.length) + 1; + } + + const patch = `${patchLines.join("\n")}\n`; + const parsed = parsePatchFiles(patch); + if (!parsed.length || !parsed[0].files.length) return null; + return parsed[0].files[0]; +}; + +// Re-export runtime type utils used by sub-components so they +// can import from a single location. +export { asNumber, asRecord, asString } from "../runtimeTypeUtils"; diff --git a/site/src/contexts/DiffsWorkerPoolProvider.tsx b/site/src/contexts/DiffsWorkerPoolProvider.tsx new file mode 100644 index 0000000000..9a7cafc538 --- /dev/null +++ b/site/src/contexts/DiffsWorkerPoolProvider.tsx @@ -0,0 +1,51 @@ +import { + type WorkerInitializationRenderOptions, + WorkerPoolContextProvider, + type WorkerPoolOptions, +} from "@pierre/diffs/react"; +import type { FC, ReactNode } from "react"; + +interface DiffsWorkerPoolProviderProps { + children: ReactNode; +} + +const highlighterOptions: WorkerInitializationRenderOptions = { + theme: { + dark: "github-dark-high-contrast", + light: "github-light", + }, +}; + +const getPoolSize = (): number => { + const cores = globalThis.navigator?.hardwareConcurrency ?? 2; + // From Kyle: This is just arbitrarily chosen by me. + return Math.min(Math.max(1, cores - 1), 3); +}; + +const hasWorkerSupport = (): boolean => + typeof window !== "undefined" && typeof Worker !== "undefined"; + +export const DiffsWorkerPoolProvider: FC = ({ + children, +}) => { + if (!hasWorkerSupport()) { + return <>{children}; + } + + const poolOptions: WorkerPoolOptions = { + poolSize: getPoolSize(), + workerFactory: () => + new Worker(new URL("@pierre/diffs/worker/worker.js", import.meta.url), { + type: "module", + }), + }; + + return ( + + {children} + + ); +}; diff --git a/site/src/hooks/useEmbeddedMetadata.test.ts b/site/src/hooks/useEmbeddedMetadata.test.ts index f8dcdf17eb..9b501ab954 100644 --- a/site/src/hooks/useEmbeddedMetadata.test.ts +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -1,4 +1,5 @@ import { + MockAgentsTabVisible, MockAppearanceConfig, MockBuildInfo, MockEntitlements, @@ -43,6 +44,7 @@ const mockDataForTags = { userAppearance: MockUserAppearanceSettings, regions: MockRegions, "tasks-tab-visible": MockTasksTabVisible, + "agents-tab-visible": MockAgentsTabVisible, } as const satisfies Record; const emptyMetadata: RuntimeHtmlMetadata = { @@ -78,6 +80,10 @@ const emptyMetadata: RuntimeHtmlMetadata = { available: false, value: undefined, }, + "agents-tab-visible": { + available: false, + value: undefined, + }, }; const populatedMetadata: RuntimeHtmlMetadata = { @@ -113,6 +119,10 @@ const populatedMetadata: RuntimeHtmlMetadata = { available: true, value: MockTasksTabVisible, }, + "agents-tab-visible": { + available: true, + value: MockAgentsTabVisible, + }, }; function seedInitialMetadata(metadataKey: string): () => void { diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index 951aced7a7..819d15ccd3 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -30,6 +30,7 @@ type AvailableMetadata = Readonly<{ regions: readonly Region[]; "build-info": BuildInfoResponse; "tasks-tab-visible": boolean; + "agents-tab-visible": boolean; }>; export type MetadataKey = keyof AvailableMetadata; @@ -92,6 +93,7 @@ export class MetadataManager implements MetadataManagerApi { "build-info": this.registerValue("build-info"), regions: this.registerRegionValue(), "tasks-tab-visible": this.registerValue("tasks-tab-visible"), + "agents-tab-visible": this.registerValue("agents-tab-visible"), }; } diff --git a/site/src/index.css b/site/src/index.css index 881a715d6b..412971dcde 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -53,6 +53,18 @@ --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; + /* + shadcn-compatible aliases consumed by streamdown and + internal components (e.g. shimmer). These names are + intentionally generic so they match shadcn conventions. + Do NOT rename without updating every consumer. + */ + --background: 0 0% 98%; + --foreground: 240 10% 4%; + --muted: 240 5% 96%; + --muted-foreground: 240 5% 34%; + --primary: 221 83% 53%; + --primary-foreground: 0 0% 98%; --avatar-lg: 2.5rem; --avatar-default: 1.5rem; --avatar-sm: 1.125rem; @@ -100,6 +112,13 @@ --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; + /* shadcn-compatible aliases – see :root block for details. */ + --background: 240 10% 4%; + --foreground: 0 0% 98%; + --muted: 240 6% 10%; + --muted-foreground: 240 5% 65%; + --primary: 213 94% 68%; + --primary-foreground: 240 10% 4%; } } diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 8f9b606a1f..a2e5e4b060 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -187,6 +187,7 @@ const NavItems: FC = ({ className, user }) => { Templates + ); }; @@ -251,6 +252,30 @@ function idleTasksLabel(count: number) { return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`; } +const AgentsNavItem: FC = () => { + const { metadata } = useEmbeddedMetadata(); + const canSeeAgents = Boolean( + metadata["agents-tab-visible"].value || + process.env.NODE_ENV === "development" || + process.env.STORYBOOK, + ); + + if (!canSeeAgents) { + return null; + } + + return ( + { + return cn(linkStyles.default, { [linkStyles.active]: isActive }); + }} + to="/agents" + > + Agents + + ); +}; + function isNavbarLink(link: TypesGen.LinkConfig): boolean { return link.location === "navbar"; } diff --git a/site/src/pages/AgentsPage/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/AgentChatInput.stories.tsx new file mode 100644 index 0000000000..978df1ad37 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentChatInput.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { AgentChatInput } from "./AgentChatInput"; + +const defaultModelOptions = [ + { + id: "openai:gpt-4o", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + }, +] as const; + +const meta: Meta = { + title: "pages/AgentsPage/AgentChatInput", + component: AgentChatInput, + args: { + onSend: fn(), + onModelChange: fn(), + isDisabled: false, + isLoading: false, + selectedModel: defaultModelOptions[0].id, + modelOptions: [...defaultModelOptions], + modelSelectorPlaceholder: "Select model", + hasModelOptions: true, + inputStatusText: null, + modelCatalogStatusMessage: null, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const DisablesSendUntilInput: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByPlaceholderText("Type a message..."); + const sendButton = canvas.getByRole("button", { name: "Send" }); + + expect(sendButton).toBeDisabled(); + await userEvent.type(input, "Write tests"); + expect(sendButton).toBeEnabled(); + }, +}; + +export const SendsAndClearsInput: Story = { + args: { + onSend: fn().mockResolvedValue(undefined), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByPlaceholderText("Type a message..."); + + await userEvent.type(input, "Run focused tests"); + await userEvent.click(canvas.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(args.onSend).toHaveBeenCalledWith("Run focused tests", undefined); + }); + expect(input).toHaveValue(""); + }, +}; + +export const DisabledInput: Story = { + args: { + isDisabled: true, + initialValue: "Should not send", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + +export const NoModelOptions: Story = { + args: { + isDisabled: false, + hasModelOptions: false, + initialValue: "Model required", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + +export const LoadingSpinner: Story = { + args: { + isDisabled: true, + isLoading: true, + initialValue: "Sending...", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sendButton = canvas.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); + // The Loader2Icon renders with the animate-spin class when + // isLoading is true. + expect(sendButton.querySelector(".animate-spin")).toBeTruthy(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx new file mode 100644 index 0000000000..74421f0826 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -0,0 +1,582 @@ +import type { ChatQueuedMessage } from "api/typesGenerated"; +import { + ModelSelector, + type ModelSelectorOption, +} from "components/ai-elements"; +import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { + ArrowUpIcon, + ListPlusIcon, + Loader2Icon, + Square, + XIcon, +} from "lucide-react"; +import { + memo, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import { formatProviderLabel } from "./modelOptions"; +import { QueuedMessagesList } from "./QueuedMessagesList"; + +export interface AgentContextUsage { + readonly usedTokens?: number; + readonly contextLimitTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cacheReadTokens?: number; + readonly cacheCreationTokens?: number; + readonly reasoningTokens?: number; + // Percentage (0–100) at which the context will be compacted. + readonly compressionThreshold?: number; +} + +interface AgentChatInputProps { + onSend: (message: string, editedMessageID?: number) => Promise; + placeholder?: string; + isDisabled: boolean; + isLoading: boolean; + // Optional initial value for the textarea (e.g. restored from + // localStorage on the create page). + initialValue?: string; + // Fires whenever the textarea value changes, useful for persisting + // the draft externally. + onInputChange?: (value: string) => void; + // Model selector. + selectedModel: string; + onModelChange: (value: string) => void; + modelOptions: readonly ModelSelectorOption[]; + modelSelectorPlaceholder: string; + hasModelOptions: boolean; + // Status messages. + inputStatusText: string | null; + modelCatalogStatusMessage: string | null; + // Streaming controls (optional, for the detail page). + isStreaming?: boolean; + onInterrupt?: () => void; + isInterruptPending?: boolean; + // Extra controls rendered in the left action area (e.g. workspace + // selector on the create page). + leftActions?: ReactNode; + // Queued user messages rendered above the textarea. + queuedMessages?: readonly ChatQueuedMessage[]; + onDeleteQueuedMessage?: (id: number) => Promise | void; + onPromoteQueuedMessage?: (id: number) => Promise | void; + + // Optional context-usage summary shown to the left of the send button. + // Pass `null` to render fallback values (e.g. when limit is unknown). + // Omit entirely to hide the indicator. + contextUsage?: AgentContextUsage | null; + // When true the entire input sticks to the bottom of the scroll + // container (used in the detail page). + sticky?: boolean; + // External edit request β€” when set, replaces the input text and + // focuses the textarea. Use a unique `key` to allow re-editing the + // same text. + editRequest?: { text: string; messageId?: number; key: number } | null; + // Called when the user cancels or completes a history edit so the + // parent can clear the editing highlight. + onEditCleared?: () => void; +} + +const hasFiniteTokenValue = (value: number | undefined): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0; + +const formatTokenCount = (value: number | undefined): string => + hasFiniteTokenValue(value) ? value.toLocaleString() : "--"; + +const formatTokenCountCompact = (value: number | undefined): string => { + if (!hasFiniteTokenValue(value)) { + return "--"; + } + if (value >= 1_000_000) { + const m = value / 1_000_000; + return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, "")}M`; + } + if (value >= 1_000) { + const k = value / 1_000; + return `${Number.isInteger(k) ? k : k.toFixed(1).replace(/\.0$/, "")}K`; + } + return String(value); +}; + +const getIndicatorToneClassName = (percentUsed: number | null): string => { + if (percentUsed === null) { + return "text-content-secondary/60"; + } + if (percentUsed >= 95) { + return "text-content-destructive"; + } + if (percentUsed >= 85) { + return "text-content-warning"; + } + return "text-content-secondary/60"; +}; + +const RING_SIZE = 18; +const RING_STROKE = 2.5; +const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>( + ({ usage }) => { + const usedTokens = hasFiniteTokenValue(usage?.usedTokens) + ? usage.usedTokens + : undefined; + const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens) + ? usage.contextLimitTokens + : undefined; + const percentUsed = + usedTokens !== undefined && + contextLimitTokens !== undefined && + contextLimitTokens > 0 + ? (usedTokens / contextLimitTokens) * 100 + : null; + const hasPercent = percentUsed !== null; + const percentLabel = + percentUsed === null ? "--" : `${Math.round(percentUsed)}%`; + const indicatorLabel = null; + const clampedPercent = hasPercent + ? Math.min(Math.max(percentUsed, 0), 100) + : 100; + const dashOffset = + RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE; + const toneClassName = getIndicatorToneClassName(percentUsed); + const ariaLabel = hasPercent + ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.` + : "Context usage"; + + return ( + + + + + + + + {indicatorLabel !== null && ( + + {indicatorLabel} + + )} + + + +
+ {hasPercent + ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` + : "Context usage unavailable"} + {hasPercent && + usage?.compressionThreshold !== undefined && + usage.compressionThreshold > 0 && ( +
+ Compacts at {usage.compressionThreshold}% +
+ )} +
+
+
+ ); + }, +); +ContextUsageIndicator.displayName = "ContextUsageIndicator"; + +export const AgentChatInput = memo( + ({ + onSend, + placeholder = "Type a message...", + isDisabled, + isLoading, + initialValue = "", + onInputChange, + selectedModel, + onModelChange, + modelOptions, + modelSelectorPlaceholder, + hasModelOptions, + inputStatusText, + modelCatalogStatusMessage, + isStreaming = false, + onInterrupt, + isInterruptPending = false, + leftActions, + queuedMessages = [], + onDeleteQueuedMessage, + onPromoteQueuedMessage, + contextUsage, + sticky = false, + editRequest = null, + onEditCleared, + }) => { + const [input, setInput] = useState(initialValue); + const [editingQueuedMessageID, setEditingQueuedMessageID] = useState< + number | null + >(null); + const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState< + string | null + >(null); + const textareaRef = useRef(null); + + // Handle external edit requests (e.g. clicking a historical + // user message's edit icon). + const lastEditKeyRef = useRef(null); + const [isEditingHistoryMessage, setIsEditingHistoryMessage] = + useState(false); + const [editingHistoryMessageID, setEditingHistoryMessageID] = useState< + number | null + >(null); + const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState< + string | null + >(null); + useEffect(() => { + if (!editRequest || editRequest.key === lastEditKeyRef.current) { + return; + } + lastEditKeyRef.current = editRequest.key; + setDraftBeforeHistoryEdit((current) => + isEditingHistoryMessage ? current : input, + ); + setIsEditingHistoryMessage(true); + setEditingHistoryMessageID(editRequest.messageId ?? null); + setInput(editRequest.text); + onInputChange?.(editRequest.text); + textareaRef.current?.focus(); + }, [editRequest, input, isEditingHistoryMessage, onInputChange]); + + const handleCancelHistoryEdit = useCallback(() => { + if (!isEditingHistoryMessage) { + return; + } + const restored = draftBeforeHistoryEdit ?? ""; + setIsEditingHistoryMessage(false); + setEditingHistoryMessageID(null); + setDraftBeforeHistoryEdit(null); + setInput(restored); + onInputChange?.(restored); + onEditCleared?.(); + textareaRef.current?.focus(); + }, [ + draftBeforeHistoryEdit, + isEditingHistoryMessage, + onEditCleared, + onInputChange, + ]); + + useEffect(() => { + if (editingQueuedMessageID === null) { + return; + } + const stillQueued = queuedMessages.some( + (message) => message.id === editingQueuedMessageID, + ); + if (stillQueued) { + return; + } + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + }, [editingQueuedMessageID, queuedMessages]); + + const handleSubmit = useCallback(async () => { + const text = input.trim(); + if (!text || isDisabled || !hasModelOptions) { + return; + } + + const queueEditID = editingQueuedMessageID; + const editedMessageID = + isEditingHistoryMessage && editingHistoryMessageID !== null + ? editingHistoryMessageID + : undefined; + try { + await onSend(input, editedMessageID); + if (queueEditID !== null && onDeleteQueuedMessage) { + await onDeleteQueuedMessage(queueEditID); + } + setInput(""); + onInputChange?.(""); + if (queueEditID !== null) { + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + } + if (isEditingHistoryMessage) { + setIsEditingHistoryMessage(false); + setEditingHistoryMessageID(null); + setDraftBeforeHistoryEdit(null); + onEditCleared?.(); + } + } catch { + // Keep input on failure so the user can retry. + } finally { + // Re-focus the textarea so the user can keep typing. + textareaRef.current?.focus(); + } + }, [ + editingQueuedMessageID, + editingHistoryMessageID, + hasModelOptions, + input, + isDisabled, + isEditingHistoryMessage, + onDeleteQueuedMessage, + onEditCleared, + onInputChange, + onSend, + ]); + + const handleStartQueueEdit = useCallback( + (id: number, text: string) => { + setDraftBeforeQueueEdit((current) => + editingQueuedMessageID === null ? input : current, + ); + setEditingQueuedMessageID(id); + setInput(text); + onInputChange?.(text); + textareaRef.current?.focus(); + }, + [editingQueuedMessageID, input, onInputChange], + ); + + const handleCancelQueueEdit = useCallback(() => { + if (editingQueuedMessageID === null) { + return; + } + const restored = draftBeforeQueueEdit ?? ""; + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + setInput(restored); + onInputChange?.(restored); + textareaRef.current?.focus(); + }, [draftBeforeQueueEdit, editingQueuedMessageID, onInputChange]); + + const sendButtonLabel = + isStreaming && editingQueuedMessageID === null ? "Queue message" : "Send"; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (editingQueuedMessageID !== null) { + e.preventDefault(); + handleCancelQueueEdit(); + } else if (isEditingHistoryMessage) { + e.preventDefault(); + handleCancelHistoryEdit(); + } + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + // If the input is empty and there are queued messages, + // promote the first one instead of submitting. + if ( + !input.trim() && + queuedMessages.length > 0 && + onPromoteQueuedMessage + ) { + void onPromoteQueuedMessage(queuedMessages[0].id); + return; + } + void handleSubmit(); + } + }, + [ + editingQueuedMessageID, + handleCancelHistoryEdit, + handleCancelQueueEdit, + handleSubmit, + input, + isEditingHistoryMessage, + onPromoteQueuedMessage, + queuedMessages, + ], + ); + + const content = ( +
+ {queuedMessages.length > 0 && ( + { + if (id === editingQueuedMessageID) { + handleCancelQueueEdit(); + } + void onDeleteQueuedMessage?.(id); + }} + onPromote={(id) => { + if (id === editingQueuedMessageID) { + handleCancelQueueEdit(); + } + void onPromoteQueuedMessage?.(id); + }} + onEdit={handleStartQueueEdit} + editingMessageID={editingQueuedMessageID} + className="mb-2" + /> + )} +
+ {editingQueuedMessageID !== null && ( +
+ + Editing queued message + + +
+ )} + {isEditingHistoryMessage && editingQueuedMessageID === null && ( +
+ + {isLoading && ( + + )} + {isLoading ? "Saving edit..." : "Editing message"} + + +
+ )} + { + setInput(e.target.value); + onInputChange?.(e.target.value); + }} + onKeyDown={handleKeyDown} + disabled={isDisabled} + minRows={4} + /> +
+
+ + {leftActions} + {inputStatusText && ( + + {inputStatusText} + + )} +
+
+ {contextUsage !== undefined && ( + + )} + {isStreaming && onInterrupt && ( + + )} + +
+
+ {inputStatusText && ( +
+ {inputStatusText} +
+ )} + {modelCatalogStatusMessage && ( +
+ {modelCatalogStatusMessage} +
+ )} +
+
+ ); + + if (sticky) { + return ( +
{content}
+ ); + } + + return content; + }, +); +AgentChatInput.displayName = "AgentChatInput"; diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx new file mode 100644 index 0000000000..b2f0c5a174 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -0,0 +1,452 @@ +import { + MockUserOwner, + MockWorkspace, + MockWorkspaceAgent, +} from "testHelpers/entities"; +import { withAuthProvider, withWebSocket } from "testHelpers/storybook"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { + chatDiffStatusKey, + chatKey, + chatModelsKey, + chatsKey, +} from "api/queries/chats"; +import { workspaceByIdKey } from "api/queries/workspaces"; +import type * as TypesGen from "api/typesGenerated"; +import { type FC, useRef, useState } from "react"; +import { Outlet } from "react-router"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-remix-react-router"; +import AgentDetail from "./AgentDetail"; +import type { AgentsOutletContext } from "./AgentsPage"; + +// --------------------------------------------------------------------------- +// Layout wrapper – provides portal targets for the top-bar and right panel +// so the component can render its portaled actions menu and diff panel. +// --------------------------------------------------------------------------- +const AgentDetailLayout: FC = () => { + const topBarTitleRef = useRef(null); + const topBarActionsRef = useRef(null); + const rightPanelRef = useRef(null); + const [rightPanelOpen, setRightPanelOpen] = useState(false); + + return ( +
+
+
+
+
+
+
+ {}, + clearChatErrorReason: () => {}, + topBarTitleRef, + topBarActionsRef, + rightPanelRef, + setRightPanelOpen, + requestArchiveAgent: () => {}, + } satisfies AgentsOutletContext + } + /> +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Shared mock data +// --------------------------------------------------------------------------- +const CHAT_ID = "chat-1"; + +const mockWorkspaceAgent: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: "workspace-agent-1", + name: "workspace-agent", + expanded_directory: "/workspace/project", + apps: [], +}; + +const mockWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: "workspace-1", + owner_name: "owner", + name: "workspace-name", + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspace.latest_build.resources[0], + agents: [mockWorkspaceAgent], + }, + ], + }, +}; + +const mockModelCatalog: TypesGen.ChatModelsResponse = { + providers: [ + { + provider: "openai", + available: true, + models: [ + { + id: "openai:gpt-4o", + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + }, + ], + }, + ], +}; + +const baseChatFields = { + owner_id: "owner-id", + workspace_id: mockWorkspace.id, + workspace_agent_id: mockWorkspaceAgent.id, + last_model_config_id: "model-config-1", + created_at: "2026-02-18T00:00:00.000Z", + updated_at: "2026-02-18T00:00:00.000Z", +} as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build `parameters.queries` entries for a given chat data object. */ +const buildQueries = ( + chatData: TypesGen.ChatWithMessages, + opts?: { diffUrl?: string }, +) => [ + { key: chatKey(CHAT_ID), data: chatData }, + { key: chatsKey, data: [chatData.chat] }, + { + key: chatDiffStatusKey(CHAT_ID), + data: { + chat_id: CHAT_ID, + url: opts?.diffUrl, + changes_requested: false, + additions: opts?.diffUrl ? 4 : 0, + deletions: opts?.diffUrl ? 1 : 0, + changed_files: opts?.diffUrl ? 2 : 0, + } satisfies TypesGen.ChatDiffStatus, + }, + { + key: workspaceByIdKey(mockWorkspace.id), + data: mockWorkspace, + }, + { key: chatModelsKey, data: mockModelCatalog }, +]; + +/** + * Wrap a chat stream event payload in the JSON string format that + * OneWayWebSocket expects when receiving a WebSocket message event. + * The result is a `ServerSentEvent` of type `"data"` serialised to JSON. + */ +const wrapSSE = (payload: unknown): string => + JSON.stringify({ type: "data", data: payload }); + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- +const meta: Meta = { + title: "pages/AgentsPage/AgentDetail", + component: AgentDetailLayout, + decorators: [withAuthProvider, withWebSocket], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + webSocket: [], + reactRouter: reactRouterParameters({ + location: { + path: `/agents/${CHAT_ID}`, + pathParams: { agentId: CHAT_ID }, + }, + routing: reactRouterOutlet({ path: "/agents/:agentId" }, ), + }), + }, + beforeEach: () => { + spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key")); + }, +}; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +/** Skeleton placeholder when no query data is available yet. */ +export const Loading: Story = {}; + +/** Full layout with actions menu and diff panel portaled to the right slot. */ +export const CompletedWithDiffPanel: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Build a feature", + status: "completed", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: "https://github.com/coder/coder/pull/123" }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + // Wait for the actions menu trigger to appear in the top bar. + const menuTrigger = await canvas.findByRole("button", { + name: "Open agent actions", + }); + await user.click(menuTrigger); + + // Verify menu items are rendered. + const body = within(document.body); + await waitFor(() => { + expect(body.getByText("Open in Cursor")).toBeInTheDocument(); + }); + expect(body.getByText("Open in VS Code")).toBeInTheDocument(); + expect(body.getByText("View Workspace")).toBeInTheDocument(); + expect(body.getByText("Archive Agent")).toBeInTheDocument(); + }, +}; + +/** Right panel stays closed when no diff-status URL exists. */ +export const NoDiffUrl: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "No diff yet", + status: "completed", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + }, +}; + +/** Subagent tool-call/result messages render subagent cards. */ +export const WithSubagentCards: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Parent agent", + status: "running", + }, + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:01.000Z", + role: "assistant", + content: [ + { + type: "tool-call", + tool_call_id: "tool-subagent-1", + tool_name: "spawn_agent", + args: { title: "Child agent" }, + }, + { + type: "tool-result", + tool_call_id: "tool-subagent-1", + tool_name: "spawn_agent", + result: { + chat_id: "child-chat-1", + title: "Child agent", + status: "pending", + }, + }, + ], + }, + ], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: /Spawn(?:ed|ing) Child agent/ }), + ).toBeInTheDocument(); + }); + }, +}; + +/** Reasoning part renders collapsed and can be expanded on click. */ +export const WithReasoningCollapsed: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Reasoning title", + status: "completed", + }, + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:01.000Z", + role: "assistant", + content: [ + { + type: "reasoning", + title: "Plan migration", + text: "Reasoning body", + }, + ], + }, + ], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const reasoningToggle = await canvas.findByRole("button", { + name: "Plan migration", + }); + expect(reasoningToggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(reasoningToggle); + + expect(reasoningToggle).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Reasoning body")).toBeInTheDocument(); + }, +}; + +/** + * Streaming subagent title via WebSocket message_part events. + * The `withWebSocket` decorator replays all events after a setTimeout(0), + * and OneWayWebSocket parses each JSON payload, so the streamed title + * should appear once the play function runs. + */ +export const StreamedSubagentTitle: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Streaming title", + status: "running", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + webSocket: [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "tool-call", + tool_call_id: "tool-subagent-stream-1", + tool_name: "spawn_agent", + args_delta: '{"title":"Streamed Child"', + }, + }, + }), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByRole("button", { + name: /Spawning Streamed Child/, + }), + ).toBeInTheDocument(); + }); + }, +}; + +/** + * Streaming reasoning part via WebSocket β€” renders collapsed and + * can be expanded on click. + */ +export const StreamedReasoningCollapsed: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Streaming reasoning title", + status: "running", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + webSocket: [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "reasoning", + title: "Plan migration", + text: "Streaming reasoning body", + }, + }, + }), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const reasoningToggle = await canvas.findByRole("button", { + name: "Plan migration", + }); + expect(reasoningToggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(reasoningToggle); + + expect(reasoningToggle).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Streaming reasoning body")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx new file mode 100644 index 0000000000..f66461b7d2 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -0,0 +1,861 @@ +import { API } from "api/api"; +import { + chat, + chatDiffStatus, + chatModelConfigs, + chatModels, + chats, + createChatMessage, + deleteChatQueuedMessage, + editChatMessage, + interruptChat, + promoteChatQueuedMessage, +} from "api/queries/chats"; +import { workspaceById } from "api/queries/workspaces"; +import type * as TypesGen from "api/typesGenerated"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { Skeleton } from "components/Skeleton/Skeleton"; +import { getVSCodeHref, SESSION_TOKEN_PLACEHOLDER } from "modules/apps/apps"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useOutletContext, useParams } from "react-router"; +import { toast } from "sonner"; +import { AgentChatInput } from "./AgentChatInput"; +import { + selectChatStatus, + selectHasStreamState, + selectMessagesByID, + selectOrderedMessageIDs, + selectQueuedMessages, + selectStreamError, + selectStreamState, + selectSubagentStatusOverrides, + useChatSelector, + useChatStore, +} from "./AgentDetail/ChatContext"; +import { ConversationTimeline } from "./AgentDetail/ConversationTimeline"; +import { + getLatestContextUsage, + getParentChatID, + getWorkspaceAgent, +} from "./AgentDetail/chatHelpers"; +import { + buildParsedMessageSections, + buildSubagentTitles, + parseMessagesWithMergedTools, +} from "./AgentDetail/messageParsing"; +import { buildStreamTools } from "./AgentDetail/streamState"; +import { AgentDetailTopBarPortals } from "./AgentDetail/TopBarPortals"; +import { useMessageWindow } from "./AgentDetail/useMessageWindow"; +import type { AgentsOutletContext } from "./AgentsPage"; +import { + getModelCatalogStatusMessage, + getModelOptionsFromCatalog, + getModelSelectorPlaceholder, + hasConfiguredModelsInCatalog, +} from "./modelOptions"; + +const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] = + () => {}; +const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] = + () => {}; +const noopSetRightPanelOpen: AgentsOutletContext["setRightPanelOpen"] = + () => {}; +const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] = + () => {}; +const lastModelConfigIDStorageKey = "agents.last-model-config-id"; +type ChatStoreHandle = ReturnType["store"]; + +const isChatMessage = ( + message: TypesGen.ChatMessage | undefined, +): message is TypesGen.ChatMessage => Boolean(message); + +const toOptimisticMessageParts = ( + inputParts: readonly TypesGen.ChatInputPart[], +): readonly TypesGen.ChatMessagePart[] => + inputParts.map((part) => ({ + type: "text", + ...(part.text !== undefined ? { text: part.text } : {}), + })); + +const getOrderedMessagesFromStore = ( + store: ChatStoreHandle, +): readonly TypesGen.ChatMessage[] => { + const snapshot = store.getSnapshot(); + return snapshot.orderedMessageIDs + .map((messageID) => snapshot.messagesByID.get(messageID)) + .filter(isChatMessage); +}; + +interface AgentDetailTimelineProps { + store: ChatStoreHandle; + chatID: string; + persistedErrorReason: string | undefined; + onEditUserMessage?: (messageId: number, text: string) => void; + editingMessageId?: number | null; + savingMessageId?: number | null; +} + +const AgentDetailTimeline: FC = ({ + store, + chatID, + persistedErrorReason, + onEditUserMessage, + editingMessageId, + savingMessageId, +}) => { + const messagesByID = useChatSelector(store, selectMessagesByID); + const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); + const streamState = useChatSelector(store, selectStreamState); + const chatStatus = useChatSelector(store, selectChatStatus); + const streamError = useChatSelector(store, selectStreamError); + const subagentStatusOverrides = useChatSelector( + store, + selectSubagentStatusOverrides, + ); + + const messages = useMemo( + () => + orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .filter(isChatMessage), + [messagesByID, orderedMessageIDs], + ); + const streamTools = useMemo( + () => buildStreamTools(streamState), + [streamState], + ); + const { hasMoreMessages, windowedMessages, loadMoreSentinelRef } = + useMessageWindow({ + messages, + resetKey: chatID, + }); + const parsedMessages = useMemo( + () => parseMessagesWithMergedTools(windowedMessages), + [windowedMessages], + ); + const subagentTitles = useMemo( + () => buildSubagentTitles(parsedMessages), + [parsedMessages], + ); + const parsedSections = useMemo( + () => buildParsedMessageSections(parsedMessages), + [parsedMessages], + ); + const detailErrorMessage = + (chatStatus === "error" ? persistedErrorReason : undefined) || streamError; + const latestMessage = messages[messages.length - 1]; + const latestMessageNeedsAssistantResponse = + !latestMessage || latestMessage.role !== "assistant"; + const isAwaitingFirstStreamChunk = + !streamState && + (chatStatus === "running" || chatStatus === "pending") && + latestMessageNeedsAssistantResponse; + const hasStreamOutput = Boolean(streamState) || isAwaitingFirstStreamChunk; + + return ( + + ); +}; + +interface AgentDetailInputProps { + store: ChatStoreHandle; + compressionThreshold: number | undefined; + onSend: (message: string, editedMessageID?: number) => Promise; + onDeleteQueuedMessage: (id: number) => Promise; + onPromoteQueuedMessage: (id: number) => Promise; + onInterrupt: () => void; + isInputDisabled: boolean; + isSendPending: boolean; + isInterruptPending: boolean; + hasModelOptions: boolean; + selectedModel: string; + onModelChange: (modelID: string) => void; + modelOptions: readonly ModelSelectorOption[]; + modelSelectorPlaceholder: string; + inputStatusText: string | null; + modelCatalogStatusMessage: string | null; + editRequest?: { text: string; messageId?: number; key: number } | null; + onEditCleared?: () => void; +} + +const AgentDetailInput: FC = ({ + store, + compressionThreshold, + onSend, + onDeleteQueuedMessage, + onPromoteQueuedMessage, + onInterrupt, + isInputDisabled, + isSendPending, + isInterruptPending, + hasModelOptions, + selectedModel, + onModelChange, + modelOptions, + modelSelectorPlaceholder, + inputStatusText, + modelCatalogStatusMessage, + editRequest, + onEditCleared, +}) => { + const messagesByID = useChatSelector(store, selectMessagesByID); + const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); + const hasStreamState = useChatSelector(store, selectHasStreamState); + const chatStatus = useChatSelector(store, selectChatStatus); + const queuedMessages = useChatSelector(store, selectQueuedMessages); + + const messages = useMemo( + () => + orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .filter(isChatMessage), + [messagesByID, orderedMessageIDs], + ); + const latestContextUsage = useMemo(() => { + const usage = getLatestContextUsage(messages); + if (!usage) { + return usage; + } + return { ...usage, compressionThreshold }; + }, [messages, compressionThreshold]); + const isStreaming = + hasStreamState || chatStatus === "running" || chatStatus === "pending"; + + return ( + + ); +}; + +interface AgentDetailConversationProps { + store: ChatStoreHandle; + chatID: string; + persistedErrorReason: string | undefined; + compressionThreshold: number | undefined; + onDeleteQueuedMessage: (id: number) => Promise; + onPromoteQueuedMessage: (id: number) => Promise; + onSend: (message: string, editedMessageID?: number) => Promise; + onInterrupt: () => void; + isInputDisabled: boolean; + isSendPending: boolean; + isInterruptPending: boolean; + hasModelOptions: boolean; + selectedModel: string; + onModelChange: (modelID: string) => void; + modelOptions: readonly ModelSelectorOption[]; + modelSelectorPlaceholder: string; + inputStatusText: string | null; + modelCatalogStatusMessage: string | null; + savingMessageId?: number | null; +} + +const AgentDetailConversation: FC = ({ + store, + chatID, + persistedErrorReason, + compressionThreshold, + onDeleteQueuedMessage, + onPromoteQueuedMessage, + onSend, + onInterrupt, + isInputDisabled, + isSendPending, + isInterruptPending, + hasModelOptions, + selectedModel, + onModelChange, + modelOptions, + modelSelectorPlaceholder, + inputStatusText, + modelCatalogStatusMessage, + savingMessageId, +}) => { + const [editRequest, setEditRequest] = useState<{ + text: string; + messageId: number; + key: number; + } | null>(null); + + const handleEditUserMessage = useCallback( + (messageId: number, text: string) => { + setEditRequest({ text, messageId, key: Date.now() }); + }, + [], + ); + + const handleEditCleared = useCallback(() => { + setEditRequest(null); + }, []); + + return ( + <> + + + + ); +}; + +const AgentDetail: FC = () => { + const navigate = useNavigate(); + const { agentId } = useParams<{ agentId: string }>(); + const outletContext = useOutletContext(); + const queryClient = useQueryClient(); + const [selectedModel, setSelectedModel] = useState(""); + const [showDiffPanel, setShowDiffPanel] = useState(false); + const [pendingEditMessageId, setPendingEditMessageId] = useState< + number | null + >(null); + const chatErrorReasons = outletContext?.chatErrorReasons ?? {}; + const setChatErrorReason = + outletContext?.setChatErrorReason ?? noopSetChatErrorReason; + const clearChatErrorReason = + outletContext?.clearChatErrorReason ?? noopClearChatErrorReason; + const setRightPanelOpen = + outletContext?.setRightPanelOpen ?? noopSetRightPanelOpen; + const requestArchiveAgent = + outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent; + const scrollContainerRef = useRef(null); + + const chatQuery = useQuery({ + ...chat(agentId ?? ""), + enabled: Boolean(agentId), + }); + const chatsQuery = useQuery(chats()); + const workspaceId = chatQuery.data?.chat?.workspace_id; + const workspaceAgentId = chatQuery.data?.chat?.workspace_agent_id; + const workspaceQuery = useQuery({ + ...workspaceById(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); + const diffStatusQuery = useQuery({ + ...chatDiffStatus(agentId ?? ""), + enabled: Boolean(agentId), + }); + const chatModelsQuery = useQuery(chatModels()); + const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const hasDiffStatus = Boolean(diffStatusQuery.data?.url); + const workspace = workspaceQuery.data; + const workspaceAgent = getWorkspaceAgent(workspace, workspaceAgentId); + const chatData = chatQuery.data; + const chatRecord = chatData?.chat; + const chatMessages = chatData?.messages; + const chatQueuedMessages = chatData?.queued_messages; + const chatLastModelConfigID = chatRecord?.last_model_config_id; + + // Auto-open the diff panel when diff status first appears. + // See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false); + if (hasDiffStatus !== prevHasDiffStatus) { + setPrevHasDiffStatus(hasDiffStatus); + if (hasDiffStatus) { + setShowDiffPanel(true); + } + } + + // Notify the parent layout about right panel visibility. This + // useEffect is necessary because we're synchronizing with state + // owned by the parent outlet, not adjusting our own state. + useEffect(() => { + setRightPanelOpen(hasDiffStatus && showDiffPanel); + return () => { + setRightPanelOpen(false); + }; + }, [hasDiffStatus, setRightPanelOpen, showDiffPanel]); + + const modelOptions = useMemo( + () => + getModelOptionsFromCatalog( + chatModelsQuery.data, + chatModelConfigsQuery.data, + ), + [chatModelsQuery.data, chatModelConfigsQuery.data], + ); + const modelConfigIDByModelID = useMemo(() => { + const byModelID = new Map(); + for (const config of chatModelConfigsQuery.data ?? []) { + const provider = config.provider.trim().toLowerCase(); + const model = config.model.trim(); + if (!provider || !model) { + continue; + } + const colonRef = `${provider}:${model}`; + if (!byModelID.has(colonRef)) { + byModelID.set(colonRef, config.id); + } + const slashRef = `${provider}/${model}`; + if (!byModelID.has(slashRef)) { + byModelID.set(slashRef, config.id); + } + } + return byModelID; + }, [chatModelConfigsQuery.data]); + const modelIDByConfigID = useMemo(() => { + const byConfigID = new Map(); + for (const [modelID, configID] of modelConfigIDByModelID.entries()) { + if (!byConfigID.has(configID)) { + byConfigID.set(configID, modelID); + } + } + return byConfigID; + }, [modelConfigIDByModelID]); + + const sendMutation = useMutation( + createChatMessage(queryClient, agentId ?? ""), + ); + const editMutation = useMutation(editChatMessage(queryClient, agentId ?? "")); + const interruptMutation = useMutation( + interruptChat(queryClient, agentId ?? ""), + ); + const deleteQueuedMutation = useMutation( + deleteChatQueuedMessage(queryClient, agentId ?? ""), + ); + const promoteQueuedMutation = useMutation( + promoteChatQueuedMessage(queryClient, agentId ?? ""), + ); + + const { store, clearStreamError } = useChatStore({ + chatID: agentId, + chatMessages, + chatRecord, + chatData, + chatQueuedMessages, + setChatErrorReason, + clearChatErrorReason, + }); + + useEffect(() => { + setSelectedModel((current) => { + if (current && modelOptions.some((model) => model.id === current)) { + return current; + } + if (chatLastModelConfigID) { + const fromChat = modelIDByConfigID.get(chatLastModelConfigID); + if (fromChat && modelOptions.some((model) => model.id === fromChat)) { + return fromChat; + } + } + return modelOptions[0]?.id ?? ""; + }); + }, [chatLastModelConfigID, modelIDByConfigID, modelOptions]); + + const compressionThreshold = useMemo(() => { + if (!chatLastModelConfigID) { + return undefined; + } + const config = chatModelConfigsQuery.data?.find( + (c) => c.id === chatLastModelConfigID, + ); + return config?.compression_threshold; + }, [chatLastModelConfigID, chatModelConfigsQuery.data]); + const hasModelOptions = modelOptions.length > 0; + const hasConfiguredModels = hasConfiguredModelsInCatalog( + chatModelsQuery.data, + ); + const modelSelectorPlaceholder = getModelSelectorPlaceholder( + modelOptions, + chatModelsQuery.isLoading, + hasConfiguredModels, + ); + const modelCatalogStatusMessage = getModelCatalogStatusMessage( + chatModelsQuery.data, + modelOptions, + chatModelsQuery.isLoading, + Boolean(chatModelsQuery.error), + ); + const inputStatusText = hasModelOptions + ? null + : hasConfiguredModels + ? "Models are configured but unavailable. Ask an admin." + : "No models configured. Ask an admin."; + const isSubmissionPending = + sendMutation.isPending || + editMutation.isPending || + interruptMutation.isPending; + const isInputDisabled = !hasModelOptions; + + const handleSend = async (message: string, editedMessageID?: number) => { + if ( + !message.trim() || + isSubmissionPending || + !agentId || + !hasModelOptions + ) { + return; + } + const content: TypesGen.ChatInputPart[] = [{ type: "text", text: message }]; + if (editedMessageID !== undefined) { + const request: TypesGen.EditChatMessageRequest = { content }; + clearChatErrorReason(agentId); + clearStreamError(); + setPendingEditMessageId(editedMessageID); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + const previousChatStatus = store.getSnapshot().chatStatus; + const previousMessages = getOrderedMessagesFromStore(store); + const messageIndex = previousMessages.findIndex( + (msg) => msg.id === editedMessageID, + ); + if (messageIndex !== -1) { + const optimisticEditedMessage: TypesGen.ChatMessage = { + ...previousMessages[messageIndex], + content: toOptimisticMessageParts(request.content), + }; + store.replaceMessages([ + ...previousMessages.slice(0, messageIndex), + optimisticEditedMessage, + ]); + } + store.clearStreamState(); + store.setChatStatus("pending"); + try { + await editMutation.mutateAsync({ + messageId: editedMessageID, + req: request, + }); + } catch (error) { + store.replaceMessages(previousMessages); + store.setChatStatus(previousChatStatus); + throw error; + } finally { + setPendingEditMessageId(null); + } + return; + } + const selectedModelConfigID = + (selectedModel && modelConfigIDByModelID.get(selectedModel)) || undefined; + const request: TypesGen.CreateChatMessageRequest = { + content, + model_config_id: selectedModelConfigID, + }; + clearChatErrorReason(agentId); + clearStreamError(); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + await sendMutation.mutateAsync(request); + if (typeof window !== "undefined") { + if (selectedModelConfigID) { + localStorage.setItem( + lastModelConfigIDStorageKey, + selectedModelConfigID, + ); + } else { + localStorage.removeItem(lastModelConfigIDStorageKey); + } + } + }; + + const handleInterrupt = () => { + if (!agentId || interruptMutation.isPending) { + return; + } + void interruptMutation.mutateAsync(); + }; + + const handleDeleteQueuedMessage = useCallback( + async (id: number) => { + const previousQueuedMessages = store.getSnapshot().queuedMessages; + store.setQueuedMessages( + previousQueuedMessages.filter((message) => message.id !== id), + ); + try { + await deleteQueuedMutation.mutateAsync(id); + } catch (error) { + store.setQueuedMessages(previousQueuedMessages); + throw error; + } + }, + [deleteQueuedMutation, store], + ); + + const handlePromoteQueuedMessage = useCallback( + async (id: number) => { + const previousSnapshot = store.getSnapshot(); + const previousQueuedMessages = previousSnapshot.queuedMessages; + const previousChatStatus = previousSnapshot.chatStatus; + store.setQueuedMessages( + previousQueuedMessages.filter((message) => message.id !== id), + ); + store.clearStreamState(); + store.clearStreamError(); + store.setChatStatus("pending"); + try { + await promoteQueuedMutation.mutateAsync(id); + } catch (error) { + store.setQueuedMessages(previousQueuedMessages); + store.setChatStatus(previousChatStatus); + throw error; + } + }, + [promoteQueuedMutation, store], + ); + + const topBarTitleRef = outletContext?.topBarTitleRef; + const topBarActionsRef = outletContext?.topBarActionsRef; + const rightPanelRef = outletContext?.rightPanelRef; + const chatTitle = chatQuery.data?.chat?.title; + const parentChatID = getParentChatID(chatQuery.data?.chat); + const parentChat = parentChatID + ? chatsQuery.data?.find((chat) => chat.id === parentChatID) + : undefined; + const workspaceRoute = workspace + ? `/@${workspace.owner_name}/${workspace.name}` + : null; + const canOpenWorkspace = Boolean(workspaceRoute); + const canOpenEditors = Boolean(workspace && workspaceAgent); + const shouldShowDiffPanel = hasDiffStatus && showDiffPanel; + + const handleOpenInEditor = async (editor: "cursor" | "vscode") => { + if (!workspace || !workspaceAgent) { + return; + } + + try { + const { key } = await API.getApiKey(); + const vscodeHref = getVSCodeHref("vscode", { + owner: workspace.owner_name, + workspace: workspace.name, + token: key, + agent: workspaceAgent.name, + folder: workspaceAgent.expanded_directory, + }); + + if (editor === "cursor") { + const cursorApp = workspaceAgent.apps.find((app) => { + const name = (app.display_name ?? app.slug).toLowerCase(); + return app.slug.toLowerCase() === "cursor" || name === "cursor"; + }); + if (cursorApp?.external && cursorApp.url) { + const href = cursorApp.url.includes(SESSION_TOKEN_PLACEHOLDER) + ? cursorApp.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, key) + : cursorApp.url; + window.location.assign(href); + return; + } + window.location.assign(vscodeHref.replace(/^vscode:/, "cursor:")); + return; + } + + window.location.assign(vscodeHref); + } catch { + toast.error( + editor === "cursor" + ? "Failed to open in Cursor." + : "Failed to open in VS Code.", + ); + } + }; + + const handleViewWorkspace = () => { + if (!workspaceRoute) { + return; + } + navigate(workspaceRoute); + }; + + const handleArchiveAgentAction = () => { + if (!agentId) { + return; + } + requestArchiveAgent(agentId); + }; + + if (chatQuery.isLoading) { + return ( +
+
+
+
+
+ {/* User message bubble (right-aligned) */} +
+ +
+ {/* Assistant response lines (left-aligned) */} +
+ + + +
+ {/* Second user message bubble */} +
+ +
+ {/* Second assistant response */} +
+ + + + + +
+
+
+ +
+
+
+ ); + } + + if (!chatQuery.data || !agentId) { + return ( +
+ Chat not found +
+ ); + } + + return ( +
+ navigate(`/agents/${chatId}`)} + diff={{ + hasDiffStatus, + diffStatus: diffStatusQuery.data, + showDiffPanel, + onToggleFilesChanged: () => setShowDiffPanel((prev) => !prev), + }} + workspace={{ + canOpenEditors, + canOpenWorkspace, + onOpenInEditor: (editor) => { + void handleOpenInEditor(editor); + }, + onViewWorkspace: handleViewWorkspace, + }} + onArchiveAgent={handleArchiveAgentAction} + shouldShowDiffPanel={shouldShowDiffPanel} + agentId={agentId} + /> + +
+
+
+ +
+
+
+ ); +}; + +export default AgentDetail; diff --git a/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx new file mode 100644 index 0000000000..f6000cef1d --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx @@ -0,0 +1,847 @@ +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import { watchChat } from "api/api"; +import { chatKey } from "api/queries/chats"; +import type * as TypesGen from "api/typesGenerated"; +import type { FC, PropsWithChildren } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import type { OneWayMessageEvent } from "utils/OneWayWebSocket"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + selectOrderedMessageIDs, + selectQueuedMessages, + selectStreamState, + useChatSelector, + useChatStore, +} from "./ChatContext"; + +vi.mock("api/api", () => ({ + watchChat: vi.fn(), +})); + +type MessageListener = ( + payload: OneWayMessageEvent, +) => void; +type ErrorListener = (payload: Event) => void; + +interface MockSocket { + addEventListener(event: "message", callback: MessageListener): void; + addEventListener(event: "error", callback: ErrorListener): void; + removeEventListener(event: "message", callback: MessageListener): void; + removeEventListener(event: "error", callback: ErrorListener): void; + close: () => void; + emitData: (event: TypesGen.ChatStreamEvent) => void; + emitDataBatch: (events: readonly TypesGen.ChatStreamEvent[]) => void; +} + +const createMockSocket = (): MockSocket => { + const messageListeners = new Set(); + const errorListeners = new Set(); + + const addEventListener = ( + event: "message" | "error", + callback: MessageListener | ErrorListener, + ): void => { + if (event === "message") { + messageListeners.add(callback as MessageListener); + return; + } + errorListeners.add(callback as ErrorListener); + }; + + const removeEventListener = ( + event: "message" | "error", + callback: MessageListener | ErrorListener, + ): void => { + if (event === "message") { + messageListeners.delete(callback as MessageListener); + return; + } + errorListeners.delete(callback as ErrorListener); + }; + + return { + addEventListener, + removeEventListener, + close: vi.fn(), + emitData: (event) => { + const payload: OneWayMessageEvent = { + sourceEvent: {} as MessageEvent, + parseError: undefined, + parsedMessage: { + type: "data", + data: event, + }, + }; + for (const listener of messageListeners) { + listener(payload); + } + }, + emitDataBatch: (events) => { + const payload: OneWayMessageEvent = { + sourceEvent: {} as MessageEvent, + parseError: undefined, + parsedMessage: { + type: "data", + data: events, + }, + }; + for (const listener of messageListeners) { + listener(payload); + } + }, + }; +}; + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, + }); + +const makeChat = (chatID: string): TypesGen.Chat => ({ + id: chatID, + owner_id: "owner-1", + last_model_config_id: "model-1", + title: "test", + status: "running", + created_at: "2025-01-01T00:00:00.000Z", + updated_at: "2025-01-01T00:00:00.000Z", +}); + +const makeMessage = ( + chatID: string, + id: number, + role: string, + text: string, +): TypesGen.ChatMessage => ({ + id, + chat_id: chatID, + created_at: "2025-01-01T00:00:00.000Z", + role, + content: [{ type: "text", text }], +}); + +const makeQueuedMessage = ( + chatID: string, + id: number, + text: string, +): TypesGen.ChatQueuedMessage => ({ + id, + chat_id: chatID, + created_at: "2025-01-01T00:00:00.000Z", + content: [{ type: "text", text }], +}); + +const immediateAnimationFrame = (): void => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useChatStore", () => { + it("does not clear in-progress stream parts for duplicate snapshot messages", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "reconnect-part-one", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "reconnect-part-one" }, + ]); + }); + + act(() => { + const duplicateSnapshotMessage: TypesGen.ChatMessage = { + ...existingMessage, + content: [...(existingMessage.content ?? [])], + }; + mockSocket.emitData({ + type: "message", + chat_id: chatID, + message: duplicateSnapshotMessage, + }); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "reconnect-part-one" }, + ]); + }); + }); + + it("clears stream state when a new durable message arrives", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const newMessage = makeMessage(chatID, 2, "assistant", "done"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "working", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "working" }, + ]); + }); + + act(() => { + mockSocket.emitData({ + type: "message", + chat_id: chatID, + message: newMessage, + }); + }); + + await waitFor(() => { + expect(result.current.streamState).toBeNull(); + }); + }); + + it("clears stream state when a duplicate message id arrives with new content", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "assistant", "old"); + const updatedMessage = makeMessage(chatID, 1, "assistant", "updated"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "partial", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "partial" }, + ]); + }); + + act(() => { + mockSocket.emitData({ + type: "message", + chat_id: chatID, + message: updatedMessage, + }); + }); + + await waitFor(() => { + expect(result.current.streamState).toBeNull(); + }); + }); + + it("keeps non-stream selectors from rerendering during message_part updates", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + let streamRenderCount = 0; + let queueRenderCount = 0; + let orderedIDsRenderCount = 0; + + type ChatStoreHandle = ReturnType["store"]; + + const StreamProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => { + useChatSelector(store, selectStreamState); + streamRenderCount += 1; + return null; + }; + + const QueueProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => { + useChatSelector(store, selectQueuedMessages); + queueRenderCount += 1; + return null; + }; + + const OrderedIDsProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => { + useChatSelector(store, selectOrderedMessageIDs); + orderedIDsRenderCount += 1; + return null; + }; + + const TestHarness: FC = () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return ( + <> + + + + + ); + }; + + render( + + + , + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + const streamBaseline = streamRenderCount; + const queueBaseline = queueRenderCount; + const orderedIDsBaseline = orderedIDsRenderCount; + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "partial", + }, + }, + }); + }); + + await waitFor(() => { + expect(streamRenderCount).toBeGreaterThan(streamBaseline); + }); + expect(queueRenderCount).toBe(queueBaseline); + expect(orderedIDsRenderCount).toBe(orderedIDsBaseline); + }); + + it("applies batched message_part events from one payload", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitDataBatch([ + { + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "hello ", + }, + }, + }, + { + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "world", + }, + }, + }, + ]); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "hello world" }, + ]); + }); + }); + + it("ignores message_part updates while chat is pending", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [], + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "first", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "first" }, + ]); + }); + + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "pending" }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState).toBeNull(); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { + type: "text", + text: "late", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.streamState).toBeNull(); + }); + }); + + it("does not restore stale queued messages after a stream queue_update", async () => { + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const queuedMessage = makeQueuedMessage(chatID, 10, "queued"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + const initialOptions = { + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [queuedMessage], + }, + chatQueuedMessages: [queuedMessage], + setChatErrorReason, + clearChatErrorReason, + }; + + const { result, rerender } = renderHook( + (options: Parameters[0]) => { + const { store } = useChatStore(options); + return { + queuedMessages: useChatSelector(store, selectQueuedMessages), + }; + }, + { + initialProps: initialOptions, + wrapper, + }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + expect(result.current.queuedMessages.map((message) => message.id)).toEqual([ + queuedMessage.id, + ]); + + act(() => { + mockSocket.emitData({ + type: "queue_update", + chat_id: chatID, + queued_messages: [], + }); + }); + + await waitFor(() => { + expect(result.current.queuedMessages).toEqual([]); + }); + + rerender({ + ...initialOptions, + chatData: { + chat: { + ...makeChat(chatID), + updated_at: "2025-01-01T00:00:01.000Z", + }, + messages: [existingMessage], + queued_messages: [queuedMessage], + }, + chatQueuedMessages: [queuedMessage], + }); + + await waitFor(() => { + expect(result.current.queuedMessages).toEqual([]); + }); + }); + + it("writes queue_update snapshots into the chat query cache", async () => { + const chatID = "chat-1"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const queuedMessage = makeQueuedMessage(chatID, 10, "queued"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, + }); + const initialChatData: TypesGen.ChatWithMessages = { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [queuedMessage], + }; + queryClient.setQueryData(chatKey(chatID), initialChatData); + + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: initialChatData, + chatQueuedMessages: [queuedMessage], + setChatErrorReason, + clearChatErrorReason, + }); + return { + queuedMessages: useChatSelector(store, selectQueuedMessages), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "queue_update", + chat_id: chatID, + queued_messages: [], + }); + }); + + await waitFor(() => { + expect(result.current.queuedMessages).toEqual([]); + }); + expect( + queryClient.getQueryData( + chatKey(chatID), + )?.queued_messages, + ).toEqual([]); + }); + + it("ignores queue_update events for other chats", async () => { + const chatID = "chat-1"; + const otherChatID = "chat-2"; + const existingMessage = makeMessage(chatID, 1, "user", "hello"); + const queuedMessage = makeQueuedMessage(chatID, 10, "queued"); + const mockSocket = createMockSocket(); + vi.mocked(watchChat).mockReturnValue(mockSocket as never); + + const queryClient = createTestQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: makeChat(chatID), + chatData: { + chat: makeChat(chatID), + messages: [existingMessage], + queued_messages: [queuedMessage], + }, + chatQueuedMessages: [queuedMessage], + setChatErrorReason, + clearChatErrorReason, + }); + return { + queuedMessages: useChatSelector(store, selectQueuedMessages), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID); + }); + + act(() => { + mockSocket.emitData({ + type: "queue_update", + chat_id: otherChatID, + queued_messages: [], + }); + }); + + await waitFor(() => { + expect( + result.current.queuedMessages.map((message) => message.id), + ).toEqual([queuedMessage.id]); + }); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts b/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts new file mode 100644 index 0000000000..60bd28d435 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts @@ -0,0 +1,690 @@ +import { watchChat } from "api/api"; +import { + chatDiffContentsKey, + chatDiffStatusKey, + chatKey, + chatsKey, +} from "api/queries/chats"; +import type * as TypesGen from "api/typesGenerated"; +import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils"; +import { + startTransition, + useCallback, + useEffect, + useRef, + useSyncExternalStore, +} from "react"; +import { useQueryClient } from "react-query"; +import type { OneWayMessageEvent } from "utils/OneWayWebSocket"; +import { applyMessagePartToStreamState } from "./streamState"; +import type { StreamState } from "./types"; + +const VALID_CHAT_STATUSES: ReadonlySet = new Set([ + "pending", + "running", + "completed", + "error", + "paused", + "waiting", +]); + +const isValidChatStatus = (value: unknown): value is TypesGen.ChatStatus => + typeof value === "string" && VALID_CHAT_STATUSES.has(value); + +const isChatStreamEvent = ( + data: unknown, +): data is TypesGen.ChatStreamEvent & Record => + typeof data === "object" && + data !== null && + "type" in data && + typeof (data as Record).type === "string"; + +const isChatStreamEventArray = ( + data: unknown, +): data is (TypesGen.ChatStreamEvent & Record)[] => + Array.isArray(data) && data.every(isChatStreamEvent); + +const toChatStreamEvents = ( + data: unknown, +): (TypesGen.ChatStreamEvent & Record)[] => { + if (isChatStreamEvent(data)) { + return [data]; + } + if (isChatStreamEventArray(data)) { + return data; + } + return []; +}; + +const byMessageCreatedAt = ( + left: TypesGen.ChatMessage, + right: TypesGen.ChatMessage, +): number => { + return ( + new Date(left.created_at).getTime() - new Date(right.created_at).getTime() + ); +}; + +const buildMessageMap = ( + messages: readonly TypesGen.ChatMessage[], +): Map => + new Map(messages.map((message) => [message.id, message])); + +const buildOrderedMessageIDs = ( + messages: readonly TypesGen.ChatMessage[], +): readonly number[] => { + const sorted = [...messages]; + sorted.sort(byMessageCreatedAt); + return sorted.map((message) => message.id); +}; + +const mapsEqualByRef = (left: Map, right: Map): boolean => { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (!right.has(key) || right.get(key) !== value) { + return false; + } + } + return true; +}; + +const arraysEqual = (left: readonly T[], right: readonly T[]): boolean => { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +}; + +const jsonValuesEqual = (left: unknown, right: unknown): boolean => { + if (left === right) { + return true; + } + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +}; + +const chatMessagesEqualByValue = ( + left: TypesGen.ChatMessage, + right: TypesGen.ChatMessage, +): boolean => + left.id === right.id && + left.chat_id === right.chat_id && + left.model_config_id === right.model_config_id && + left.created_at === right.created_at && + left.role === right.role && + jsonValuesEqual(left.content, right.content) && + jsonValuesEqual(left.usage, right.usage); + +const chatQueuedMessagesEqualByID = ( + left: readonly TypesGen.ChatQueuedMessage[], + right: readonly TypesGen.ChatQueuedMessage[], +): boolean => { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index]?.id !== right[index]?.id) { + return false; + } + } + return true; +}; + +type ChatStoreState = { + messagesByID: Map; + orderedMessageIDs: readonly number[]; + streamState: StreamState | null; + chatStatus: TypesGen.ChatStatus | null; + streamError: string | null; + queuedMessages: readonly TypesGen.ChatQueuedMessage[]; + subagentStatusOverrides: Map; +}; + +type ChatStore = { + getSnapshot: () => ChatStoreState; + subscribe: (listener: () => void) => () => void; + replaceMessages: ( + messages: readonly TypesGen.ChatMessage[] | undefined, + ) => void; + upsertDurableMessage: (message: TypesGen.ChatMessage) => { + isDuplicate: boolean; + changed: boolean; + }; + applyMessagePart: (part: Record) => void; + applyMessageParts: (parts: readonly Record[]) => void; + setQueuedMessages: ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + ) => void; + setChatStatus: (status: TypesGen.ChatStatus | null) => void; + setStreamError: (reason: string | null) => void; + clearStreamError: () => void; + clearStreamState: () => void; + setSubagentStatusOverride: ( + chatID: string, + status: TypesGen.ChatStatus, + ) => void; + resetTransientState: () => void; +}; + +const createInitialState = (): ChatStoreState => ({ + messagesByID: new Map(), + orderedMessageIDs: [], + streamState: null, + chatStatus: null, + streamError: null, + queuedMessages: [], + subagentStatusOverrides: new Map(), +}); + +const createChatStore = (): ChatStore => { + let state = createInitialState(); + const listeners = new Set<() => void>(); + + const emit = (): void => { + for (const listener of listeners) { + listener(); + } + }; + + const setState = ( + updater: (current: ChatStoreState) => ChatStoreState, + ): void => { + const next = updater(state); + if (next === state) { + return; + } + state = next; + emit(); + }; + + const replaceMessages = ( + messages: readonly TypesGen.ChatMessage[] | undefined, + ): void => { + const safeMessages = messages ?? []; + const nextMessagesByID = buildMessageMap(safeMessages); + const nextOrderedMessageIDs = buildOrderedMessageIDs(safeMessages); + + if ( + mapsEqualByRef(state.messagesByID, nextMessagesByID) && + arraysEqual(state.orderedMessageIDs, nextOrderedMessageIDs) + ) { + return; + } + + setState((current) => ({ + ...current, + messagesByID: nextMessagesByID, + orderedMessageIDs: nextOrderedMessageIDs, + })); + }; + + const upsertDurableMessage = (message: TypesGen.ChatMessage) => { + const existing = state.messagesByID.get(message.id); + const isDuplicate = state.messagesByID.has(message.id); + if (existing && chatMessagesEqualByValue(existing, message)) { + return { isDuplicate, changed: false }; + } + + const nextMessagesByID = new Map(state.messagesByID); + nextMessagesByID.set(message.id, message); + const nextOrderedMessageIDs = isDuplicate + ? state.orderedMessageIDs + : buildOrderedMessageIDs(Array.from(nextMessagesByID.values())); + + setState((current) => ({ + ...current, + messagesByID: nextMessagesByID, + orderedMessageIDs: nextOrderedMessageIDs, + })); + return { isDuplicate, changed: true }; + }; + + const applyMessageParts = (parts: readonly Record[]) => { + if (parts.length === 0) { + return; + } + + let nextStreamState: StreamState | null = state.streamState; + for (const part of parts) { + nextStreamState = applyMessagePartToStreamState(nextStreamState, part); + } + if (nextStreamState === state.streamState) { + return; + } + setState((current) => ({ + ...current, + streamState: nextStreamState, + })); + }; + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + replaceMessages, + upsertDurableMessage, + applyMessagePart: (part) => applyMessageParts([part]), + applyMessageParts, + setQueuedMessages: (queuedMessages) => { + const nextQueuedMessages = queuedMessages ?? []; + if ( + chatQueuedMessagesEqualByID(state.queuedMessages, nextQueuedMessages) + ) { + return; + } + setState((current) => ({ + ...current, + queuedMessages: nextQueuedMessages, + })); + }, + setChatStatus: (status) => { + if (state.chatStatus === status) { + return; + } + setState((current) => ({ + ...current, + chatStatus: status, + })); + }, + setStreamError: (reason) => { + if (state.streamError === reason) { + return; + } + setState((current) => ({ + ...current, + streamError: reason, + })); + }, + clearStreamError: () => { + if (state.streamError === null) { + return; + } + setState((current) => ({ + ...current, + streamError: null, + })); + }, + clearStreamState: () => { + if (state.streamState === null) { + return; + } + setState((current) => ({ + ...current, + streamState: null, + })); + }, + setSubagentStatusOverride: (chatID, status) => { + if (state.subagentStatusOverrides.get(chatID) === status) { + return; + } + const nextOverrides = new Map(state.subagentStatusOverrides); + nextOverrides.set(chatID, status); + setState((current) => ({ + ...current, + subagentStatusOverrides: nextOverrides, + })); + }, + resetTransientState: () => { + if ( + state.streamState === null && + state.streamError === null && + state.subagentStatusOverrides.size === 0 + ) { + return; + } + setState((current) => ({ + ...current, + streamState: null, + streamError: null, + subagentStatusOverrides: new Map(), + })); + }, + }; +}; + +interface UseChatStoreOptions { + chatID: string | undefined; + chatMessages: readonly TypesGen.ChatMessage[] | undefined; + chatRecord: TypesGen.Chat | undefined; + chatData: TypesGen.ChatWithMessages | undefined; + chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined; + setChatErrorReason: (chatID: string, reason: string) => void; + clearChatErrorReason: (chatID: string) => void; +} + +export const selectMessagesByID = (state: ChatStoreState) => state.messagesByID; +export const selectOrderedMessageIDs = (state: ChatStoreState) => + state.orderedMessageIDs; +export const selectStreamState = (state: ChatStoreState) => state.streamState; +export const selectHasStreamState = (state: ChatStoreState) => + state.streamState !== null; +export const selectChatStatus = (state: ChatStoreState) => state.chatStatus; +export const selectStreamError = (state: ChatStoreState) => state.streamError; +export const selectQueuedMessages = (state: ChatStoreState) => + state.queuedMessages; +export const selectSubagentStatusOverrides = (state: ChatStoreState) => + state.subagentStatusOverrides; + +export const useChatStore = ( + options: UseChatStoreOptions, +): { store: ChatStore; clearStreamError: () => void } => { + const { + chatID, + chatMessages, + chatRecord, + chatData, + chatQueuedMessages, + setChatErrorReason, + clearChatErrorReason, + } = options; + + const queryClient = useQueryClient(); + const storeRef = useRef(createChatStore()); + const streamResetFrameRef = useRef(null); + const queuedMessagesHydratedChatIDRef = useRef(null); + + const store = storeRef.current; + + const updateSidebarChat = useCallback( + (updater: (chat: TypesGen.Chat) => TypesGen.Chat) => { + if (!chatID) { + return; + } + queryClient.setQueryData( + chatsKey, + (currentChats) => { + if (!currentChats) { + return currentChats; + } + let didUpdate = false; + const nextChats = currentChats.map((chat) => { + if (chat.id !== chatID) { + return chat; + } + didUpdate = true; + return updater(chat); + }); + return didUpdate ? nextChats : currentChats; + }, + ); + }, + [chatID, queryClient], + ); + + const cancelScheduledStreamReset = useCallback(() => { + if (streamResetFrameRef.current === null) { + return; + } + window.cancelAnimationFrame(streamResetFrameRef.current); + streamResetFrameRef.current = null; + }, []); + + const scheduleStreamReset = useCallback(() => { + cancelScheduledStreamReset(); + streamResetFrameRef.current = window.requestAnimationFrame(() => { + store.clearStreamState(); + streamResetFrameRef.current = null; + }); + }, [cancelScheduledStreamReset, store]); + + const updateChatQueuedMessages = useCallback( + (queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined) => { + if (!chatID) { + return; + } + const nextQueuedMessages = queuedMessages ?? []; + queryClient.setQueryData( + chatKey(chatID), + (currentChat) => { + if (!currentChat) { + return currentChat; + } + if ( + chatQueuedMessagesEqualByID( + currentChat.queued_messages, + nextQueuedMessages, + ) + ) { + return currentChat; + } + return { + ...currentChat, + queued_messages: nextQueuedMessages, + }; + }, + ); + }, + [chatID, queryClient], + ); + + useEffect(() => { + store.replaceMessages(chatMessages); + }, [chatMessages, store]); + + useEffect(() => { + store.setChatStatus(chatRecord?.status ?? null); + }, [chatRecord?.status, store]); + + useEffect(() => { + queuedMessagesHydratedChatIDRef.current = null; + store.setQueuedMessages([]); + if (!chatID) { + return; + } + }, [chatID, store]); + + useEffect(() => { + if (!chatID || !chatData) { + return; + } + if (queuedMessagesHydratedChatIDRef.current === chatID) { + return; + } + queuedMessagesHydratedChatIDRef.current = chatID; + store.setQueuedMessages(chatQueuedMessages); + }, [chatData, chatID, chatQueuedMessages, store]); + + useEffect(() => { + cancelScheduledStreamReset(); + store.resetTransientState(); + + if (!chatID) { + return; + } + + const socket = watchChat(chatID); + const handleMessage = ( + payload: OneWayMessageEvent, + ) => { + if (payload.parseError || !payload.parsedMessage) { + store.setStreamError("Failed to parse chat stream update."); + return; + } + if (payload.parsedMessage.type !== "data") { + return; + } + + const streamEvents = toChatStreamEvents(payload.parsedMessage.data); + if (streamEvents.length === 0) { + return; + } + + const shouldApplyMessagePart = (): boolean => { + const currentStatus = store.getSnapshot().chatStatus; + return currentStatus !== "pending" && currentStatus !== "waiting"; + }; + + const pendingMessageParts: Record[] = []; + const flushMessageParts = () => { + if (pendingMessageParts.length === 0) { + return; + } + cancelScheduledStreamReset(); + const parts = pendingMessageParts.splice(0, pendingMessageParts.length); + startTransition(() => { + store.applyMessageParts(parts); + }); + }; + + for (const streamEvent of streamEvents) { + if (streamEvent.type === "message_part") { + const eventChatID = asString(streamEvent.chat_id); + if (eventChatID && eventChatID !== chatID) { + continue; + } + if (!shouldApplyMessagePart()) { + continue; + } + const part = asRecord(streamEvent.message_part?.part); + if (part) { + pendingMessageParts.push(part); + } + continue; + } + flushMessageParts(); + + switch (streamEvent.type) { + case "message": { + const message = streamEvent.message; + if (!message) { + continue; + } + const { changed } = store.upsertDurableMessage(message); + if (changed) { + scheduleStreamReset(); + } + updateSidebarChat((chat) => ({ + ...chat, + updated_at: message.created_at ?? new Date().toISOString(), + })); + continue; + } + case "queue_update": + { + const eventChatID = asString(streamEvent.chat_id); + if (eventChatID && eventChatID !== chatID) { + continue; + } + } + store.setQueuedMessages(streamEvent.queued_messages); + updateChatQueuedMessages(streamEvent.queued_messages); + continue; + case "status": { + const status = asRecord(streamEvent.status); + const nextStatus = asString(status?.status); + if (!isValidChatStatus(nextStatus)) { + continue; + } + + const eventChatID = asString(streamEvent.chat_id); + if (eventChatID && eventChatID !== chatID) { + store.setSubagentStatusOverride(eventChatID, nextStatus); + continue; + } + + const previousStatus = store.getSnapshot().chatStatus; + store.setChatStatus(nextStatus); + if (nextStatus === "pending" || nextStatus === "waiting") { + store.clearStreamState(); + } + if (nextStatus !== "error") { + clearChatErrorReason(chatID); + } + updateSidebarChat((chat) => ({ + ...chat, + status: nextStatus, + updated_at: new Date().toISOString(), + })); + if (previousStatus !== nextStatus) { + void Promise.all([ + queryClient.invalidateQueries({ + queryKey: chatDiffStatusKey(chatID), + }), + queryClient.invalidateQueries({ + queryKey: chatDiffContentsKey(chatID), + }), + ]); + } + + continue; + } + case "error": { + const error = asRecord(streamEvent.error); + const reason = + asString(error?.message).trim() || "Chat processing failed."; + store.setChatStatus("error"); + store.setStreamError(reason); + setChatErrorReason(chatID, reason); + updateSidebarChat((chat) => ({ + ...chat, + status: "error", + updated_at: new Date().toISOString(), + })); + continue; + } + default: + continue; + } + } + flushMessageParts(); + }; + + const handleError = () => { + if (!store.getSnapshot().streamError) { + store.setStreamError("Chat stream disconnected."); + } + }; + + socket.addEventListener("message", handleMessage); + socket.addEventListener("error", handleError); + + return () => { + socket.removeEventListener("message", handleMessage); + socket.removeEventListener("error", handleError); + socket.close(); + cancelScheduledStreamReset(); + }; + }, [ + cancelScheduledStreamReset, + chatID, + clearChatErrorReason, + queryClient, + scheduleStreamReset, + setChatErrorReason, + store, + updateChatQueuedMessages, + updateSidebarChat, + ]); + + return { + store, + clearStreamError: useCallback(() => { + store.clearStreamError(); + }, [store]), + }; +}; + +export const useChatSelector = ( + store: ChatStore, + selector: (state: ChatStoreState) => T, +): T => { + const getSnapshot = useCallback( + () => selector(store.getSnapshot()), + [selector, store], + ); + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx new file mode 100644 index 0000000000..b1b40997a3 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -0,0 +1,703 @@ +import type * as TypesGen from "api/typesGenerated"; +import { + ConversationItem, + Message, + MessageContent, + Response, + Shimmer, + Tool, +} from "components/ai-elements"; +import { ChevronDownIcon, Loader2Icon } from "lucide-react"; +import { + type FC, + memo, + type ReactNode, + type RefObject, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { cn } from "utils/cn"; +import type { + MergedTool, + ParsedMessageContent, + ParsedMessageSection, + RenderBlock, + StreamState, +} from "./types"; + +const ReasoningDisclosure: FC<{ + id: string; + title?: string; + text: string; + isStreaming?: boolean; +}> = ({ id, title, text, isStreaming = false }) => { + const [isOpen, setIsOpen] = useState(false); + const hasText = text.trim().length > 0; + const label = title ?? "Thinking"; + const showStreamingPlaceholder = isStreaming && !hasText; + + if (!title && hasText) { + return ( +
+ + {text} + +
+ ); + } + + const labelContent = ( + + {showStreamingPlaceholder ? ( + Thinking... + ) : ( + label + )} + + ); + + return ( +
+ {hasText ? ( +
setIsOpen((prev) => !prev)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setIsOpen((prev) => !prev); + } + }} + > + {labelContent} + +
+ ) : ( +
+ {labelContent} +
+ )} + {isOpen && hasText ? ( +
+ + {text} + +
+ ) : null} +
+ ); +}; + +// Shared block renderer used by both ChatMessageItem (historical +// messages) and StreamingOutput (live stream). Encapsulates the +// response / thinking / tool switch so the two consumers stay in sync. +type RenderBlockListParams = { + blocks: readonly RenderBlock[]; + toolByID: ReadonlyMap; + keyPrefix: string; + isStreaming?: boolean; + subagentTitles?: Map; + subagentStatusOverrides?: Map; +}; + +type RenderBlockListResult = { + elements: ReactNode[]; + renderedToolIDs: ReadonlySet; +}; + +function renderBlockList({ + blocks, + toolByID, + keyPrefix, + isStreaming = false, + subagentTitles, + subagentStatusOverrides, +}: RenderBlockListParams): RenderBlockListResult { + const renderedToolIDs = new Set(); + const elements = blocks + .map((block, index) => { + switch (block.type) { + case "response": + return ( + + {block.text} + + ); + case "thinking": + return ( + + ); + case "tool": { + const tool = toolByID.get(block.id); + if (!tool) { + if (!isStreaming) { + return null; + } + // Streaming placeholder for not-yet-resolved tool. + renderedToolIDs.add(block.id); + return ( + + ); + } + renderedToolIDs.add(tool.id); + return ( + + ); + } + default: + return null; + } + }) + .filter((el): el is NonNullable => el != null); + return { elements, renderedToolIDs }; +} + +const ChatMessageItem = memo<{ + message: TypesGen.ChatMessage; + parsed: ParsedMessageContent; + onEditUserMessage?: (messageId: number, text: string) => void; + editingMessageId?: number | null; + savingMessageId?: number | null; + // When true, renders a gradient overlay inside the bubble + // that fades text out toward the bottom. Used by the sticky + // overlay to indicate truncated content. + fadeFromBottom?: boolean; +}>( + ({ + message, + parsed, + onEditUserMessage, + editingMessageId, + savingMessageId, + fadeFromBottom = false, + }) => { + const isUser = message.role === "user"; + const isSavingMessage = savingMessageId === message.id; + const toolByID = new Map(parsed.tools.map((tool) => [tool.id, tool])); + + if ( + parsed.toolResults.length > 0 && + parsed.toolCalls.length === 0 && + parsed.markdown === "" && + parsed.reasoning === "" + ) { + return null; + } + + const hasRenderableContent = + parsed.blocks.length > 0 || parsed.tools.length > 0; + const conversationItemProps: { role: "user" | "assistant" } = { + role: isUser ? "user" : "assistant", + }; + const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({ + blocks: parsed.blocks, + toolByID, + keyPrefix: String(message.id), + }); + const remainingTools = parsed.tools.filter( + (tool) => !renderedToolIDs.has(tool.id), + ); + + return ( + + {isUser ? ( + + onEditUserMessage(message.id, parsed.markdown || "") + : undefined + } + > +
+ {parsed.markdown || ""} + {isSavingMessage && ( + + )} +
+ {fadeFromBottom && ( +
+ )} + + + ) : ( + + +
+ {orderedBlocks} + {remainingTools.map((tool) => ( + + ))} + {!hasRenderableContent && ( +
+ Message has no renderable content. +
+ )} +
+
+
+ )} + + ); + }, +); +ChatMessageItem.displayName = "ChatMessageItem"; + +const StreamingOutput = memo<{ + streamState: StreamState | null; + streamTools: readonly MergedTool[]; + subagentTitles?: Map; + subagentStatusOverrides?: Map; + showInitialPlaceholder?: boolean; +}>( + ({ + streamState, + streamTools, + subagentTitles, + subagentStatusOverrides, + showInitialPlaceholder = false, + }) => { + const conversationItemProps = { role: "assistant" as const }; + const toolByID = new Map(streamTools.map((tool) => [tool.id, tool])); + const blocks = streamState?.blocks ?? []; + const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({ + blocks, + toolByID, + keyPrefix: "stream", + isStreaming: true, + subagentTitles, + subagentStatusOverrides, + }); + const remainingTools = streamTools.filter( + (tool) => !renderedToolIDs.has(tool.id), + ); + + return ( + + + +
+ {orderedBlocks} + {showInitialPlaceholder || + (streamState && + orderedBlocks.length === 0 && + streamTools.length === 0) ? ( +
+ + Thinking... + +
+ + Thinking... + +
+
+ ) : null} + {remainingTools.map((tool) => ( + + ))} +
+
+
+
+ ); + }, +); +StreamingOutput.displayName = "StreamingOutput"; + +const StickyUserMessage: FC<{ + message: TypesGen.ChatMessage; + parsed: ParsedMessageContent; + onEditUserMessage?: (messageId: number, text: string) => void; + editingMessageId?: number | null; + savingMessageId?: number | null; +}> = ({ + message, + parsed, + onEditUserMessage, + editingMessageId, + savingMessageId, +}) => { + const [isStuck, setIsStuck] = useState(false); + const [isReady, setIsReady] = useState(false); + const sentinelRef = useRef(null); + const containerRef = useRef(null); + + // useLayoutEffect so isStuck and --clip-h are both resolved + // before the browser paints, avoiding a flash on load. + useLayoutEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + // Immediate check so the first paint is correct when the + // sentinel is already scrolled out of view. + const scroller = sentinel.closest(".overflow-y-auto"); + if (scroller) { + const stuck = + sentinel.getBoundingClientRect().top < + scroller.getBoundingClientRect().top; + if (stuck) { + setIsStuck(true); + } + } + setIsReady(true); + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { threshold: 0 }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, []); + + // Sets a single CSS custom property (--clip-h) on the sticky + // container. All visual behaviour (max-height, mask fade) is + // driven by CSS using this variable. + useLayoutEffect(() => { + const sentinel = sentinelRef.current; + const container = containerRef.current; + if (!sentinel || !container) return; + const scroller = sentinel.closest(".overflow-y-auto") as HTMLElement | null; + if (!scroller) return; + + const MIN_HEIGHT = 72; + let scrollerTop = scroller.getBoundingClientRect().top; + + const update = () => { + const fullHeight = container.offsetHeight; + const sentinelTop = sentinel.getBoundingClientRect().top; + const scrolledPast = scrollerTop - sentinelTop; + + if (scrolledPast <= 0) { + // Always set a valid value so the overlay has the + // correct height immediately when isStuck flips. + container.style.setProperty("--clip-h", `${fullHeight}px`); + container.style.setProperty("--fade-opacity", "0"); + return; + } + + const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT); + container.style.setProperty("--clip-h", `${visible}px`); + // Only show the fade gradient once enough content is + // clipped to be visually meaningful. + container.style.setProperty( + "--fade-opacity", + visible < fullHeight - 8 ? "1" : "0", + ); + }; + + const onResize = () => { + scrollerTop = scroller.getBoundingClientRect().top; + update(); + }; + + // Throttle to one update per animation frame so we don't + // do redundant work on high-refresh-rate displays. + let rafId: number | null = null; + const onScroll = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + update(); + }); + }; + + scroller.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onResize); + update(); + // Set immediately β€” both --clip-h and --overlay-ready are + // applied before the browser paints since we're in a + // useLayoutEffect. + container.style.setProperty("--overlay-ready", "1"); + return () => { + scroller.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onResize); + container.style.removeProperty("--overlay-ready"); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, []); + + const handleEditUserMessage = onEditUserMessage + ? (messageId: number, text: string) => { + onEditUserMessage(messageId, text); + requestAnimationFrame(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const scroller = sentinel.closest( + ".overflow-y-auto", + ) as HTMLElement | null; + if (!scroller) return; + const offset = + sentinel.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + scroller.scrollBy({ top: offset, behavior: "smooth" }); + }); + } + : undefined; + + return ( + <> +
+
+ {/* Flow element: always in the DOM to preserve + scroll layout. Hidden when stuck so the + clipped overlay takes over visually. */} +
+ +
+ + {/* Overlay: absolutely positioned, matching the + sticky container. max-height + mask are driven + entirely by the --clip-h CSS variable which the + scroll handler sets on the container. */} + {isStuck && ( +
+ {/* Blur layer: extends 48px beyond the + clipped content so the frosted effect + is visible around the bubble. Promoted + to its own GPU layer via will-change. */} +
+ {/* Content layer: px-3 pt-2 matches the + sticky container's padding so the + overlay aligns with the flow element. + will-change promotes to GPU layer. */} +
+ +
+
+ )} +
+ + ); +}; + +type ConversationTimelineProps = { + isEmpty: boolean; + hasMoreMessages: boolean; + loadMoreSentinelRef: RefObject; + parsedSections: readonly ParsedMessageSection[]; + hasStreamOutput: boolean; + streamState: StreamState | null; + streamTools: readonly MergedTool[]; + subagentTitles: Map; + subagentStatusOverrides: Map; + isAwaitingFirstStreamChunk: boolean; + detailErrorMessage?: string | null; + onEditUserMessage?: (messageId: number, text: string) => void; + editingMessageId?: number | null; + savingMessageId?: number | null; +}; + +export const ConversationTimeline: FC = ({ + isEmpty, + hasMoreMessages, + loadMoreSentinelRef, + parsedSections, + hasStreamOutput, + streamState, + streamTools, + subagentTitles, + subagentStatusOverrides, + isAwaitingFirstStreamChunk, + detailErrorMessage, + onEditUserMessage, + editingMessageId, + savingMessageId, +}) => { + const shouldRenderStreamInLastSection = + hasStreamOutput && parsedSections.length > 0; + + return ( +
+ {isEmpty && !hasStreamOutput ? ( +
+

Start a conversation with your agent.

+
+ ) : ( +
+ {hasMoreMessages && ( +
+ Loading earlier messages… +
+ )} + {parsedSections.map((section, sectionIdx) => ( +
+
+ {section.entries.map(({ message, parsed }) => + message.role === "user" ? ( + + ) : ( + + ), + )} + {shouldRenderStreamInLastSection && + sectionIdx === parsedSections.length - 1 && ( + + )} +
+
+ ))} + {hasStreamOutput && parsedSections.length === 0 && ( + + )} +
+ )} + {detailErrorMessage && ( +
+ {detailErrorMessage} +
+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx new file mode 100644 index 0000000000..f88352d1f0 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx @@ -0,0 +1,199 @@ +import type { ChatDiffStatusResponse } from "api/api"; +import type * as TypesGen from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { + ArchiveIcon, + ChevronRightIcon, + EllipsisIcon, + ExternalLinkIcon, + MonitorIcon, + PanelRightCloseIcon, + PanelRightOpenIcon, +} from "lucide-react"; +import type { FC, RefObject } from "react"; +import { createPortal } from "react-dom"; +import { FilesChangedPanel } from "../FilesChangedPanel"; + +interface DiffStatsBadgeProps { + status: ChatDiffStatusResponse; + isOpen: boolean; + onToggle: () => void; +} + +const DiffStatsBadge: FC = ({ + status, + isOpen, + onToggle, +}) => { + const additions = status.additions ?? 0; + const deletions = status.deletions ?? 0; + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + onToggle(); + } + }} + className="flex cursor-pointer items-center gap-3 px-2 py-1 text-content-secondary transition-colors hover:text-content-primary" + > + + +{additions} + + + βˆ’{deletions} + + {isOpen ? ( + + ) : ( + + )} +
+ ); +}; + +interface DiffPanelState { + hasDiffStatus: boolean; + diffStatus: ChatDiffStatusResponse | undefined; + showDiffPanel: boolean; + onToggleFilesChanged: () => void; +} + +interface WorkspaceActions { + canOpenEditors: boolean; + canOpenWorkspace: boolean; + onOpenInEditor: (editor: "cursor" | "vscode") => void; + onViewWorkspace: () => void; +} + +type AgentDetailTopBarPortalsProps = { + topBarTitleRef?: RefObject; + topBarActionsRef?: RefObject; + rightPanelRef?: RefObject; + chatTitle?: string; + parentChat?: TypesGen.Chat; + onOpenParentChat: (chatId: string) => void; + diff: DiffPanelState; + workspace: WorkspaceActions; + onArchiveAgent: () => void; + shouldShowDiffPanel: boolean; + agentId: string; +}; + +export const AgentDetailTopBarPortals: FC = ({ + topBarTitleRef, + topBarActionsRef, + rightPanelRef, + chatTitle, + parentChat, + onOpenParentChat, + diff, + workspace, + onArchiveAgent, + shouldShowDiffPanel, + agentId, +}) => { + return ( + <> + {chatTitle && + topBarTitleRef?.current && + createPortal( +
+ {parentChat && ( + <> + + + + )} + + {chatTitle} + +
, + topBarTitleRef.current, + )} + {diff.hasDiffStatus && + diff.diffStatus && + topBarActionsRef?.current && + createPortal( + , + topBarActionsRef.current, + )} + {topBarActionsRef?.current && + createPortal( + + + + + + { + workspace.onOpenInEditor("cursor"); + }} + > + + Open in Cursor + + { + workspace.onOpenInEditor("vscode"); + }} + > + + Open in VS Code + + + + View Workspace + + + + Archive Agent + + + , + topBarActionsRef.current, + )} + {shouldShowDiffPanel && + rightPanelRef?.current && + createPortal( + , + rightPanelRef.current, + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts b/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts new file mode 100644 index 0000000000..d18d381c36 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + appendTextBlock, + asNonEmptyString, + mergeThinkingTitles, +} from "./blockUtils"; +import type { RenderBlock } from "./types"; + +// --------------------------------------------------------------------------- +// asNonEmptyString +// --------------------------------------------------------------------------- + +describe("asNonEmptyString", () => { + it("returns the string when it is non-empty", () => { + expect(asNonEmptyString("hello")).toBe("hello"); + }); + + it("returns trimmed string when value has whitespace", () => { + expect(asNonEmptyString(" hello ")).toBe("hello"); + }); + + it("returns undefined for an empty string", () => { + expect(asNonEmptyString("")).toBeUndefined(); + }); + + it("returns undefined for a whitespace-only string", () => { + expect(asNonEmptyString(" ")).toBeUndefined(); + }); + + it("returns undefined for non-string values", () => { + expect(asNonEmptyString(undefined)).toBeUndefined(); + expect(asNonEmptyString(null)).toBeUndefined(); + expect(asNonEmptyString(42)).toBeUndefined(); + expect(asNonEmptyString(true)).toBeUndefined(); + expect(asNonEmptyString({})).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// mergeThinkingTitles +// --------------------------------------------------------------------------- + +describe("mergeThinkingTitles", () => { + it("merges when both titles are undefined", () => { + expect(mergeThinkingTitles(undefined, undefined)).toEqual({ + shouldMerge: true, + title: undefined, + }); + }); + + it("merges and picks nextTitle when current is undefined", () => { + expect(mergeThinkingTitles(undefined, "Thinking")).toEqual({ + shouldMerge: true, + title: "Thinking", + }); + }); + + it("merges and keeps currentTitle when next is undefined", () => { + expect(mergeThinkingTitles("Thinking", undefined)).toEqual({ + shouldMerge: true, + title: "Thinking", + }); + }); + + it("merges when titles are identical", () => { + expect(mergeThinkingTitles("Thinking", "Thinking")).toEqual({ + shouldMerge: true, + title: "Thinking", + }); + }); + + it("merges and uses nextTitle when it extends currentTitle", () => { + expect(mergeThinkingTitles("Think", "Thinking deeply")).toEqual({ + shouldMerge: true, + title: "Thinking deeply", + }); + }); + + it("merges and keeps currentTitle when it extends nextTitle", () => { + expect(mergeThinkingTitles("Thinking deeply", "Think")).toEqual({ + shouldMerge: true, + title: "Thinking deeply", + }); + }); + + it("does not merge when titles are completely different", () => { + expect(mergeThinkingTitles("Analyzing", "Planning")).toEqual({ + shouldMerge: false, + title: "Planning", + }); + }); +}); + +// --------------------------------------------------------------------------- +// appendTextBlock +// --------------------------------------------------------------------------- + +describe("appendTextBlock", () => { + it("returns the same blocks when text is empty or whitespace", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "hello" }]; + expect(appendTextBlock(blocks, "response", "")).toBe(blocks); + expect(appendTextBlock(blocks, "response", " ")).toBe(blocks); + expect(appendTextBlock(blocks, "thinking", "\n\t")).toBe(blocks); + }); + + it("appends a new response block to an empty list", () => { + const result = appendTextBlock([], "response", "hello"); + expect(result).toEqual([{ type: "response", text: "hello" }]); + }); + + it("appends a new thinking block to an empty list", () => { + const result = appendTextBlock([], "thinking", "pondering", "Deep thought"); + expect(result).toEqual([ + { type: "thinking", text: "pondering", title: "Deep thought" }, + ]); + }); + + it("merges consecutive response blocks", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "aaa" }]; + const result = appendTextBlock(blocks, "response", "bbb"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "response", text: "aaabbb" }); + }); + + it("merges consecutive thinking blocks with compatible titles", () => { + const blocks: RenderBlock[] = [ + { type: "thinking", text: "part1", title: "Reasoning" }, + ]; + const result = appendTextBlock(blocks, "thinking", "part2", "Reasoning"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "thinking", + text: "part1part2", + title: "Reasoning", + }); + }); + + it("does not merge thinking blocks with incompatible titles", () => { + const blocks: RenderBlock[] = [ + { type: "thinking", text: "part1", title: "Analyzing" }, + ]; + const result = appendTextBlock(blocks, "thinking", "part2", "Planning"); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: "thinking", + text: "part1", + title: "Analyzing", + }); + expect(result[1]).toEqual({ + type: "thinking", + text: "part2", + title: "Planning", + }); + }); + + it("does not merge blocks of different types", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "hello" }]; + const result = appendTextBlock(blocks, "thinking", "hmm"); + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ + type: "thinking", + text: "hmm", + title: undefined, + }); + }); + + it("does not merge when last block is a tool block", () => { + const blocks: RenderBlock[] = [{ type: "tool", id: "tool-1" }]; + const result = appendTextBlock(blocks, "response", "after tool"); + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ type: "response", text: "after tool" }); + }); + + it("uses the custom joinText function when merging", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "line1" }]; + const join = (a: string, b: string) => `${a}\n${b}`; + const result = appendTextBlock( + blocks, + "response", + "line2", + undefined, + join, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "response", text: "line1\nline2" }); + }); + + it("does not mutate the original blocks array", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "original" }]; + const result = appendTextBlock(blocks, "response", " added"); + expect(blocks).toHaveLength(1); + expect((blocks[0] as { text: string }).text).toBe("original"); + expect(result).not.toBe(blocks); + }); + + it("merges thinking block when nextTitle extends currentTitle", () => { + const blocks: RenderBlock[] = [ + { type: "thinking", text: "a", title: "Think" }, + ]; + const result = appendTextBlock(blocks, "thinking", "b", "Thinking deeply"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "thinking", + text: "ab", + title: "Thinking deeply", + }); + }); + + it("merges thinking blocks when both have no title", () => { + const blocks: RenderBlock[] = [{ type: "thinking", text: "a" }]; + const result = appendTextBlock(blocks, "thinking", "b"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "thinking", + text: "ab", + title: undefined, + }); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts b/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts new file mode 100644 index 0000000000..f397dad984 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts @@ -0,0 +1,84 @@ +import { asString } from "components/ai-elements/runtimeTypeUtils"; +import type { RenderBlock } from "./types"; + +const createBlock = ( + type: "response" | "thinking", + text: string, + title?: string, +): RenderBlock => + type === "thinking" ? { type, text, title } : { type, text }; + +export const asNonEmptyString = (value: unknown): string | undefined => { + const next = asString(value).trim(); + return next.length > 0 ? next : undefined; +}; + +export const mergeThinkingTitles = ( + currentTitle: string | undefined, + nextTitle: string | undefined, +): { shouldMerge: boolean; title: string | undefined } => { + if (!currentTitle && !nextTitle) { + return { shouldMerge: true, title: undefined }; + } + if (!currentTitle) { + return { shouldMerge: true, title: nextTitle }; + } + if (!nextTitle) { + return { shouldMerge: true, title: currentTitle }; + } + if (currentTitle === nextTitle) { + return { shouldMerge: true, title: currentTitle }; + } + if (nextTitle.startsWith(currentTitle)) { + return { shouldMerge: true, title: nextTitle }; + } + if (currentTitle.startsWith(nextTitle)) { + return { shouldMerge: true, title: currentTitle }; + } + return { shouldMerge: false, title: nextTitle }; +}; + +/** + * Append a text or thinking block to a render block list, merging + * with the previous block when the types match (and thinking titles + * are compatible). + * + * @param joinText Controls how existing and new text are concatenated + * when merging into an existing block. Callers that process + * complete message blocks typically join with a newline, while + * streaming callers concatenate directly. + */ +export const appendTextBlock = ( + blocks: RenderBlock[], + type: "response" | "thinking", + text: string, + title?: string, + joinText: (current: string, next: string) => string = (a, b) => `${a}${b}`, +): RenderBlock[] => { + if (!text.trim()) { + return blocks; + } + const nextBlocks = [...blocks]; + const last = nextBlocks[nextBlocks.length - 1]; + if (last && last.type === type) { + const shouldMerge = + type === "response" || + (type === "thinking" && + last.type === "thinking" && + mergeThinkingTitles(last.title, title).shouldMerge); + if (shouldMerge) { + const mergedTitle = + type === "thinking" && last.type === "thinking" + ? mergeThinkingTitles(last.title, title).title + : undefined; + nextBlocks[nextBlocks.length - 1] = createBlock( + type, + joinText(last.text, text), + mergedTitle, + ); + return nextBlocks; + } + } + nextBlocks.push(createBlock(type, text, title)); + return nextBlocks; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/chatHelpers.test.ts b/site/src/pages/AgentsPage/AgentDetail/chatHelpers.test.ts new file mode 100644 index 0000000000..feea3842b7 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/chatHelpers.test.ts @@ -0,0 +1,319 @@ +import type * as TypesGen from "api/typesGenerated"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { describe, expect, it } from "vitest"; +import { + extractContextUsageFromMessage, + getLatestContextUsage, + getParentChatID, + getWorkspaceAgent, + resolveModelFromChatConfig, +} from "./chatHelpers"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal ChatMessage factory – only required fields. */ +const makeMessage = ( + overrides: Partial = {}, +): TypesGen.ChatMessage => + ({ + id: 1, + chat_id: "chat-1", + created_at: "2025-01-01T00:00:00Z", + role: "assistant", + ...overrides, + }) as TypesGen.ChatMessage; + +const makeOption = ( + id: string, + provider: string, + model: string, +): ModelSelectorOption => ({ + id, + provider, + model, + displayName: `${provider}/${model}`, +}); + +// --------------------------------------------------------------------------- +// extractContextUsageFromMessage +// --------------------------------------------------------------------------- + +describe("extractContextUsageFromMessage", () => { + it("returns null when the message has no usage fields", () => { + expect(extractContextUsageFromMessage(makeMessage())).toBeNull(); + }); + + it("returns usage when input_tokens is present", () => { + const msg = makeMessage({ usage: { input_tokens: 100 } }); + const result = extractContextUsageFromMessage(msg); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(100); + expect(result!.usedTokens).toBe(100); + }); + + it("returns usage when output_tokens is present", () => { + const msg = makeMessage({ usage: { output_tokens: 50 } }); + const result = extractContextUsageFromMessage(msg); + expect(result).not.toBeNull(); + expect(result!.outputTokens).toBe(50); + expect(result!.usedTokens).toBe(50); + }); + + it("sums all token components into usedTokens", () => { + const msg = makeMessage({ + usage: { + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 5, + cache_creation_tokens: 3, + cache_read_tokens: 2, + }, + }); + const result = extractContextUsageFromMessage(msg); + expect(result).not.toBeNull(); + expect(result!.usedTokens).toBe(10 + 20 + 5 + 3 + 2); + expect(result!.inputTokens).toBe(10); + expect(result!.outputTokens).toBe(20); + expect(result!.reasoningTokens).toBe(5); + expect(result!.cacheCreationTokens).toBe(3); + expect(result!.cacheReadTokens).toBe(2); + }); + + it("includes contextLimitTokens when context_limit is set", () => { + const msg = makeMessage({ usage: { context_limit: 128000 } }); + const result = extractContextUsageFromMessage(msg); + expect(result).not.toBeNull(); + expect(result!.contextLimitTokens).toBe(128000); + }); + + it("returns usage with only contextLimitTokens and no usedTokens", () => { + const msg = makeMessage({ usage: { context_limit: 4096 } }); + const result = extractContextUsageFromMessage(msg); + expect(result).not.toBeNull(); + expect(result!.usedTokens).toBeUndefined(); + expect(result!.contextLimitTokens).toBe(4096); + }); +}); + +// --------------------------------------------------------------------------- +// getLatestContextUsage +// --------------------------------------------------------------------------- + +describe("getLatestContextUsage", () => { + it("returns null for an empty message list", () => { + expect(getLatestContextUsage([])).toBeNull(); + }); + + it("returns null when no messages have usage data", () => { + const messages = [makeMessage(), makeMessage({ id: 2 })]; + expect(getLatestContextUsage(messages)).toBeNull(); + }); + + it("returns usage from the last message with usage data", () => { + const messages = [ + makeMessage({ id: 1, usage: { input_tokens: 100 } }), + makeMessage({ id: 2 }), + makeMessage({ id: 3, usage: { input_tokens: 300 } }), + ]; + const result = getLatestContextUsage(messages); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(300); + }); + + it("skips trailing messages without usage and finds the latest one", () => { + const messages = [ + makeMessage({ id: 1, usage: { input_tokens: 50 } }), + makeMessage({ id: 2, usage: { input_tokens: 200 } }), + makeMessage({ id: 3 }), + ]; + const result = getLatestContextUsage(messages); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// getParentChatID +// --------------------------------------------------------------------------- + +describe("getParentChatID", () => { + it("returns undefined for undefined chat", () => { + expect(getParentChatID(undefined)).toBeUndefined(); + }); + + it("returns undefined when parent_chat_id is not present", () => { + const chat = { id: "c1", title: "test" } as TypesGen.Chat; + expect(getParentChatID(chat)).toBeUndefined(); + }); + + it("returns the parent_chat_id when it is a non-empty string", () => { + const chat = { + id: "c1", + title: "test", + parent_chat_id: "parent-1", + } as TypesGen.Chat; + expect(getParentChatID(chat)).toBe("parent-1"); + }); + + it("returns undefined when parent_chat_id is an empty string", () => { + const chat = { + id: "c1", + title: "test", + parent_chat_id: "", + } as TypesGen.Chat; + expect(getParentChatID(chat)).toBeUndefined(); + }); + + it("returns undefined when parent_chat_id is only whitespace", () => { + const chat = { + id: "c1", + title: "test", + parent_chat_id: " ", + } as TypesGen.Chat; + expect(getParentChatID(chat)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveModelFromChatConfig +// --------------------------------------------------------------------------- + +describe("resolveModelFromChatConfig", () => { + const options: ModelSelectorOption[] = [ + makeOption("openai:gpt-4", "openai", "gpt-4"), + makeOption("anthropic:claude-3", "anthropic", "claude-3"), + ]; + + it("returns empty string when no model options exist", () => { + expect(resolveModelFromChatConfig({ model: "gpt-4" }, [])).toBe(""); + }); + + it("returns first option when modelConfig is undefined", () => { + expect(resolveModelFromChatConfig(undefined, options)).toBe("openai:gpt-4"); + }); + + it("matches by exact model id", () => { + const config = { model: "anthropic:claude-3" }; + expect(resolveModelFromChatConfig(config, options)).toBe( + "anthropic:claude-3", + ); + }); + + it("matches by provider:model combined candidate", () => { + // The model field alone doesn't match an option id, but + // provider + model concatenated does. + const config = { model: "gpt-4", provider: "openai" }; + expect(resolveModelFromChatConfig(config, options)).toBe("openai:gpt-4"); + }); + + it("falls back to model field match on option.model property", () => { + // Neither `model` nor `provider:model` match an option id, + // so the function falls through to matching option.model. + const altOptions: ModelSelectorOption[] = [ + makeOption("custom-id-1", "openai", "gpt-4"), + ]; + const config = { model: "gpt-4", provider: "openai" }; + expect(resolveModelFromChatConfig(config, altOptions)).toBe("custom-id-1"); + }); + + it("falls back to model field match ignoring provider when provider is absent", () => { + const altOptions: ModelSelectorOption[] = [ + makeOption("custom-id-1", "openai", "gpt-4"), + ]; + const config = { model: "gpt-4" }; + expect(resolveModelFromChatConfig(config, altOptions)).toBe("custom-id-1"); + }); + + it("respects provider when matching on option.model", () => { + const altOptions: ModelSelectorOption[] = [ + makeOption("id-a", "azure", "gpt-4"), + makeOption("id-b", "openai", "gpt-4"), + ]; + const config = { model: "gpt-4", provider: "openai" }; + expect(resolveModelFromChatConfig(config, altOptions)).toBe("id-b"); + }); + + it("returns first option when no match is found", () => { + const config = { model: "unknown-model" }; + expect(resolveModelFromChatConfig(config, options)).toBe("openai:gpt-4"); + }); + + it("returns first option when modelConfig is an empty object", () => { + expect(resolveModelFromChatConfig({}, options)).toBe("openai:gpt-4"); + }); +}); + +// --------------------------------------------------------------------------- +// getWorkspaceAgent +// --------------------------------------------------------------------------- + +describe("getWorkspaceAgent", () => { + const makeAgent = (id: string): TypesGen.WorkspaceAgent => + ({ id, name: `agent-${id}` }) as TypesGen.WorkspaceAgent; + + const makeWorkspace = ( + agents: TypesGen.WorkspaceAgent[], + ): TypesGen.Workspace => + ({ + latest_build: { + resources: [{ agents }], + }, + }) as unknown as TypesGen.Workspace; + + it("returns undefined when workspace is undefined", () => { + expect(getWorkspaceAgent(undefined, "agent-1")).toBeUndefined(); + }); + + it("returns undefined when there are no agents", () => { + const ws = makeWorkspace([]); + expect(getWorkspaceAgent(ws, "agent-1")).toBeUndefined(); + }); + + it("returns the matching agent by id", () => { + const ws = makeWorkspace([makeAgent("a1"), makeAgent("a2")]); + expect(getWorkspaceAgent(ws, "a2")).toEqual( + expect.objectContaining({ id: "a2" }), + ); + }); + + it("returns the first agent when workspaceAgentId does not match", () => { + const ws = makeWorkspace([makeAgent("a1"), makeAgent("a2")]); + expect(getWorkspaceAgent(ws, "no-match")).toEqual( + expect.objectContaining({ id: "a1" }), + ); + }); + + it("returns the first agent when workspaceAgentId is undefined", () => { + const ws = makeWorkspace([makeAgent("a1")]); + expect(getWorkspaceAgent(ws, undefined)).toEqual( + expect.objectContaining({ id: "a1" }), + ); + }); + + it("collects agents from multiple resources", () => { + const ws = { + latest_build: { + resources: [ + { agents: [makeAgent("r1-a1")] }, + { agents: [makeAgent("r2-a1")] }, + ], + }, + } as unknown as TypesGen.Workspace; + expect(getWorkspaceAgent(ws, "r2-a1")).toEqual( + expect.objectContaining({ id: "r2-a1" }), + ); + }); + + it("handles resources with no agents array", () => { + const ws = { + latest_build: { + resources: [{ agents: undefined }, { agents: [makeAgent("a1")] }], + }, + } as unknown as TypesGen.Workspace; + expect(getWorkspaceAgent(ws, "a1")).toEqual( + expect.objectContaining({ id: "a1" }), + ); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/chatHelpers.ts b/site/src/pages/AgentsPage/AgentDetail/chatHelpers.ts new file mode 100644 index 0000000000..c563a3330a --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/chatHelpers.ts @@ -0,0 +1,124 @@ +import type * as TypesGen from "api/typesGenerated"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { asString } from "components/ai-elements/runtimeTypeUtils"; +import type { AgentContextUsage } from "../AgentChatInput"; +import { asNonEmptyString } from "./blockUtils"; + +export const extractContextUsageFromMessage = ( + message: TypesGen.ChatMessage, +): AgentContextUsage | null => { + const usage = message.usage; + if (!usage) { + return null; + } + + const inputTokens = usage.input_tokens; + const outputTokens = usage.output_tokens; + const reasoningTokens = usage.reasoning_tokens; + const cacheCreationTokens = usage.cache_creation_tokens; + const cacheReadTokens = usage.cache_read_tokens; + const contextLimitTokens = usage.context_limit; + + const components = [ + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + reasoningTokens, + ].filter((value): value is number => value !== undefined); + const usedTokens = + components.length > 0 + ? components.reduce((total, value) => total + value, 0) + : undefined; + + return { + usedTokens, + contextLimitTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + reasoningTokens, + }; +}; + +export const getLatestContextUsage = ( + messages: readonly TypesGen.ChatMessage[], +): AgentContextUsage | null => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const usage = extractContextUsageFromMessage(messages[index]); + if (usage) { + return usage; + } + } + return null; +}; + +type ChatWithHierarchyMetadata = TypesGen.Chat & { + readonly parent_chat_id?: string; +}; + +export const getParentChatID = ( + chat: TypesGen.Chat | undefined, +): string | undefined => { + return asNonEmptyString( + (chat as ChatWithHierarchyMetadata | undefined)?.parent_chat_id, + ); +}; + +export const resolveModelFromChatConfig = ( + modelConfig: unknown, + modelOptions: readonly ModelSelectorOption[], +): string => { + if (modelOptions.length === 0) { + return ""; + } + + if (!modelConfig || typeof modelConfig !== "object") { + return modelOptions[0]?.id ?? ""; + } + + const typedModelConfig = modelConfig as Record; + const model = asString(typedModelConfig.model); + const provider = asString(typedModelConfig.provider); + + const candidates = [model]; + if (provider && model) { + candidates.push(`${provider}:${model}`); + } + + for (const candidate of candidates) { + const match = modelOptions.find((option) => option.id === candidate); + if (match) { + return match.id; + } + } + + if (model) { + const modelMatch = modelOptions.find( + (option) => + option.model === model && (!provider || option.provider === provider), + ); + if (modelMatch) { + return modelMatch.id; + } + } + + return modelOptions[0]?.id ?? ""; +}; + +export const getWorkspaceAgent = ( + workspace: TypesGen.Workspace | undefined, + workspaceAgentId: string | undefined, +): TypesGen.WorkspaceAgent | undefined => { + if (!workspace) { + return undefined; + } + const agents = workspace.latest_build.resources.flatMap( + (resource) => resource.agents ?? [], + ); + if (agents.length === 0) { + return undefined; + } + return agents.find((agent) => agent.id === workspaceAgentId) ?? agents[0]; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts new file mode 100644 index 0000000000..edb58526b8 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { + mergeTools, + normalizeBlockType, + parseMessageContent, + parseToolResultIsError, +} from "./messageParsing"; + +describe("normalizeBlockType", () => { + it("lowercases and replaces underscores with hyphens", () => { + expect(normalizeBlockType("Tool_Call")).toBe("tool-call"); + expect(normalizeBlockType("TOOL_RESULT")).toBe("tool-result"); + }); + + it("returns empty string for non-string input", () => { + expect(normalizeBlockType(undefined)).toBe(""); + expect(normalizeBlockType(null)).toBe(""); + }); +}); + +describe("parseToolResultIsError", () => { + it("returns the boolean is_error when present", () => { + expect(parseToolResultIsError("tool", { is_error: true }, null)).toBe(true); + expect(parseToolResultIsError("tool", { is_error: false }, null)).toBe( + false, + ); + }); + + it("returns false when no error indicator is present", () => { + expect(parseToolResultIsError("tool", {}, null)).toBe(false); + }); + + it("returns true when error field is present for non-subagent tools", () => { + expect( + parseToolResultIsError("some_tool", { error: "something" }, null), + ).toBe(true); + }); + + it("returns false for completed subagent even with error field", () => { + expect( + parseToolResultIsError( + "spawn_agent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); + expect( + parseToolResultIsError( + "wait_agent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); + expect( + parseToolResultIsError( + "message_agent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); + }); +}); + +describe("parseMessageContent", () => { + it("returns empty result for null content", () => { + const result = parseMessageContent(null); + expect(result.markdown).toBe(""); + expect(result.blocks).toEqual([]); + expect(result.toolCalls).toEqual([]); + expect(result.toolResults).toEqual([]); + }); + + it("returns empty result for undefined content", () => { + const result = parseMessageContent(undefined); + expect(result.markdown).toBe(""); + expect(result.blocks).toEqual([]); + }); + + it("returns empty result for an empty array", () => { + const result = parseMessageContent([]); + expect(result.markdown).toBe(""); + expect(result.blocks).toEqual([]); + expect(result.toolCalls).toEqual([]); + expect(result.toolResults).toEqual([]); + }); + + it("handles a plain string content", () => { + const result = parseMessageContent("Hello world"); + expect(result.markdown).toBe("Hello world"); + expect(result.blocks).toEqual([]); + }); + + it("parses a single text block", () => { + const result = parseMessageContent([{ type: "text", text: "Hello" }]); + expect(result.markdown).toBe("Hello"); + expect(result.blocks).toEqual([{ type: "response", text: "Hello" }]); + }); + + it("merges multiple text blocks into a single response block", () => { + const result = parseMessageContent([ + { type: "text", text: "Line one" }, + { type: "text", text: "Line two" }, + ]); + expect(result.markdown).toBe("Line one\nLine two"); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0]).toEqual({ + type: "response", + text: "Line one\nLine two", + }); + }); + + it("parses a thinking block", () => { + const result = parseMessageContent([ + { type: "thinking", text: "Let me think...", title: "Reasoning" }, + ]); + expect(result.reasoning).toBe("Let me think..."); + expect(result.blocks).toEqual([ + { type: "thinking", text: "Let me think...", title: "Reasoning" }, + ]); + }); + + it("parses a tool_use / tool-call block", () => { + const result = parseMessageContent([ + { + type: "tool-call", + tool_name: "bash", + tool_call_id: "call-1", + args: { command: "ls" }, + }, + ]); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]).toEqual({ + id: "call-1", + name: "bash", + args: { command: "ls" }, + }); + expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]); + }); + + it("parses a tool-result block", () => { + const result = parseMessageContent([ + { + type: "tool-result", + tool_name: "bash", + tool_call_id: "call-1", + result: { output: "file.txt" }, + }, + ]); + expect(result.toolResults).toHaveLength(1); + expect(result.toolResults[0]).toEqual({ + id: "call-1", + name: "bash", + result: { output: "file.txt" }, + isError: false, + }); + expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]); + }); + + it("handles interleaved text and tool blocks in correct order", () => { + const result = parseMessageContent([ + { type: "text", text: "Starting..." }, + { + type: "tool-call", + tool_name: "bash", + tool_call_id: "call-1", + args: {}, + }, + { + type: "tool-result", + tool_name: "bash", + tool_call_id: "call-1", + result: "ok", + }, + { type: "text", text: "Done!" }, + ]); + expect(result.blocks).toHaveLength(3); + expect(result.blocks[0]).toEqual({ + type: "response", + text: "Starting...", + }); + expect(result.blocks[1]).toEqual({ type: "tool", id: "call-1" }); + // The second text block creates a new response block after the + // tool block. + expect(result.blocks[2]).toEqual({ type: "response", text: "Done!" }); + }); + + it("generates fallback IDs when tool_call_id is missing", () => { + const result = parseMessageContent([ + { type: "tool-call", tool_name: "run" }, + ]); + expect(result.toolCalls[0].id).toBe("tool-call-0"); + }); + + it("handles unknown block types gracefully (no crash)", () => { + const result = parseMessageContent([ + { type: "unknown_block_type", text: "some text" }, + ]); + // Unknown types fall through to the default branch which treats + // the text field as a response. + expect(result.markdown).toBe("some text"); + expect(result.blocks).toEqual([{ type: "response", text: "some text" }]); + }); + + it("handles non-object array entries gracefully", () => { + const result = parseMessageContent(["raw string", 42, null]); + expect(result.markdown).toBe("raw string"); + expect(result.blocks).toEqual([{ type: "response", text: "raw string" }]); + }); + + it("handles an object with a type field (treated as single-element array)", () => { + const result = parseMessageContent({ type: "text", text: "single" }); + expect(result.markdown).toBe("single"); + }); + + it("handles an object with text/content fields", () => { + const result = parseMessageContent({ text: "fallback text" }); + expect(result.markdown).toBe("fallback text"); + }); + + it("normalizes underscore block types like tool_call", () => { + const result = parseMessageContent([ + { + type: "tool_call", + tool_name: "test", + tool_call_id: "tc-1", + args: {}, + }, + ]); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].name).toBe("test"); + }); +}); + +describe("mergeTools", () => { + it("merges tool calls with matching results", () => { + const merged = mergeTools( + [{ id: "1", name: "bash", args: { cmd: "ls" } }], + [{ id: "1", name: "bash", result: "ok", isError: false }], + ); + expect(merged).toHaveLength(1); + expect(merged[0]).toEqual({ + id: "1", + name: "bash", + args: { cmd: "ls" }, + result: "ok", + isError: false, + status: "completed", + }); + }); + + it("includes orphaned results that have no matching call", () => { + const merged = mergeTools( + [], + [{ id: "1", name: "bash", result: "output", isError: false }], + ); + expect(merged).toHaveLength(1); + expect(merged[0].id).toBe("1"); + expect(merged[0].status).toBe("completed"); + }); + + it("marks error results with error status", () => { + const merged = mergeTools( + [{ id: "1", name: "bash" }], + [{ id: "1", name: "bash", result: "fail", isError: true }], + ); + expect(merged[0].isError).toBe(true); + expect(merged[0].status).toBe("error"); + }); + + it("returns completed for calls without results", () => { + const merged = mergeTools([{ id: "1", name: "bash" }], []); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe("completed"); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts new file mode 100644 index 0000000000..a4731f414d --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts @@ -0,0 +1,339 @@ +import type * as TypesGen from "api/typesGenerated"; +import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils"; +import { appendTextBlock, asNonEmptyString } from "./blockUtils"; +import type { + MergedTool, + ParsedMessageContent, + ParsedMessageEntry, + ParsedMessageSection, + ParsedToolCall, + ParsedToolResult, + RenderBlock, +} from "./types"; + +const appendText = (current: string, next: string): string => { + const trimmed = next.trim(); + if (!trimmed) { + return current; + } + if (!current) { + return next; + } + return `${current}\n${next}`; +}; + +export const asOptionalTitle = (value: unknown): string | undefined => + asNonEmptyString(value); + +export const normalizeBlockType = (value: unknown): string => + asString(value).toLowerCase().replace(/_/g, "-"); + +const isSubagentToolName = (name: string): boolean => + name === "spawn_agent" || name === "wait_agent" || name === "message_agent"; + +const isCompletedSubagentResult = ( + toolName: string, + result: unknown, +): boolean => { + if (!isSubagentToolName(toolName)) { + return false; + } + const typedResult = asRecord(result); + if (!typedResult) { + return false; + } + const status = asString( + typedResult.status ?? typedResult.subagent_status, + ).toLowerCase(); + return status === "completed" || status === "reported"; +}; + +type ToolResultErrorBlock = { + readonly is_error?: unknown; + readonly error?: unknown; +}; + +export const parseToolResultIsError = ( + toolName: string, + block: ToolResultErrorBlock, + result: unknown, +): boolean => { + if (typeof block.is_error === "boolean") { + return block.is_error; + } + if (!block.error) { + return false; + } + // Some providers include generic error metadata even on successful + // subagent completions. + return !isCompletedSubagentResult(toolName, result); +}; + +const emptyParsedMessageContent = (): ParsedMessageContent => ({ + markdown: "", + reasoning: "", + toolCalls: [], + toolResults: [], + tools: [], + blocks: [], +}); + +/** Wraps appendTextBlock with newline-joining for complete message blocks. */ +const appendParsedTextBlock = ( + blocks: RenderBlock[], + type: "response" | "thinking", + text: string, + title?: string, +): RenderBlock[] => appendTextBlock(blocks, type, text, title, appendText); + +export const ensureToolBlock = ( + blocks: RenderBlock[], + id: string, +): RenderBlock[] => { + if (blocks.some((block) => block.type === "tool" && block.id === id)) { + return blocks; + } + return [...blocks, { type: "tool", id }]; +}; + +export const mergeTools = ( + calls: ParsedToolCall[], + results: ParsedToolResult[], +): MergedTool[] => { + const resultById = new Map(results.map((r) => [r.id, r])); + const seen = new Set(); + const merged: MergedTool[] = []; + + for (const call of calls) { + seen.add(call.id); + const result = resultById.get(call.id); + merged.push({ + id: call.id, + name: call.name, + args: call.args, + result: result?.result, + isError: result?.isError ?? false, + status: result ? (result.isError ? "error" : "completed") : "completed", + }); + } + + for (const result of results) { + if (!seen.has(result.id)) { + merged.push({ + id: result.id, + name: result.name, + result: result.result, + isError: result.isError, + status: result.isError ? "error" : "completed", + }); + } + } + + return merged; +}; + +export const parseMessageContent = (content: unknown): ParsedMessageContent => { + if (typeof content === "string") { + return { + ...emptyParsedMessageContent(), + markdown: content, + }; + } + + if (Array.isArray(content)) { + const parsed = emptyParsedMessageContent(); + for (const [index, block] of content.entries()) { + if (typeof block === "string") { + parsed.markdown = appendText(parsed.markdown, block); + parsed.blocks = appendParsedTextBlock(parsed.blocks, "response", block); + continue; + } + + const typedBlock = asRecord(block); + if (!typedBlock) { + continue; + } + + switch (normalizeBlockType(typedBlock.type)) { + case "text": { + const text = asString(typedBlock.text); + parsed.markdown = appendText(parsed.markdown, text); + parsed.blocks = appendParsedTextBlock( + parsed.blocks, + "response", + text, + ); + break; + } + case "reasoning": + case "thinking": { + const text = asString(typedBlock.text); + const title = asOptionalTitle(typedBlock.title); + parsed.reasoning = appendText(parsed.reasoning, text); + parsed.blocks = appendParsedTextBlock( + parsed.blocks, + "thinking", + text, + title, + ); + break; + } + case "tool-call": + case "toolcall": { + const name = + asString(typedBlock.tool_name) || asString(typedBlock.name); + const id = + asString(typedBlock.tool_call_id) || + asString(typedBlock.id) || + `tool-call-${index}`; + parsed.toolCalls.push({ + id, + name: name || "Tool", + args: typedBlock.args ?? typedBlock.input ?? typedBlock.arguments, + }); + parsed.blocks = ensureToolBlock(parsed.blocks, id); + break; + } + case "tool-result": + case "toolresult": { + const name = + asString(typedBlock.tool_name) || asString(typedBlock.name); + const id = + asString(typedBlock.tool_call_id) || + asString(typedBlock.id) || + `tool-result-${index}`; + const result = + typedBlock.result ?? + typedBlock.output ?? + typedBlock.content ?? + typedBlock.data; + parsed.toolResults.push({ + id, + name: name || "Tool", + result, + isError: parseToolResultIsError(name, typedBlock, result), + }); + parsed.blocks = ensureToolBlock(parsed.blocks, id); + break; + } + default: { + const text = asString(typedBlock.text); + parsed.markdown = appendText(parsed.markdown, text); + parsed.blocks = appendParsedTextBlock( + parsed.blocks, + "response", + text, + ); + break; + } + } + } + return parsed; + } + + if (content === null || content === undefined) { + return emptyParsedMessageContent(); + } + + const typedContent = asRecord(content); + if (!typedContent) { + const markdown = String(content); + return { + ...emptyParsedMessageContent(), + markdown, + blocks: appendParsedTextBlock([], "response", markdown), + }; + } + + if (typedContent.type) { + return parseMessageContent([typedContent]); + } + + const markdown = + asString(typedContent.text) || asString(typedContent.content); + return { + ...emptyParsedMessageContent(), + markdown, + blocks: appendParsedTextBlock([], "response", markdown), + }; +}; + +export const parseMessagesWithMergedTools = ( + messages: readonly TypesGen.ChatMessage[], +): ParsedMessageEntry[] => { + const rawParsed = messages.map((message) => ({ + message, + parsed: parseMessageContent(message.content), + })); + + const globalToolResults = new Map(); + for (const { parsed } of rawParsed) { + for (const result of parsed.toolResults) { + globalToolResults.set(result.id, result); + } + } + + for (const { parsed } of rawParsed) { + const resultById = new Map(); + for (const result of parsed.toolResults) { + resultById.set(result.id, result); + } + for (const call of parsed.toolCalls) { + if (!resultById.has(call.id)) { + const global = globalToolResults.get(call.id); + if (global) { + resultById.set(global.id, global); + } + } + } + parsed.tools = mergeTools( + parsed.toolCalls, + Array.from(resultById.values()), + ); + } + + return rawParsed; +}; + +export const buildSubagentTitles = ( + parsedMessages: readonly ParsedMessageEntry[], +): Map => { + const map = new Map(); + for (const { parsed } of parsedMessages) { + for (const tool of parsed.tools) { + if (tool.name !== "spawn_agent") { + continue; + } + const rec = asRecord(tool.result); + if (!rec) { + continue; + } + const chatId = asString(rec.chat_id); + const title = asString(rec.title); + if (chatId && title) { + map.set(chatId, title); + } + } + } + return map; +}; + +export const buildParsedMessageSections = ( + parsedMessages: readonly ParsedMessageEntry[], +): ParsedMessageSection[] => { + const sections: ParsedMessageSection[] = []; + + for (const entry of parsedMessages) { + if (entry.message.role === "user") { + sections.push({ userEntry: entry, entries: [entry] }); + continue; + } + if (sections.length === 0) { + sections.push({ userEntry: null, entries: [entry] }); + continue; + } + sections[sections.length - 1].entries.push(entry); + } + + return sections; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts new file mode 100644 index 0000000000..e1c6daf2e6 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; +import { + applyMessagePartToStreamState, + applyStreamThinkingTitle, + buildStreamTools, + createEmptyStreamState, +} from "./streamState"; +import type { StreamState } from "./types"; + +describe("createEmptyStreamState", () => { + it("returns fresh state with empty blocks and tool maps", () => { + const state = createEmptyStreamState(); + expect(state.blocks).toEqual([]); + expect(state.toolCalls).toEqual({}); + expect(state.toolResults).toEqual({}); + }); +}); + +describe("applyStreamThinkingTitle", () => { + it("returns blocks unchanged when title is undefined", () => { + const blocks = [{ type: "response" as const, text: "hello" }]; + expect(applyStreamThinkingTitle(blocks, undefined)).toBe(blocks); + }); + + it("creates a new thinking block when last block is not thinking", () => { + const blocks = [{ type: "response" as const, text: "hello" }]; + const result = applyStreamThinkingTitle(blocks, "Plan"); + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ type: "thinking", text: "", title: "Plan" }); + }); + + it("merges title into existing thinking block", () => { + const blocks = [ + { type: "thinking" as const, text: "some thought", title: "Old" }, + ]; + const result = applyStreamThinkingTitle(blocks, "Old and more"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "thinking", + text: "some thought", + title: "Old and more", + }); + }); +}); + +describe("applyMessagePartToStreamState", () => { + it("creates new state with response block from text part on null prev", () => { + const result = applyMessagePartToStreamState(null, { + type: "text", + text: "Hello", + }); + expect(result).not.toBeNull(); + expect(result!.blocks).toEqual([{ type: "response", text: "Hello" }]); + }); + + it("appends text to existing response block", () => { + const prev: StreamState = { + blocks: [{ type: "response", text: "Hello" }], + toolCalls: {}, + toolResults: {}, + }; + const result = applyMessagePartToStreamState(prev, { + type: "text", + text: " world", + }); + expect(result!.blocks).toHaveLength(1); + expect(result!.blocks[0]).toEqual({ + type: "response", + text: "Hello world", + }); + }); + + it("returns prev when text part has empty text", () => { + const prev = createEmptyStreamState(); + const result = applyMessagePartToStreamState(prev, { + type: "text", + text: "", + }); + expect(result).toBe(prev); + }); + + it("creates thinking block from thinking part", () => { + const result = applyMessagePartToStreamState(null, { + type: "thinking", + text: "Let me reason...", + title: "Analysis", + }); + expect(result).not.toBeNull(); + expect(result!.blocks).toEqual([ + { type: "thinking", text: "Let me reason...", title: "Analysis" }, + ]); + }); + + it("handles reasoning type alias the same as thinking", () => { + const result = applyMessagePartToStreamState(null, { + type: "reasoning", + text: "hmm", + }); + expect(result).not.toBeNull(); + expect(result!.blocks[0].type).toBe("thinking"); + }); + + it("returns prev for thinking part with no text and no title", () => { + const prev = createEmptyStreamState(); + const result = applyMessagePartToStreamState(prev, { + type: "thinking", + text: "", + }); + expect(result).toBe(prev); + }); + + it("creates tool call entry from tool-call part", () => { + const result = applyMessagePartToStreamState(null, { + type: "tool-call", + tool_name: "bash", + tool_call_id: "tc-1", + args: { command: "ls" }, + }); + expect(result).not.toBeNull(); + expect(result!.toolCalls["tc-1"]).toEqual({ + id: "tc-1", + name: "bash", + args: { command: "ls" }, + argsRaw: undefined, + }); + expect(result!.blocks).toEqual([{ type: "tool", id: "tc-1" }]); + }); + + it("generates fallback tool call ID when missing", () => { + const result = applyMessagePartToStreamState(null, { + type: "tool-call", + tool_name: "run", + }); + expect(result).not.toBeNull(); + const ids = Object.keys(result!.toolCalls); + expect(ids).toHaveLength(1); + expect(ids[0]).toBe("tool-call-1"); + }); + + it("creates tool result entry from tool-result part", () => { + const result = applyMessagePartToStreamState(null, { + type: "tool-result", + tool_name: "bash", + tool_call_id: "tc-1", + result: { output: "file.txt" }, + }); + expect(result).not.toBeNull(); + expect(result!.toolResults["tc-1"]).toMatchObject({ + id: "tc-1", + name: "bash", + result: { output: "file.txt" }, + isError: false, + }); + }); + + it("handles tool_call underscore type alias", () => { + const result = applyMessagePartToStreamState(null, { + type: "tool_call", + tool_name: "test", + tool_call_id: "t1", + }); + expect(result).not.toBeNull(); + expect(result!.toolCalls.t1).toBeDefined(); + }); + + it("returns prev for unknown part type", () => { + const prev = createEmptyStreamState(); + const result = applyMessagePartToStreamState(prev, { + type: "banana", + }); + expect(result).toBe(prev); + }); + + it("returns null for unknown part type when prev is null", () => { + const result = applyMessagePartToStreamState(null, { + type: "banana", + }); + expect(result).toBeNull(); + }); + + it("accumulates multiple tool calls in sequence", () => { + let state: StreamState | null = null; + state = applyMessagePartToStreamState(state, { + type: "tool-call", + tool_name: "bash", + tool_call_id: "tc-1", + args: { cmd: "ls" }, + }); + state = applyMessagePartToStreamState(state, { + type: "tool-call", + tool_name: "read", + tool_call_id: "tc-2", + args: { path: "/tmp" }, + }); + expect(Object.keys(state!.toolCalls)).toHaveLength(2); + expect(state!.blocks).toHaveLength(2); + }); +}); + +describe("buildStreamTools", () => { + it("returns empty array for null stream state", () => { + expect(buildStreamTools(null)).toEqual([]); + }); + + it("returns running status for calls without results", () => { + const state: StreamState = { + blocks: [{ type: "tool", id: "tc-1" }], + toolCalls: { + "tc-1": { id: "tc-1", name: "bash", args: { cmd: "ls" } }, + }, + toolResults: {}, + }; + const tools = buildStreamTools(state); + expect(tools).toHaveLength(1); + expect(tools[0].status).toBe("running"); + }); + + it("returns completed status when call has a result", () => { + const state: StreamState = { + blocks: [], + toolCalls: { + "tc-1": { id: "tc-1", name: "bash" }, + }, + toolResults: { + "tc-1": { + id: "tc-1", + name: "bash", + result: "ok", + isError: false, + }, + }, + }; + const tools = buildStreamTools(state); + expect(tools[0].status).toBe("completed"); + }); + + it("includes orphan results with no matching call", () => { + const state: StreamState = { + blocks: [], + toolCalls: {}, + toolResults: { + "tc-1": { + id: "tc-1", + name: "bash", + result: "output", + isError: false, + }, + }, + }; + const tools = buildStreamTools(state); + expect(tools).toHaveLength(1); + expect(tools[0].status).toBe("completed"); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.ts new file mode 100644 index 0000000000..9681be3f5b --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.ts @@ -0,0 +1,194 @@ +import { asString } from "components/ai-elements/runtimeTypeUtils"; +import { appendTextBlock, mergeThinkingTitles } from "./blockUtils"; +import { + asOptionalTitle, + ensureToolBlock, + normalizeBlockType, + parseToolResultIsError, +} from "./messageParsing"; +import { mergeStreamPayload } from "./streamingJson"; +import type { MergedTool, RenderBlock, StreamState } from "./types"; + +export const createEmptyStreamState = (): StreamState => ({ + blocks: [], + toolCalls: {}, + toolResults: {}, +}); + +/** Streaming variant β€” uses direct concatenation (the default joinText). */ +const appendStreamTextBlock = appendTextBlock; + +export const applyStreamThinkingTitle = ( + blocks: RenderBlock[], + title?: string, +): RenderBlock[] => { + if (!title) { + return blocks; + } + const nextBlocks = [...blocks]; + const last = nextBlocks[nextBlocks.length - 1]; + if (last && last.type === "thinking") { + const merged = mergeThinkingTitles(last.title, title); + nextBlocks[nextBlocks.length - 1] = { + type: "thinking", + text: last.text, + title: merged.title, + }; + return nextBlocks; + } + nextBlocks.push({ + type: "thinking", + text: "", + title, + }); + return nextBlocks; +}; + +export const applyMessagePartToStreamState = ( + prev: StreamState | null, + part: Record, +): StreamState | null => { + const partType = normalizeBlockType(part.type); + const nextState: StreamState = prev ?? createEmptyStreamState(); + + switch (partType) { + case "text": { + const text = asString(part.text); + if (!text) { + return prev; + } + return { + ...nextState, + blocks: appendStreamTextBlock(nextState.blocks, "response", text), + }; + } + case "reasoning": + case "thinking": { + const text = asString(part.text); + const title = asOptionalTitle(part.title); + if (!text && !title) { + return prev; + } + const nextBlocks = text + ? appendStreamTextBlock(nextState.blocks, "thinking", text, title) + : applyStreamThinkingTitle(nextState.blocks, title); + return { + ...nextState, + blocks: nextBlocks, + }; + } + case "tool-call": + case "toolcall": { + const toolName = asString(part.tool_name); + const existingByName = Object.values(nextState.toolCalls).find( + (call) => call.name === toolName, + ); + const toolCallID = + asString(part.tool_call_id) || + existingByName?.id || + `tool-call-${Object.keys(nextState.toolCalls).length + 1}`; + const existing = nextState.toolCalls[toolCallID]; + const nextArgs = mergeStreamPayload( + existing?.args, + existing?.argsRaw, + part.args, + part.args_delta, + ); + + return { + ...nextState, + blocks: ensureToolBlock(nextState.blocks, toolCallID), + toolCalls: { + ...nextState.toolCalls, + [toolCallID]: { + id: toolCallID, + name: toolName || existing?.name || "Tool", + args: nextArgs.value, + argsRaw: nextArgs.rawText, + }, + }, + }; + } + case "tool-result": + case "toolresult": { + const toolName = asString(part.tool_name); + const existingByName = Object.values(nextState.toolResults).find( + (result) => result.name === toolName, + ); + const existingCallByName = Object.values(nextState.toolCalls).find( + (call) => call.name === toolName, + ); + const toolCallID = + asString(part.tool_call_id) || + existingByName?.id || + existingCallByName?.id || + `tool-result-${Object.keys(nextState.toolResults).length + 1}`; + const existing = nextState.toolResults[toolCallID]; + const nextResult = mergeStreamPayload( + existing?.result, + existing?.resultRaw, + part.result, + part.result_delta, + ); + const nextToolName = toolName || existing?.name || "Tool"; + const nextIsError = + existing?.isError || + parseToolResultIsError(nextToolName, part, nextResult.value); + + return { + ...nextState, + blocks: ensureToolBlock(nextState.blocks, toolCallID), + toolResults: { + ...nextState.toolResults, + [toolCallID]: { + id: toolCallID, + name: nextToolName, + result: nextResult.value, + resultRaw: nextResult.rawText, + isError: nextIsError, + }, + }, + }; + } + default: + return prev; + } +}; + +export const buildStreamTools = ( + streamState: StreamState | null, +): MergedTool[] => { + if (!streamState) { + return []; + } + const calls = Object.values(streamState.toolCalls); + const seen = new Set(); + const merged: MergedTool[] = []; + + for (const call of calls) { + seen.add(call.id); + const result = streamState.toolResults[call.id]; + merged.push({ + id: call.id, + name: call.name, + args: call.args, + result: result?.result, + isError: result?.isError ?? false, + status: result ? (result.isError ? "error" : "completed") : "running", + }); + } + + for (const result of Object.values(streamState.toolResults)) { + if (!seen.has(result.id)) { + merged.push({ + id: result.id, + name: result.name, + result: result.result, + isError: result.isError, + status: result.isError ? "error" : "completed", + }); + } + } + + return merged; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/streamingJson.test.ts b/site/src/pages/AgentsPage/AgentDetail/streamingJson.test.ts new file mode 100644 index 0000000000..2a383779d6 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/streamingJson.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { mergeStreamPayload, parseStreamingJSON } from "./streamingJson"; + +describe("parseStreamingJSON", () => { + it("parses complete JSON objects", () => { + expect(parseStreamingJSON('{"ok":true,"count":2}')).toEqual({ + ok: true, + count: 2, + }); + }); + + it("parses partial objects with an in-progress string value", () => { + expect(parseStreamingJSON('{"command":"git che')).toEqual({ + command: "git che", + }); + }); + + it("returns parsed fields for partial objects with trailing incomplete field", () => { + expect(parseStreamingJSON('{"a":1,"b":')).toEqual({ a: 1 }); + }); + + it("returns null when content is not JSON-like", () => { + expect(parseStreamingJSON("hello world")).toBeNull(); + }); +}); + +describe("mergeStreamPayload", () => { + it("prefers explicit non-string values", () => { + expect( + mergeStreamPayload(undefined, undefined, { done: true }, undefined), + ).toEqual({ + value: { done: true }, + }); + }); + + it("parses explicit string values and preserves raw text", () => { + expect( + mergeStreamPayload(undefined, undefined, '{"output":"ok"}', undefined), + ).toEqual({ + value: { output: "ok" }, + rawText: '{"output":"ok"}', + }); + }); + + it("merges incoming deltas into parsed partial JSON", () => { + const first = mergeStreamPayload( + undefined, + undefined, + undefined, + '{"command":"git ', + ); + expect(first).toEqual({ + value: { command: "git" }, + rawText: '{"command":"git ', + }); + + const second = mergeStreamPayload( + first.value, + first.rawText, + undefined, + 'status"}', + ); + expect(second).toEqual({ + value: { command: "git status" }, + rawText: '{"command":"git status"}', + }); + }); + + it("keeps structured existing values when only deltas arrive", () => { + expect( + mergeStreamPayload({ a: 1 }, undefined, undefined, "ignored"), + ).toEqual({ + value: { a: 1 }, + }); + }); + + it("returns existing payload when delta is empty", () => { + expect(mergeStreamPayload("abc", "abc", undefined, undefined)).toEqual({ + value: "abc", + rawText: "abc", + }); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/streamingJson.ts b/site/src/pages/AgentsPage/AgentDetail/streamingJson.ts new file mode 100644 index 0000000000..62f329d0fc --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/streamingJson.ts @@ -0,0 +1,434 @@ +/** + * Incremental JSON parser for streaming tool call arguments. + * + * LLM tool calls arrive as partial JSON fragments via server-sent events. + * This module provides utilities to extract usable data from incomplete + * JSON strings without waiting for the full payload. + * + * Guarantees: + * - Partial object recovery (returns fields parsed so far). + * - Graceful handling of truncated strings, numbers, and booleans. + * + * Known limitations: + * - Does not handle partial arrays. + * - Does not handle \uXXXX unicode escape sequences in strings. + */ + +const tryParseJSONObject = (value: string): unknown | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const first = trimmed[0]; + if (first !== "{" && first !== "[") { + return null; + } + try { + return JSON.parse(trimmed); + } catch { + return null; + } +}; + +const parsePartialJSONString = ( + input: string, + startIndex: number, +): { value: string; nextIndex: number } | "incomplete" | null => { + if (input[startIndex] !== '"') { + return null; + } + let escaped = false; + for (let i = startIndex + 1; i < input.length; i += 1) { + const char = input[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char !== '"') { + continue; + } + const token = input.slice(startIndex, i + 1); + try { + return { + value: JSON.parse(token) as string, + nextIndex: i + 1, + }; + } catch { + return null; + } + } + return "incomplete"; +}; + +const isJSONValueBoundary = (char: string | undefined): boolean => + char === undefined || + char === "," || + char === "}" || + char === "]" || + /\s/.test(char); + +const findBalancedJSONEnd = ( + input: string, + startIndex: number, +): number | "incomplete" | null => { + const stack: string[] = []; + let escaped = false; + let inString = false; + + for (let index = startIndex; index < input.length; index += 1) { + const char = input[index]; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + switch (char) { + case '"': + inString = true; + break; + case "{": + case "[": + stack.push(char); + break; + case "}": { + const top = stack.pop(); + if (top !== "{") { + return null; + } + break; + } + case "]": { + const top = stack.pop(); + if (top !== "[") { + return null; + } + break; + } + default: + break; + } + + if (stack.length === 0) { + return index + 1; + } + } + + return "incomplete"; +}; + +type PartialJSONValue = + | { status: "ok"; value: unknown; nextIndex: number } + | { status: "incomplete" } + | { status: "invalid" }; + +const parsePartialJSONValue = ( + input: string, + startIndex: number, +): PartialJSONValue => { + let index = startIndex; + while (index < input.length && /\s/.test(input[index])) { + index += 1; + } + if (index >= input.length) { + return { status: "incomplete" }; + } + + const char = input[index]; + if (char === '"') { + const parsed = parsePartialJSONString(input, index); + if (parsed === "incomplete") { + return { status: "incomplete" }; + } + if (!parsed) { + return { status: "invalid" }; + } + return { + status: "ok", + value: parsed.value, + nextIndex: parsed.nextIndex, + }; + } + + if (char === "{" || char === "[") { + const end = findBalancedJSONEnd(input, index); + if (end === "incomplete") { + return { status: "incomplete" }; + } + if (end === null) { + return { status: "invalid" }; + } + const parsed = tryParseJSONObject(input.slice(index, end)); + if (parsed === null) { + return { status: "invalid" }; + } + return { + status: "ok", + value: parsed, + nextIndex: end, + }; + } + + if (input.startsWith("true", index)) { + const next = index + 4; + if (!isJSONValueBoundary(input[next])) { + return { status: "invalid" }; + } + return { status: "ok", value: true, nextIndex: next }; + } + if ("true".startsWith(input.slice(index))) { + return { status: "incomplete" }; + } + + if (input.startsWith("false", index)) { + const next = index + 5; + if (!isJSONValueBoundary(input[next])) { + return { status: "invalid" }; + } + return { status: "ok", value: false, nextIndex: next }; + } + if ("false".startsWith(input.slice(index))) { + return { status: "incomplete" }; + } + + if (input.startsWith("null", index)) { + const next = index + 4; + if (!isJSONValueBoundary(input[next])) { + return { status: "invalid" }; + } + return { status: "ok", value: null, nextIndex: next }; + } + if ("null".startsWith(input.slice(index))) { + return { status: "incomplete" }; + } + + if (char === "-" || (char >= "0" && char <= "9")) { + let end = index; + while (end < input.length && /[0-9eE+.-]/.test(input[end])) { + end += 1; + } + const token = input.slice(index, end); + if (!token) { + return { status: "invalid" }; + } + if ( + end === input.length && + /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?)?$/.test(token) + ) { + return { status: "incomplete" }; + } + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) { + return { status: "invalid" }; + } + if (!isJSONValueBoundary(input[end])) { + return { status: "invalid" }; + } + return { status: "ok", value: Number(token), nextIndex: end }; + } + + return { status: "invalid" }; +}; + +const extractIncompleteStringContent = ( + input: string, + startIndex: number, +): string | null => { + if (input[startIndex] !== '"') { + return null; + } + let result = ""; + let escaped = false; + for (let i = startIndex + 1; i < input.length; i += 1) { + const char = input[i]; + if (escaped) { + switch (char) { + case '"': + result += '"'; + break; + case "\\": + result += "\\"; + break; + case "/": + result += "/"; + break; + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += "\t"; + break; + default: + result += `\\${char}`; + break; + } + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + return result; + } + result += char; + } + return result.length > 0 ? result : null; +}; + +const parsePartialJSONObject = ( + value: string, +): Record | null => { + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + + let index = 1; + const parsed: Record = {}; + let hasFields = false; + + while (index < trimmed.length) { + while (index < trimmed.length && /\s/.test(trimmed[index])) { + index += 1; + } + if (index >= trimmed.length) { + break; + } + + if (trimmed[index] === "}") { + return hasFields ? parsed : null; + } + + if (trimmed[index] === ",") { + index += 1; + continue; + } + + const key = parsePartialJSONString(trimmed, index); + if (key === "incomplete") { + break; + } + if (!key) { + return hasFields ? parsed : null; + } + index = key.nextIndex; + + while (index < trimmed.length && /\s/.test(trimmed[index])) { + index += 1; + } + if (index >= trimmed.length || trimmed[index] !== ":") { + break; + } + index += 1; + + const nextValue = parsePartialJSONValue(trimmed, index); + if (nextValue.status === "incomplete") { + const partialStr = extractIncompleteStringContent(trimmed, index); + if (partialStr !== null) { + parsed[key.value] = partialStr; + hasFields = true; + } + break; + } + if (nextValue.status === "invalid") { + return hasFields ? parsed : null; + } + + parsed[key.value] = nextValue.value; + hasFields = true; + index = nextValue.nextIndex; + + while (index < trimmed.length && /\s/.test(trimmed[index])) { + index += 1; + } + if (index >= trimmed.length) { + break; + } + if (trimmed[index] === ",") { + index += 1; + continue; + } + if (trimmed[index] === "}") { + return parsed; + } + return hasFields ? parsed : null; + } + + return hasFields ? parsed : null; +}; + +export const parseStreamingJSON = (value: string): unknown | null => { + const complete = tryParseJSONObject(value); + if (complete !== null) { + return complete; + } + return parsePartialJSONObject(value); +}; + +type StreamPayloadMerge = { + value: unknown; + rawText?: string; +}; + +export const mergeStreamPayload = ( + existingValue: unknown, + existingRawText: string | undefined, + value: unknown, + delta: unknown, +): StreamPayloadMerge => { + if (value !== undefined) { + if (typeof value !== "string") { + return { value }; + } + const parsed = parseStreamingJSON(value); + if (parsed !== null) { + return { value: parsed, rawText: value }; + } + return { value, rawText: value }; + } + + const chunk = typeof delta === "string" ? delta : ""; + if (!chunk) { + return { + value: existingValue, + rawText: existingRawText, + }; + } + + if ( + existingValue !== undefined && + typeof existingValue !== "string" && + existingRawText === undefined + ) { + return { + value: existingValue, + }; + } + + const base = + existingRawText ?? (typeof existingValue === "string" ? existingValue : ""); + const rawText = `${base}${chunk}`; + const parsed = parseStreamingJSON(rawText); + + return { + value: parsed ?? rawText, + rawText, + }; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/types.ts b/site/src/pages/AgentsPage/AgentDetail/types.ts new file mode 100644 index 0000000000..efb436e0dc --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/types.ts @@ -0,0 +1,78 @@ +import type * as TypesGen from "api/typesGenerated"; + +export type ParsedToolCall = { + id: string; + name: string; + args?: unknown; +}; + +export type ParsedToolResult = { + id: string; + name: string; + result?: unknown; + isError: boolean; +}; + +export type MergedTool = { + id: string; + name: string; + args?: unknown; + result?: unknown; + isError: boolean; + status: "completed" | "error" | "running"; +}; + +export type RenderBlock = + | { + type: "response"; + text: string; + } + | { + type: "thinking"; + text: string; + title?: string; + } + | { + type: "tool"; + id: string; + }; + +export type ParsedMessageContent = { + markdown: string; + reasoning: string; + toolCalls: ParsedToolCall[]; + toolResults: ParsedToolResult[]; + tools: MergedTool[]; + blocks: RenderBlock[]; +}; + +export type ParsedMessageEntry = { + message: TypesGen.ChatMessage; + parsed: ParsedMessageContent; +}; + +export type ParsedMessageSection = { + userEntry: ParsedMessageEntry | null; + entries: ParsedMessageEntry[]; +}; + +type StreamToolCall = { + id: string; + name: string; + args?: unknown; + argsRaw?: string; +}; + +type StreamToolResult = { + id: string; + name: string; + result?: unknown; + resultRaw?: string; + isError: boolean; +}; + +export type StreamState = { + blocks: RenderBlock[]; + toolCalls: Record; + toolResults: Record; +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/useMessageWindow.ts b/site/src/pages/AgentsPage/AgentDetail/useMessageWindow.ts new file mode 100644 index 0000000000..14d26fa2f0 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/useMessageWindow.ts @@ -0,0 +1,55 @@ +import type * as TypesGen from "api/typesGenerated"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const DEFAULT_PAGE_SIZE = 50; + +type UseMessageWindowOptions = { + messages: readonly TypesGen.ChatMessage[]; + resetKey?: string; + pageSize?: number; +}; + +export const useMessageWindow = ({ + messages, + resetKey, + pageSize = DEFAULT_PAGE_SIZE, +}: UseMessageWindowOptions) => { + const [renderedMessageCount, setRenderedMessageCount] = useState(pageSize); + const loadMoreSentinelRef = useRef(null); + + useEffect(() => { + void resetKey; + setRenderedMessageCount(pageSize); + }, [resetKey, pageSize]); + + const hasMoreMessages = renderedMessageCount < messages.length; + const windowedMessages = useMemo(() => { + if (renderedMessageCount >= messages.length) { + return messages; + } + return messages.slice(messages.length - renderedMessageCount); + }, [messages, renderedMessageCount]); + + useEffect(() => { + const node = loadMoreSentinelRef.current; + if (!node || !hasMoreMessages) { + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setRenderedMessageCount((prev) => prev + pageSize); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [hasMoreMessages, pageSize]); + + return { + hasMoreMessages, + windowedMessages, + loadMoreSentinelRef, + }; +}; diff --git a/site/src/pages/AgentsPage/AgentsPage.stories.tsx b/site/src/pages/AgentsPage/AgentsPage.stories.tsx new file mode 100644 index 0000000000..7e9891af8d --- /dev/null +++ b/site/src/pages/AgentsPage/AgentsPage.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { useRef } from "react"; +import { + expect, + fn, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { AgentsEmptyState } from "./AgentsPage"; + +const modelOptions = [ + { + id: "openai:gpt-4o", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + }, +] as const; + +const behaviorStorageKey = "agents.system-prompt"; + +/** + * Wrapper that creates the top-bar actions ref that AgentsEmptyState + * portals its admin button into. + */ +const AgentsEmptyStateWithPortal = ( + props: Omit< + React.ComponentProps, + "topBarActionsRef" + >, +) => { + const topBarActionsRef = useRef(null); + return ( + <> +
+ + + ); +}; + +const meta: Meta = { + title: "pages/AgentsPage/AgentsEmptyState", + component: AgentsEmptyStateWithPortal, + args: { + onCreateChat: fn(), + isCreating: false, + createError: undefined, + modelCatalog: null, + modelOptions: [...modelOptions], + isModelCatalogLoading: false, + modelConfigs: [], + isModelConfigsLoading: false, + modelCatalogError: undefined, + canSetSystemPrompt: true, + canManageChatModelConfigs: false, + }, + beforeEach: () => { + localStorage.clear(); + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: [], + count: 0, + }); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SavesBehaviorPromptAndRestores: Story = { + play: async ({ canvasElement }) => { + const host = canvasElement.ownerDocument.querySelector( + '[data-testid="topbar-actions-host"]', + )!; + + // Open the admin dialog via the portalled button. + await userEvent.click( + await within(host as HTMLElement).findByRole("button", { + name: "Admin", + }), + ); + + const dialog = await screen.findByRole("dialog"); + const textarea = await within(dialog).findByPlaceholderText( + "Optional. Set deployment-wide instructions for all new chats.", + ); + + await userEvent.type(textarea, "You are a focused coding assistant."); + await userEvent.click(within(dialog).getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(localStorage.getItem(behaviorStorageKey)).toBe( + "You are a focused coding assistant.", + ); + }); + }, +}; + +export const UsesSavedBehaviorPromptOnSend: Story = { + play: async ({ canvasElement, args }) => { + const host = canvasElement.ownerDocument.querySelector( + '[data-testid="topbar-actions-host"]', + )!; + + // First, save a behavior prompt. + await userEvent.click( + await within(host as HTMLElement).findByRole("button", { + name: "Admin", + }), + ); + + const dialog = await screen.findByRole("dialog"); + const textarea = await within(dialog).findByPlaceholderText( + "Optional. Set deployment-wide instructions for all new chats.", + ); + + await userEvent.type(textarea, "Use concise and actionable answers."); + await userEvent.click(within(dialog).getByRole("button", { name: "Save" })); + + // Modify without saving, then close. + await userEvent.clear(textarea); + await userEvent.type(textarea, "Unsaved draft prompt"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Close" }), + ); + + // Wait for the dialog to fully close (exit animation) before + // interacting with the page content underneath. + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + // Type a chat message and send. + await userEvent.type( + screen.getByPlaceholderText( + "Ask Coder to build, fix bugs, or explore your project...", + ), + "Create a README checklist", + ); + await userEvent.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Create a README checklist", + }), + ); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx new file mode 100644 index 0000000000..dc96586cc0 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -0,0 +1,785 @@ +import { watchChats } from "api/api"; +import { getErrorMessage } from "api/errors"; +import { + chatKey, + chatModelConfigs, + chatModels, + chats, + chatsKey, + createChat, + deleteChat, +} from "api/queries/chats"; +import { workspaces } from "api/queries/workspaces"; +import type * as TypesGen from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { CoderIcon } from "components/Icons/CoderIcon"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { useAuthenticated } from "hooks"; +import { ArrowLeftIcon, MonitorIcon, PanelLeftIcon } from "lucide-react"; +import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { + type FC, + type FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { NavLink, Outlet, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; +import { cn } from "utils/cn"; +import { pageTitle } from "utils/page"; +import { AgentChatInput } from "./AgentChatInput"; +import { AgentsSidebar } from "./AgentsSidebar"; +import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog"; +import { DiffRightPanel } from "./DiffRightPanel"; +import { + getModelCatalogStatusMessage, + getModelOptionsFromCatalog, + getModelSelectorPlaceholder, + hasConfiguredModelsInCatalog, +} from "./modelOptions"; + +const emptyInputStorageKey = "agents.empty-input"; +const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id"; +const lastModelConfigIDStorageKey = "agents.last-model-config-id"; +const systemPromptStorageKey = "agents.system-prompt"; +const nilUUID = "00000000-0000-0000-0000-000000000000"; + +type ChatModelOption = ModelSelectorOption; + +type CreateChatOptions = { + message: string; + workspaceId?: string; + model?: string; +}; + +// Type guard for SSE events from the chat list watch endpoint. +function isChatListSSEEvent( + data: unknown, +): data is { kind: string; chat: TypesGen.Chat } { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record; + return ( + typeof obj.kind === "string" && + typeof obj.chat === "object" && + obj.chat !== null && + "id" in obj.chat + ); +} + +export interface AgentsOutletContext { + chatErrorReasons: Record; + setChatErrorReason: (chatId: string, reason: string) => void; + clearChatErrorReason: (chatId: string) => void; + topBarTitleRef: React.RefObject; + topBarActionsRef: React.RefObject; + rightPanelRef: React.RefObject; + setRightPanelOpen: (isOpen: boolean) => void; + requestArchiveAgent: (chatId: string) => void; +} + +const AgentsPage: FC = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { agentId } = useParams(); + const { permissions, user, signOut } = useAuthenticated(); + const { appearance, buildInfo } = useDashboard(); + const isAgentsAdmin = + permissions.editDeploymentConfig || + user.roles.some((role) => role.name === "owner" || role.name === "admin"); + const canSetSystemPrompt = isAgentsAdmin; + + // The global CSS sets scrollbar-gutter: stable on to prevent + // layout shift on pages that toggle scrollbars. The agents page uses + // its own internal scroll containers so the reserved gutter space is + // unnecessary and wastes horizontal room. + useEffect(() => { + const html = document.documentElement; + const prev = html.style.scrollbarGutter; + html.style.scrollbarGutter = "auto"; + return () => { + html.style.scrollbarGutter = prev; + }; + }, []); + + const chatsQuery = useQuery(chats()); + const chatModelsQuery = useQuery(chatModels()); + const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const createMutation = useMutation(createChat(queryClient)); + const archiveMutation = useMutation(deleteChat(queryClient)); + const [archivingChatId, setArchivingChatId] = useState(null); + const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [chatErrorReasons, setChatErrorReasons] = useState< + Record + >({}); + const catalogModelOptions = useMemo( + () => + getModelOptionsFromCatalog( + chatModelsQuery.data, + chatModelConfigsQuery.data, + ), + [chatModelsQuery.data, chatModelConfigsQuery.data], + ); + const modelConfigIDByModelID = useMemo(() => { + const byModelID = new Map(); + for (const config of chatModelConfigsQuery.data ?? []) { + const provider = config.provider.trim().toLowerCase(); + const model = config.model.trim(); + if (!provider || !model) { + continue; + } + const colonRef = `${provider}:${model}`; + if (!byModelID.has(colonRef)) { + byModelID.set(colonRef, config.id); + } + const slashRef = `${provider}/${model}`; + if (!byModelID.has(slashRef)) { + byModelID.set(slashRef, config.id); + } + } + return byModelID; + }, [chatModelConfigsQuery.data]); + const setChatErrorReason = useCallback((chatId: string, reason: string) => { + const trimmedReason = reason.trim(); + if (!chatId || !trimmedReason) { + return; + } + setChatErrorReasons((current) => { + if (current[chatId] === trimmedReason) { + return current; + } + return { + ...current, + [chatId]: trimmedReason, + }; + }); + }, []); + const clearChatErrorReason = useCallback((chatId: string) => { + if (!chatId) { + return; + } + setChatErrorReasons((current) => { + if (!(chatId in current)) { + return current; + } + const next = { ...current }; + delete next[chatId]; + return next; + }); + }, []); + const topBarTitleRef = useRef(null); + const topBarActionsRef = useRef(null); + const rightPanelRef = useRef(null); + const chatList = chatsQuery.data ?? []; + const requestArchiveAgent = useCallback( + async (chatId: string) => { + if (archiveMutation.isPending) { + return; + } + + setArchivingChatId(chatId); + const nextChatId = ( + queryClient.getQueryData(chats().queryKey) as + | TypesGen.Chat[] + | undefined + )?.find((chat) => chat.id !== chatId)?.id; + + try { + await archiveMutation.mutateAsync(chatId); + clearChatErrorReason(chatId); + toast.success("Agent archived."); + + if (chatId === agentId) { + navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", { + replace: true, + }); + } + } catch (error) { + toast.error(getErrorMessage(error, "Failed to archive agent.")); + } finally { + setArchivingChatId(null); + } + }, + [archiveMutation, queryClient, agentId, navigate, clearChatErrorReason], + ); + const outletContext: AgentsOutletContext = useMemo( + () => ({ + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + topBarTitleRef, + topBarActionsRef, + rightPanelRef, + setRightPanelOpen: setIsRightPanelOpen, + requestArchiveAgent, + }), + [ + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + requestArchiveAgent, + ], + ); + const handleCreateChat = async (options: CreateChatOptions) => { + const { message, workspaceId, model } = options; + const modelConfigID = + (model && modelConfigIDByModelID.get(model)) || nilUUID; + const createdChat = await createMutation.mutateAsync({ + content: [{ type: "text", text: message }], + workspace_id: workspaceId, + model_config_id: modelConfigID, + }); + + if (typeof window !== "undefined") { + localStorage.removeItem(emptyInputStorageKey); + if (modelConfigID !== nilUUID) { + localStorage.setItem(lastModelConfigIDStorageKey, modelConfigID); + } else { + localStorage.removeItem(lastModelConfigIDStorageKey); + } + } + + navigate(`/agents/${createdChat.id}`); + }; + + const handleNewAgent = () => { + if (typeof window !== "undefined") { + localStorage.setItem(emptyInputStorageKey, ""); + } + navigate("/agents"); + }; + + useEffect(() => { + const ws = watchChats(); + ws.addEventListener("message", (event) => { + const sse = event.parsedMessage; + if (sse?.type !== "data" || !sse.data) { + return; + } + if (!isChatListSSEEvent(sse.data)) { + return; + } + const chatEvent = sse.data; + const updatedChat = chatEvent.chat; + + if (chatEvent.kind === "deleted") { + queryClient.setQueryData( + chatsKey, + (prev: TypesGen.Chat[] | undefined) => + prev?.filter((c) => c.id !== updatedChat.id), + ); + queryClient.removeQueries({ + queryKey: chatKey(updatedChat.id), + exact: true, + }); + return; + } + + queryClient.setQueryData( + chatsKey, + (prev: TypesGen.Chat[] | undefined) => { + if (!prev) return prev; + const exists = prev.some((c) => c.id === updatedChat.id); + if (exists) { + return prev.map((c) => + c.id === updatedChat.id + ? { + ...c, + status: updatedChat.status, + title: updatedChat.title, + updated_at: updatedChat.updated_at, + } + : c, + ); + } + if (chatEvent.kind === "created") { + return [updatedChat, ...prev]; + } + return prev; + }, + ); + queryClient.setQueryData( + chatKey(updatedChat.id), + (previousChat) => { + if (!previousChat) { + return previousChat; + } + return { + ...previousChat, + chat: { + ...previousChat.chat, + status: updatedChat.status, + title: updatedChat.title, + updated_at: updatedChat.updated_at, + }, + }; + }, + ); + }); + return () => ws.close(); + }, [queryClient]); + + useEffect(() => { + document.title = pageTitle("Agents"); + }, []); + + useEffect(() => { + if (!agentId) { + setIsRightPanelOpen(false); + } + }, [agentId]); + + return ( +
+
+ void chatsQuery.refetch()} + onCollapse={() => setIsSidebarCollapsed(true)} + /> +
+ +
+
+
+ {/* Mobile logo: visible when no agent is selected. */} + {!agentId && ( + + {appearance.logo_url ? ( + + ) : ( + + )} + + )} + {/* Mobile back button: visible on mobile when an agent is selected. */} + {agentId && ( + + )} + {/* Desktop expand button: visible when sidebar is manually collapsed. */} + {isSidebarCollapsed && ( + + )} +
+
+
+ link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> +
+
+ {agentId ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; + +interface AgentsEmptyStateProps { + onCreateChat: (options: CreateChatOptions) => Promise; + isCreating: boolean; + createError: unknown; + modelCatalog: TypesGen.ChatModelsResponse | null | undefined; + modelOptions: readonly ChatModelOption[]; + isModelCatalogLoading: boolean; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + isModelConfigsLoading: boolean; + modelCatalogError: unknown; + canSetSystemPrompt: boolean; + canManageChatModelConfigs: boolean; + topBarActionsRef: React.RefObject; +} + +export const AgentsEmptyState: FC = ({ + onCreateChat, + isCreating, + createError, + modelCatalog, + modelOptions, + modelConfigs, + isModelCatalogLoading, + isModelConfigsLoading, + modelCatalogError, + canSetSystemPrompt, + canManageChatModelConfigs, + topBarActionsRef, +}) => { + const initialInput = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + return localStorage.getItem(emptyInputStorageKey) ?? ""; + }, []); + const initialSystemPrompt = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + return localStorage.getItem(systemPromptStorageKey) ?? ""; + }, []); + const initialLastModelConfigID = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + return localStorage.getItem(lastModelConfigIDStorageKey) ?? ""; + }, []); + const modelIDByConfigID = useMemo(() => { + const optionIDByRef = new Map(); + for (const option of modelOptions) { + const provider = option.provider.trim().toLowerCase(); + const model = option.model.trim(); + if (!provider || !model) { + continue; + } + const key = `${provider}:${model}`; + if (!optionIDByRef.has(key)) { + optionIDByRef.set(key, option.id); + } + } + + const byConfigID = new Map(); + for (const config of modelConfigs) { + const provider = config.provider.trim().toLowerCase(); + const model = config.model.trim(); + if (!provider || !model) { + continue; + } + const modelID = optionIDByRef.get(`${provider}:${model}`); + if (!modelID || byConfigID.has(config.id)) { + continue; + } + byConfigID.set(config.id, modelID); + } + return byConfigID; + }, [modelConfigs, modelOptions]); + const lastUsedModelID = useMemo(() => { + if (!initialLastModelConfigID) { + return ""; + } + return modelIDByConfigID.get(initialLastModelConfigID) ?? ""; + }, [initialLastModelConfigID, modelIDByConfigID]); + const defaultModelID = useMemo(() => { + const defaultModelConfig = modelConfigs.find((config) => config.is_default); + if (!defaultModelConfig) { + return ""; + } + return modelIDByConfigID.get(defaultModelConfig.id) ?? ""; + }, [modelConfigs, modelIDByConfigID]); + const preferredModelID = + lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? ""); + const [userSelectedModel, setUserSelectedModel] = useState(""); + const [hasUserSelectedModel, setHasUserSelectedModel] = useState(false); + // Derive the effective model every render so we never reference + // a stale model id and can honor fallback precedence. + const selectedModel = + hasUserSelectedModel && + modelOptions.some((modelOption) => modelOption.id === userSelectedModel) + ? userSelectedModel + : preferredModelID; + const [savedSystemPrompt, setSavedSystemPrompt] = + useState(initialSystemPrompt); + const [systemPromptDraft, setSystemPromptDraft] = + useState(initialSystemPrompt); + const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] = + useState(false); + const workspacesQuery = useQuery(workspaces({ limit: 50 })); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( + () => { + if (typeof window === "undefined") return null; + return localStorage.getItem(selectedWorkspaceIdStorageKey) || null; + }, + ); + const workspaceOptions = workspacesQuery.data?.workspaces ?? []; + const autoCreateWorkspaceValue = "__auto_create_workspace__"; + const hasAdminControls = canSetSystemPrompt || canManageChatModelConfigs; + const hasModelOptions = modelOptions.length > 0; + const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); + const modelSelectorPlaceholder = getModelSelectorPlaceholder( + modelOptions, + isModelCatalogLoading, + hasConfiguredModels, + ); + const modelCatalogStatusMessage = getModelCatalogStatusMessage( + modelCatalog, + modelOptions, + isModelCatalogLoading, + Boolean(modelCatalogError), + ); + const inputStatusText = hasModelOptions + ? null + : hasConfiguredModels + ? "Models are configured but unavailable. Ask an admin." + : "No models configured. Ask an admin."; + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + if (!initialLastModelConfigID) { + return; + } + if (isModelCatalogLoading || isModelConfigsLoading) { + return; + } + if (lastUsedModelID) { + return; + } + localStorage.removeItem(lastModelConfigIDStorageKey); + }, [ + initialLastModelConfigID, + isModelCatalogLoading, + isModelConfigsLoading, + lastUsedModelID, + ]); + + // Keep a mutable ref to selectedWorkspaceId and selectedModel so + // that the onSend callback always sees the latest values without + // the shared input component re-rendering on every change. + const selectedWorkspaceIdRef = useRef(selectedWorkspaceId); + selectedWorkspaceIdRef.current = selectedWorkspaceId; + const selectedModelRef = useRef(selectedModel); + selectedModelRef.current = selectedModel; + const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt; + + const handleWorkspaceChange = (value: string) => { + if (value === autoCreateWorkspaceValue) { + setSelectedWorkspaceId(null); + if (typeof window !== "undefined") { + localStorage.removeItem(selectedWorkspaceIdStorageKey); + } + return; + } + setSelectedWorkspaceId(value); + if (typeof window !== "undefined") { + localStorage.setItem(selectedWorkspaceIdStorageKey, value); + } + }; + + const handleInputChange = useCallback((value: string) => { + if (typeof window !== "undefined") { + localStorage.setItem(emptyInputStorageKey, value); + } + }, []); + const handleModelChange = useCallback((value: string) => { + setHasUserSelectedModel(true); + setUserSelectedModel(value); + }, []); + + const handleSaveSystemPrompt = useCallback( + (event: FormEvent) => { + event.preventDefault(); + if (!isSystemPromptDirty) { + return; + } + + setSavedSystemPrompt(systemPromptDraft); + if (typeof window !== "undefined") { + if (systemPromptDraft) { + localStorage.setItem(systemPromptStorageKey, systemPromptDraft); + } else { + localStorage.removeItem(systemPromptStorageKey); + } + } + }, + [isSystemPromptDirty, systemPromptDraft], + ); + + const handleSend = useCallback( + async (message: string) => { + await onCreateChat({ + message, + workspaceId: selectedWorkspaceIdRef.current ?? undefined, + model: selectedModelRef.current || undefined, + }); + }, + [onCreateChat], + ); + + const selectedWorkspaceName = selectedWorkspaceId + ? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)?.name + : null; + + return ( +
+ {hasAdminControls && + topBarActionsRef.current && + createPortal( + , + topBarActionsRef.current, + )} + +
+ {createError ? : null} + {workspacesQuery.isError && ( + + )} + + + + + + {selectedWorkspaceName ?? "Workspace"} + + + + + Auto-create Workspace + + {workspaceOptions.map((workspace) => ( + + {workspace.name} + + ))} + {workspaceOptions.length === 0 && + !workspacesQuery.isLoading && ( + + No workspaces found + + )} + + + } + /> +
+ + {hasAdminControls && ( + + )} +
+ ); +}; + +export default AgentsPage; diff --git a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx new file mode 100644 index 0000000000..da06f3dad7 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx @@ -0,0 +1,240 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as TypesGen from "api/typesGenerated"; +import type { Chat } from "api/typesGenerated"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { AgentsSidebar } from "./AgentsSidebar"; + +const defaultModelOptions: ModelSelectorOption[] = [ + { + id: "openai:gpt-4o", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + }, +]; + +const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ + { + id: "config-openai-gpt-4o", + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + enabled: true, + is_default: false, + context_limit: 200000, + compression_threshold: 70, + created_at: "2026-02-18T00:00:00.000Z", + updated_at: "2026-02-18T00:00:00.000Z", + }, +]; + +const buildChat = (overrides: Partial = {}): Chat => ({ + id: "chat-default", + owner_id: "owner-1", + title: "Agent", + status: "completed", + last_model_config_id: defaultModelConfigs[0].id, + created_at: "2026-02-18T00:00:00.000Z", + updated_at: "2026-02-18T00:00:00.000Z", + ...overrides, +}); + +const agentsRouting = [ + { path: "/agents/:agentId", useStoryElement: true }, + { path: "/agents", useStoryElement: true }, +] satisfies [ + { path: string; useStoryElement: boolean }, + ...{ path: string; useStoryElement: boolean }[], +]; + +const meta: Meta = { + title: "pages/AgentsPage/AgentsSidebar", + component: AgentsSidebar, + args: { + chatErrorReasons: {}, + modelOptions: defaultModelOptions, + modelConfigs: defaultModelConfigs, + onArchiveAgent: fn(), + onNewAgent: fn(), + isCreating: false, + }, + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const SearchFiltering: Story = { + args: { + chats: [ + buildChat({ id: "parent-1", title: "Parent planner" }), + buildChat({ + id: "child-1", + title: "Child executor", + parent_chat_id: "parent-1", + root_chat_id: "parent-1", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByPlaceholderText("Search agents..."), + "child", + ); + await waitFor(() => { + expect(canvas.getByText("Parent planner")).toBeInTheDocument(); + expect(canvas.getByText("Child executor")).toBeInTheDocument(); + }); + }, +}; + +export const RunningDelegatedChat: Story = { + args: { + chats: [ + buildChat({ id: "root-1", title: "Root agent" }), + buildChat({ + id: "child-running", + title: "Running child", + status: "running", + parent_chat_id: "root-1", + root_chat_id: "root-1", + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/child-running", + pathParams: { agentId: "child-running" }, + }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByTestId("agents-tree-executing-child-running"), + ).toBeInTheDocument(); + }, +}; + +export const PendingDelegatedChat: Story = { + args: { + chats: [ + buildChat({ id: "root-pending", title: "Root agent" }), + buildChat({ + id: "child-pending", + title: "Pending child", + status: "pending", + parent_chat_id: "root-pending", + root_chat_id: "root-pending", + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/child-pending", + pathParams: { agentId: "child-pending" }, + }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByTestId("agents-tree-executing-child-pending"), + ).toBeInTheDocument(); + }, +}; + +export const ExpandCollapse: Story = { + args: { + chats: [ + buildChat({ id: "root-2", title: "Root for collapse" }), + buildChat({ + id: "child-collapse", + title: "Nested child", + parent_chat_id: "root-2", + root_chat_id: "root-2", + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/child-collapse", + pathParams: { agentId: "child-collapse" }, + }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByTestId("agents-tree-toggle-root-2"); + + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Nested child")).toBeInTheDocument(); + + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Nested child")).not.toBeInTheDocument(); + + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Nested child")).toBeInTheDocument(); + }, +}; + +export const ActiveChatAncestryExpanded: Story = { + args: { + chats: [ + buildChat({ id: "root-active", title: "Active root" }), + buildChat({ + id: "child-active", + title: "Active middle", + parent_chat_id: "root-active", + root_chat_id: "root-active", + }), + buildChat({ + id: "grandchild-active", + title: "Active leaf", + parent_chat_id: "child-active", + root_chat_id: "root-active", + }), + buildChat({ id: "other-root", title: "Other root" }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/grandchild-active", + pathParams: { agentId: "grandchild-active" }, + }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Active root")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("Active middle")).toBeInTheDocument(); + expect(canvas.getByText("Active leaf")).toBeInTheDocument(); + }); + await expect( + canvas.getByTestId("agents-tree-toggle-root-active"), + ).toHaveAttribute("aria-expanded", "true"); + await expect( + canvas.getByTestId("agents-tree-toggle-child-active"), + ).toHaveAttribute("aria-expanded", "true"); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx new file mode 100644 index 0000000000..8228568b1e --- /dev/null +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -0,0 +1,696 @@ +import type { + Chat, + ChatDiffStatus, + ChatModelConfig, + ChatStatus, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { Button } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { CoderIcon } from "components/Icons/CoderIcon"; +import { Input } from "components/Input/Input"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Skeleton } from "components/Skeleton/Skeleton"; +import { + AlertTriangleIcon, + ArchiveIcon, + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + Loader2Icon, + PanelLeftCloseIcon, + PauseIcon, + SearchIcon, +} from "lucide-react"; +import { + createContext, + type FC, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { NavLink, useParams } from "react-router"; +import { cn } from "utils/cn"; +import { shortRelativeTime } from "utils/time"; + +interface AgentsSidebarProps { + chats: readonly Chat[]; + chatErrorReasons: Record; + modelOptions: readonly ModelSelectorOption[]; + modelConfigs: readonly ChatModelConfig[]; + logoUrl?: string; + onArchiveAgent: (chatId: string) => void; + onNewAgent: () => void; + isCreating: boolean; + isArchiving?: boolean; + archivingChatId?: string | null; + isLoading?: boolean; + loadError?: unknown; + onRetryLoad?: () => void; + onCollapse?: () => void; +} + +const statusConfig = { + waiting: { icon: CheckIcon, className: "text-content-secondary" }, + pending: { icon: Loader2Icon, className: "text-content-link animate-spin" }, + running: { icon: Loader2Icon, className: "text-content-link animate-spin" }, + paused: { icon: PauseIcon, className: "text-content-warning" }, + error: { icon: AlertTriangleIcon, className: "text-content-destructive" }, + completed: { icon: CheckIcon, className: "text-content-secondary" }, +} as const; + +type ChatTree = { + readonly rootIds: readonly string[]; + readonly childrenById: ReadonlyMap; + readonly parentById: ReadonlyMap; +}; + +const TIME_GROUPS = ["Today", "Yesterday", "This Week", "Older"] as const; +type TimeGroup = (typeof TIME_GROUPS)[number]; + +function getTimeGroup(dateStr: string): TimeGroup { + const now = new Date(); + const date = new Date(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + if (date >= today) return "Today"; + if (date >= yesterday) return "Yesterday"; + if (date >= weekAgo) return "This Week"; + return "Older"; +} + +const getStatusConfig = (status: ChatStatus) => { + return statusConfig[status] ?? statusConfig.completed; +}; + +const asNonEmptyString = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const getModelDisplayName = ( + lastModelConfigID: Chat["last_model_config_id"] | undefined, + modelConfigs: readonly ChatModelConfig[], + modelOptions: readonly ModelSelectorOption[], +) => { + if (!lastModelConfigID) { + return "Default model"; + } + const modelConfig = modelConfigs.find( + (config) => config.id === lastModelConfigID, + ); + if (!modelConfig) { + return "Default model"; + } + const provider = modelConfig.provider.trim().toLowerCase(); + const model = modelConfig.model.trim(); + if (!provider || !model) { + return modelConfig.display_name.trim() || "Default model"; + } + + // Try to find a matching option with a display name. + const match = modelOptions.find( + (opt) => + opt.id === `${provider}:${model}` || + (opt.provider === provider && opt.model === model), + ); + if (match?.displayName) { + return match.displayName; + } + + if (modelConfig.display_name.trim()) { + return modelConfig.display_name.trim(); + } + + return model; +}; + +const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => { + return chat.diff_status; +}; + +const getParentChatID = (chat: Chat): string | undefined => { + return asNonEmptyString(chat.parent_chat_id); +}; + +const getRootChatID = (chat: Chat): string | undefined => { + return asNonEmptyString(chat.root_chat_id); +}; + +const buildChatTree = (chats: readonly Chat[]): ChatTree => { + const orderById = new Map(); + const chatById = new Map(); + const parentById = new Map(); + const childrenById = new Map(); + + for (const [index, chat] of chats.entries()) { + orderById.set(chat.id, index); + chatById.set(chat.id, chat); + childrenById.set(chat.id, []); + } + + for (const chat of chats) { + let parentID = getParentChatID(chat); + if (!parentID || parentID === chat.id || !chatById.has(parentID)) { + parentID = undefined; + } + + if (!parentID) { + const rootID = getRootChatID(chat); + if (rootID && rootID !== chat.id && chatById.has(rootID)) { + parentID = rootID; + } + } + + parentById.set(chat.id, parentID); + if (parentID) { + childrenById.get(parentID)?.push(chat.id); + } + } + + for (const children of childrenById.values()) { + children.sort((leftID, rightID) => { + return (orderById.get(leftID) ?? 0) - (orderById.get(rightID) ?? 0); + }); + } + + const rootIds = chats + .map((chat) => chat.id) + .filter((chatID) => !parentById.get(chatID)); + + return { + rootIds, + childrenById, + parentById, + }; +}; + +const collectVisibleChatIDs = ({ + chats, + search, + tree, +}: { + readonly chats: readonly Chat[]; + readonly search: string; + readonly tree: ChatTree; +}): Set => { + if (!search) { + return new Set(chats.map((chat) => chat.id)); + } + + const matchedChatIDs = chats + .filter((chat) => chat.title.toLowerCase().includes(search)) + .map((chat) => chat.id); + if (matchedChatIDs.length === 0) { + return new Set(); + } + + const visible = new Set(); + for (const matchedChatID of matchedChatIDs) { + let parentCursor: string | undefined = matchedChatID; + const seenParents = new Set(); + while (parentCursor && !seenParents.has(parentCursor)) { + seenParents.add(parentCursor); + visible.add(parentCursor); + parentCursor = tree.parentById.get(parentCursor); + } + + const stack = [matchedChatID]; + const seenDescendants = new Set(); + while (stack.length > 0) { + const currentID = stack.pop(); + if (!currentID || seenDescendants.has(currentID)) { + continue; + } + seenDescendants.add(currentID); + visible.add(currentID); + for (const childID of tree.childrenById.get(currentID) ?? []) { + stack.push(childID); + } + } + } + + return visible; +}; + +interface ChatTreeContextValue { + readonly chatTree: ChatTree; + readonly chatById: ReadonlyMap; + readonly visibleChatIDs: ReadonlySet; + readonly normalizedSearch: string; + readonly expandedById: Record; + readonly modelOptions: readonly ModelSelectorOption[]; + readonly modelConfigs: readonly ChatModelConfig[]; + readonly chatErrorReasons: Record; + readonly isArchiving: boolean; + readonly archivingChatId: string | null; + readonly toggleExpanded: (chatID: string) => void; + readonly onArchiveAgent: (chatId: string) => void; +} + +const ChatTreeContext = createContext(null); + +function useChatTree(): ChatTreeContextValue { + const ctx = useContext(ChatTreeContext); + if (!ctx) { + throw new Error("useChatTree must be used within ChatTreeContext.Provider"); + } + return ctx; +} + +interface ChatTreeNodeProps { + readonly chat: Chat; + readonly isChildNode: boolean; +} + +const ChatTreeNode = memo(({ chat, isChildNode }) => { + const { + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + expandedById, + modelOptions, + modelConfigs, + chatErrorReasons, + isArchiving, + archivingChatId, + toggleExpanded, + onArchiveAgent, + } = useChatTree(); + const chatID = chat.id; + const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) => + visibleChatIDs.has(childID), + ); + const hasChildren = childIDs.length > 0; + const isDelegated = Boolean(getParentChatID(chat)); + const config = getStatusConfig(chat.status); + const StatusIcon = config.icon; + const isDelegatedExecuting = + isDelegated && (chat.status === "pending" || chat.status === "running"); + const modelName = getModelDisplayName( + chat.last_model_config_id, + modelConfigs, + modelOptions, + ); + const errorReason = + chat.status === "error" ? chatErrorReasons[chat.id] : undefined; + const subtitle = errorReason || modelName; + const diffStatus = getChatDiffStatus(chat); + const hasLinkedDiffStatus = Boolean(diffStatus?.url); + const changedFiles = diffStatus?.changed_files ?? 0; + const additions = diffStatus?.additions ?? 0; + const deletions = diffStatus?.deletions ?? 0; + const hasLineStats = additions > 0 || deletions > 0; + const filesChangedLabel = `${changedFiles} ${ + changedFiles === 1 ? "file" : "files" + }`; + const isArchivingThisChat = isArchiving && archivingChatId === chat.id; + const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false); + + return ( +
+
+
+
+ +
+ {hasChildren && ( + + )} +
+ + {({ isActive }) => ( + <> +
+
+ + {chat.title} + +
+
+ {hasLinkedDiffStatus && hasLineStats && ( + + +{additions} + + -{deletions} + + + )} +
+ {subtitle} +
+
+
+ + )} +
+
+ + {shortRelativeTime(chat.updated_at)} + + + + + + + onArchiveAgent(chat.id)} + > + + Archive agent + + + +
+
+ + {hasChildren && isExpanded && ( +
+ {childIDs.map((childID) => { + const childChat = chatById.get(childID); + if (!childChat) return null; + return ( + + ); + })} +
+ )} +
+ ); +}); +ChatTreeNode.displayName = "ChatTreeNode"; + +export const AgentsSidebar: FC = (props) => { + const { + chats, + chatErrorReasons, + modelOptions, + modelConfigs, + logoUrl, + onArchiveAgent, + onNewAgent, + isCreating, + isArchiving = false, + archivingChatId = null, + isLoading = false, + loadError, + onRetryLoad, + onCollapse, + } = props; + const { agentId, chatId } = useParams<{ + agentId?: string; + chatId?: string; + }>(); + const activeChatId = agentId ?? chatId; + const [search, setSearch] = useState(""); + const normalizedSearch = search.trim().toLowerCase(); + const [expandedById, setExpandedById] = useState>({}); + + const chatTree = useMemo(() => buildChatTree(chats), [chats]); + const chatById = useMemo(() => { + return new Map(chats.map((chat) => [chat.id, chat] as const)); + }, [chats]); + const visibleChatIDs = useMemo( + () => + collectVisibleChatIDs({ + chats, + search: normalizedSearch, + tree: chatTree, + }), + [chats, normalizedSearch, chatTree], + ); + const visibleRootIDs = useMemo( + () => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)), + [chatTree.rootIds, visibleChatIDs], + ); + + // Auto-expand ancestors of the active chat so it's always visible. + useEffect(() => { + if (!activeChatId) { + return; + } + const toExpand: string[] = []; + let cursor = chatTree.parentById.get(activeChatId); + const seen = new Set(); + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + toExpand.push(cursor); + cursor = chatTree.parentById.get(cursor); + } + if (toExpand.length > 0) { + setExpandedById((prev) => { + const next = { ...prev }; + for (const id of toExpand) { + next[id] = true; + } + return next; + }); + } + }, [activeChatId, chatTree.parentById]); + + const toggleExpanded = useCallback((chatID: string) => { + setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] })); + }, []); + + const chatTreeCtx = useMemo( + () => ({ + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + expandedById, + modelOptions, + modelConfigs, + chatErrorReasons, + isArchiving, + archivingChatId, + toggleExpanded, + onArchiveAgent, + }), + [ + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + expandedById, + modelOptions, + modelConfigs, + chatErrorReasons, + isArchiving, + archivingChatId, + toggleExpanded, + onArchiveAgent, + ], + ); + + return ( +
+
+
+ + {logoUrl ? ( + + ) : ( + + )} + + {onCollapse && ( + + )} +
+
+
+ + + setSearch(event.target.value)} + className="h-9 rounded-lg border-border-default bg-surface-primary pl-8 text-[13px] shadow-none" + /> +
+ +
+
+ + +
+ {loadError ? ( +
+ + {onRetryLoad && ( + + )} +
+ ) : isLoading ? ( + <> + +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ + ) : ( + + {visibleRootIDs.length === 0 ? ( +
+ {normalizedSearch ? "No matching agents" : "No agents yet"} +
+ ) : ( + TIME_GROUPS.map((group) => { + const groupChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter( + (chat): chat is Chat => + chat !== undefined && + getTimeGroup(chat.updated_at) === group, + ); + if (groupChats.length === 0) return null; + return ( +
+
+ {group} +
+
+ {groupChats.map((chat) => ( + + ))} +
+
+ ); + }) + )} +
+ )} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentsSkeletons.tsx b/site/src/pages/AgentsPage/AgentsSkeletons.tsx new file mode 100644 index 0000000000..421f27d9e1 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentsSkeletons.tsx @@ -0,0 +1,82 @@ +import { Skeleton } from "components/Skeleton/Skeleton"; +import type { FC } from "react"; + +/** + * Skeleton shown while the AgentsPage chunk is loading. Mimics the + * sidebar + empty main area layout so the user sees structure + * immediately instead of a fullscreen spinner. + */ +export const AgentsPageSkeleton: FC = () => ( +
+
+
+
+ +
+ + +
+
+
+ +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+
+
+
+); + +/** + * Skeleton shown while the AgentDetail chunk is loading. Mimics a + * chat conversation layout (user bubble + assistant response lines) + * inside the same scroll/padding wrapper used by the real view. + */ +export const AgentDetailSkeleton: FC = () => ( +
+
+
+
+ {/* User message bubble (right-aligned) */} +
+ +
+ {/* Assistant response lines (left-aligned) */} +
+ + + +
+ {/* Second user message bubble */} +
+ +
+ {/* Second assistant response */} +
+ + + + + +
+
+
+
+
+); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx new file mode 100644 index 0000000000..9c90afc93b --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -0,0 +1,535 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import type * as TypesGen from "api/typesGenerated"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { + ChatModelAdminPanel, + type ChatModelAdminSection, +} from "./ChatModelAdminPanel"; + +// ── Helpers ──────────────────────────────────────────────────── + +const now = "2026-02-18T12:00:00.000Z"; +const nilProviderConfigID = "00000000-0000-0000-0000-000000000000"; + +const createProviderConfig = ( + overrides: Partial & + Pick, +): TypesGen.ChatProviderConfig => ({ + id: overrides.id, + provider: overrides.provider, + display_name: overrides.display_name ?? "", + enabled: overrides.enabled ?? true, + has_api_key: overrides.has_api_key ?? false, + base_url: overrides.base_url ?? "", + source: overrides.source ?? "database", + created_at: overrides.created_at ?? now, + updated_at: overrides.updated_at ?? now, +}); + +const createModelConfig = ( + overrides: Partial & + Pick, +): TypesGen.ChatModelConfig => ({ + id: overrides.id, + provider: overrides.provider, + model: overrides.model, + display_name: overrides.display_name ?? overrides.model, + enabled: overrides.enabled ?? true, + is_default: overrides.is_default ?? false, + context_limit: overrides.context_limit ?? 200000, + compression_threshold: overrides.compression_threshold ?? 70, + created_at: overrides.created_at ?? now, + updated_at: overrides.updated_at ?? now, +}); + +/** + * Set up spies for all chat admin API methods. The mutable `state` + * object lets mutation spies update what queries return on refetch, + * mimicking the real server round-trip. + */ +const setupChatSpies = (state: { + providerConfigs: TypesGen.ChatProviderConfig[]; + modelConfigs: TypesGen.ChatModelConfig[]; + modelCatalog: TypesGen.ChatModelsResponse; +}) => { + spyOn(API, "getChatProviderConfigs").mockImplementation(async () => { + return state.providerConfigs; + }); + spyOn(API, "getChatModelConfigs").mockImplementation(async () => { + return state.modelConfigs; + }); + spyOn(API, "getChatModels").mockImplementation(async () => { + return state.modelCatalog; + }); + + spyOn(API, "createChatProviderConfig").mockImplementation(async (req) => { + const created = createProviderConfig({ + id: `provider-${Date.now()}`, + provider: req.provider, + display_name: req.display_name ?? "", + has_api_key: (req.api_key ?? "").trim().length > 0, + base_url: req.base_url ?? "", + source: "database", + }); + state.providerConfigs = [ + ...state.providerConfigs.filter((p) => p.provider !== req.provider), + created, + ]; + return created; + }); + + spyOn(API, "updateChatProviderConfig").mockImplementation( + async (providerConfigId, req) => { + const idx = state.providerConfigs.findIndex( + (p) => p.id === providerConfigId, + ); + if (idx < 0) { + throw new Error("Provider config not found."); + } + const current = state.providerConfigs[idx]; + const updated: TypesGen.ChatProviderConfig = { + ...current, + display_name: + typeof req.display_name === "string" + ? req.display_name + : current.display_name, + has_api_key: + typeof req.api_key === "string" + ? req.api_key.trim().length > 0 + : current.has_api_key, + base_url: + typeof req.base_url === "string" ? req.base_url : current.base_url, + updated_at: now, + }; + state.providerConfigs = state.providerConfigs.map((p, i) => + i === idx ? updated : p, + ); + return updated; + }, + ); + + spyOn(API, "createChatModelConfig").mockImplementation(async (req) => { + const created = createModelConfig({ + id: `model-${state.modelConfigs.length + 1}`, + provider: req.provider, + model: req.model, + display_name: req.display_name || req.model, + context_limit: + typeof req.context_limit === "number" && + Number.isFinite(req.context_limit) + ? req.context_limit + : 200000, + compression_threshold: + typeof req.compression_threshold === "number" && + Number.isFinite(req.compression_threshold) + ? req.compression_threshold + : 70, + }); + state.modelConfigs = [...state.modelConfigs, created]; + return created; + }); + + spyOn(API, "deleteChatModelConfig").mockImplementation( + async (modelConfigId) => { + state.modelConfigs = state.modelConfigs.filter( + (m) => m.id !== modelConfigId, + ); + }, + ); + + // Unused but mock to avoid errors. + spyOn(API, "deleteChatProviderConfig").mockResolvedValue(undefined); + spyOn(API, "updateChatModelConfig").mockResolvedValue( + createModelConfig({ + id: "stub", + provider: "stub", + model: "stub", + }), + ); +}; + +// ── Meta ─────────────────────────────────────────────────────── + +const meta: Meta = { + title: "pages/AgentsPage/ChatModelAdminPanel", + component: ChatModelAdminPanel, +}; + +export default meta; +type Story = StoryObj; + +// ── Providers section stories ────────────────────────────────── + +export const ProviderAccordionCards: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: nilProviderConfigID, + provider: "openrouter", + display_name: "OpenRouter", + source: "supported", + enabled: false, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await expect(await body.findByText("OpenRouter")).toBeInTheDocument(); + // OpenAI should not be rendered. + expect(body.queryByText("OpenAI")).not.toBeInTheDocument(); + + await userEvent.click(body.getByRole("button", { name: /OpenRouter/i })); + await expect(body.getByLabelText("Base URL")).toBeInTheDocument(); + }, +}; + +export const EnvPresetProviders: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: nilProviderConfigID, + provider: "openai", + display_name: "OpenAI", + has_api_key: true, + source: "env_preset", + enabled: true, + }), + createProviderConfig({ + id: nilProviderConfigID, + provider: "anthropic", + display_name: "Anthropic", + has_api_key: true, + source: "env_preset", + enabled: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + await expect( + await body.findByText("API key managed by environment variable."), + ).toBeVisible(); + expect(body.getByText("Anthropic")).toBeInTheDocument(); + expect( + body.getByText( + "This provider API key is managed by an environment variable.", + ), + ).toBeVisible(); + expect( + body.getByText( + "This provider key is configured from deployment environment settings and cannot be edited in this UI.", + ), + ).toBeVisible(); + expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument(); + expect( + body.queryByRole("button", { + name: "Create provider config", + }), + ).not.toBeInTheDocument(); + }, +}; + +export const CreateAndUpdateProvider: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: nilProviderConfigID, + provider: "openai", + display_name: "OpenAI", + source: "supported", + enabled: false, + has_api_key: false, + }), + ], + modelConfigs: [], + modelCatalog: { + providers: [ + { + provider: "openai", + available: false, + unavailable_reason: "missing_api_key", + models: [], + }, + ], + }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Expand the accordion. + await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + + // Fill in form to create a provider config. + await userEvent.type( + await body.findByLabelText(/API key/i), + "sk-provider-key", + ); + await userEvent.type( + body.getByLabelText("Base URL"), + "https://proxy.example.com/v1", + ); + await userEvent.click( + body.getByRole("button", { name: "Create provider config" }), + ); + + // The create spy should have been called. + await waitFor(() => { + expect(API.createChatProviderConfig).toHaveBeenCalledTimes(1); + }); + expect(API.createChatProviderConfig).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + api_key: "sk-provider-key", + base_url: "https://proxy.example.com/v1", + }), + ); + + // After creation the form should switch to "Save changes". + await waitFor(() => { + expect( + body.getByRole("button", { name: "Save changes" }), + ).toBeInTheDocument(); + }); + + // Update the display name and base URL. + const displayNameInput = body.getByPlaceholderText( + "Friendly provider label", + ); + await userEvent.clear(displayNameInput); + await userEvent.type(displayNameInput, "Primary OpenAI"); + const baseURLInput = body.getByLabelText("Base URL"); + await userEvent.clear(baseURLInput); + await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2"); + await userEvent.type( + body.getByLabelText(/API key/i), + "sk-updated-provider-key", + ); + await userEvent.click(body.getByRole("button", { name: "Save changes" })); + + await waitFor(() => { + expect(API.updateChatProviderConfig).toHaveBeenCalledTimes(1); + }); + expect(API.updateChatProviderConfig).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + display_name: "Primary OpenAI", + api_key: "sk-updated-provider-key", + base_url: "https://internal-proxy.example.com/v2", + }), + ); + }, +}; + +// ── Models section stories ───────────────────────────────────── + +export const ProviderSpecificModelConfigSchema: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + createProviderConfig({ + id: "provider-anthropic", + provider: "anthropic", + display_name: "Anthropic", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await body.findByRole("button", { name: "Add model" }), + ); + + const schemaBlock = await body.findByTestId("chat-model-config-schema"); + expect(schemaBlock).toHaveTextContent('"provider": "openai"'); + expect(schemaBlock).toHaveTextContent('"openai": {'); + expect(schemaBlock).toHaveTextContent('"reasoning_effort": "high"'); + + // Switch provider to Anthropic. + await userEvent.click(body.getByRole("combobox", { name: "Provider" })); + await userEvent.click( + await body.findByRole("option", { name: /Anthropic/i }), + ); + + await waitFor(() => { + expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( + '"provider": "anthropic"', + ); + }); + expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( + '"anthropic": {', + ); + expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( + '"thinking": {', + ); + }, +}; + +export const NoModelConfigByDefault: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await body.findByRole("button", { name: "Add model" }), + ); + await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + + await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue( + "", + ); + + await userEvent.click(body.getByRole("button", { name: "Add model" })); + await waitFor(() => { + expect(API.createChatModelConfig).toHaveBeenCalledTimes(1); + }); + expect(API.createChatModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + model: "gpt-5-pro", + }), + ); + // The request should not include a model_config key. + const callArgs = ( + API.createChatModelConfig as unknown as ReturnType + ).mock.calls[0][0] as Record; + expect(callArgs).not.toHaveProperty("model_config"); + }, +}; + +export const SubmitModelConfigExplicitly: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await body.findByRole("button", { name: "Add model" }), + ); + await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro-custom"); + await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + await userEvent.type( + await body.findByLabelText(/Max output tokens/i), + "32000", + ); + await userEvent.click( + body.getByRole("combobox", { + name: "Reasoning effort", + }), + ); + await userEvent.click(await body.findByRole("option", { name: "high" })); + + await userEvent.click(body.getByRole("button", { name: "Add model" })); + await waitFor(() => { + expect(API.createChatModelConfig).toHaveBeenCalledTimes(1); + }); + expect(API.createChatModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + model: "gpt-5-pro-custom", + model_config: expect.objectContaining({ + max_output_tokens: 32000, + provider_options: { + openai: { + reasoning_effort: "high", + }, + }, + }), + }), + ); + }, +}; + +export const ValidatesModelConfigFields: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await body.findByRole("button", { name: "Add model" }), + ); + await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + const maxOutputTokensInput = + await body.findByLabelText(/Max output tokens/i); + await userEvent.type(maxOutputTokensInput, "not-a-number"); + await waitFor(() => { + expect(body.getByRole("button", { name: "Add model" })).toBeDisabled(); + }); + // No API call should have been made. + expect(API.createChatModelConfig).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx new file mode 100644 index 0000000000..caa63ac884 --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -0,0 +1,376 @@ +import { + chatModelConfigs, + chatModels, + chatProviderConfigs, + createChatModelConfig as createChatModelConfigMutation, + createChatProviderConfig as createChatProviderConfigMutation, + deleteChatModelConfig as deleteChatModelConfigMutation, + updateChatModelConfig as updateChatModelConfigMutation, + updateChatProviderConfig as updateChatProviderConfigMutation, +} from "api/queries/chats"; +import type * as TypesGen from "api/typesGenerated"; +import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader2Icon } from "lucide-react"; +import { type FC, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { cn } from "utils/cn"; +import { formatProviderLabel } from "../modelOptions"; +import { normalizeProvider, readOptionalString } from "./helpers"; +import { ModelsSection } from "./ModelsSection"; +import { ProvidersSection } from "./ProvidersSection"; + +// ── Exported types ───────────────────────────────────────────── + +export type ProviderState = { + provider: string; + label: string; + providerConfig: TypesGen.ChatProviderConfig | undefined; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + catalogModelCount: number; + hasManagedAPIKey: boolean; + hasCatalogAPIKey: boolean; + hasEffectiveAPIKey: boolean; + isEnvPreset: boolean; + baseURL: string; +}; + +export type ChatModelAdminSection = "providers" | "models"; + +// ── Internal helpers ─────────────────────────────────────────── + +type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number]; + +const nilUUID = "00000000-0000-0000-0000-000000000000"; +const envPresetProviders = new Set(["openai", "anthropic"]); + +const hasProviderAPIKey = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, +): boolean => { + if (!providerConfig) return false; + return providerConfig.has_api_key; +}; + +const getProviderConfigSource = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, +): TypesGen.ChatProviderConfigSource | undefined => { + return providerConfig?.source; +}; + +const isDatabaseProviderConfig = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, + source: TypesGen.ChatProviderConfigSource | undefined, +): providerConfig is TypesGen.ChatProviderConfig => { + if (!providerConfig) return false; + if (providerConfig.id === nilUUID) return false; + return source === undefined || source === "database"; +}; + +const getCatalogProviders = ( + catalog: TypesGen.ChatModelsResponse | null | undefined, +): readonly CatalogProvider[] => { + const providers = catalog?.providers; + return Array.isArray(providers) ? providers : []; +}; + +const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean => + provider.available || + (Boolean(provider.unavailable_reason) && + provider.unavailable_reason !== "missing_api_key"); + +const getProviderModels = ( + provider: CatalogProvider | undefined, +): readonly CatalogProvider["models"][number][] => { + const models = provider?.models; + return Array.isArray(models) ? models : []; +}; + +const getProviderBaseURL = ( + providerConfig: TypesGen.ChatProviderConfig | undefined, +): string => { + return readOptionalString(providerConfig?.base_url) ?? ""; +}; + +// ── Hook: compute provider states from query data ────────────── + +const useProviderStates = ( + modelConfigs: readonly TypesGen.ChatModelConfig[], + providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined, + catalogData: TypesGen.ChatModelsResponse | null | undefined, +): readonly ProviderState[] => + useMemo(() => { + const orderedProviders: string[] = []; + const seenProviders = new Set(); + const includeProvider = (providerValue: string) => { + const normalized = normalizeProvider(providerValue); + if (!normalized || seenProviders.has(normalized)) return; + seenProviders.add(normalized); + orderedProviders.push(normalized); + }; + + const catalogProviders = getCatalogProviders(catalogData); + const catalogProvidersByProvider = new Map(); + for (const cp of catalogProviders) { + const normalized = normalizeProvider(cp.provider); + if (!normalized) continue; + includeProvider(normalized); + catalogProvidersByProvider.set(normalized, cp); + } + + for (const pc of providerConfigsData ?? []) { + includeProvider(pc.provider); + } + for (const mc of modelConfigs) { + includeProvider(mc.provider); + } + + const providerConfigsByProvider = new Map< + string, + TypesGen.ChatProviderConfig + >(); + for (const pc of providerConfigsData ?? []) { + const normalized = normalizeProvider(pc.provider); + if (!normalized) continue; + providerConfigsByProvider.set(normalized, pc); + } + + const modelConfigsByProvider = new Map< + string, + TypesGen.ChatModelConfig[] + >(); + for (const mc of modelConfigs) { + const normalized = normalizeProvider(mc.provider); + if (!normalized) continue; + const existing = modelConfigsByProvider.get(normalized); + if (existing) { + existing.push(mc); + } else { + modelConfigsByProvider.set(normalized, [mc]); + } + } + + return orderedProviders.map((provider) => { + const providerConfigEntry = providerConfigsByProvider.get(provider); + const providerConfigSource = getProviderConfigSource(providerConfigEntry); + const providerConfig = isDatabaseProviderConfig( + providerConfigEntry, + providerConfigSource, + ) + ? providerConfigEntry + : undefined; + const catalogProvider = catalogProvidersByProvider.get(provider); + const catalogProviderSource = readOptionalString( + (catalogProvider as CatalogProvider & { source?: string })?.source, + ); + const hasManagedAPIKey = hasProviderAPIKey(providerConfig); + const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry); + const hasCatalogAPIKey = catalogProvider + ? providerHasCatalogAPIKey(catalogProvider) + : false; + const label = + readOptionalString(providerConfigEntry?.display_name) ?? + formatProviderLabel(provider); + const modelConfigsForProvider = + modelConfigsByProvider.get(provider) ?? []; + const isCatalogEnvPreset = + !providerConfig && + envPresetProviders.has(provider) && + (catalogProviderSource === "env" || hasCatalogAPIKey); + const isEnvPreset = + providerConfigSource === "env_preset" || isCatalogEnvPreset; + + return { + provider, + label, + providerConfig, + modelConfigs: modelConfigsForProvider, + catalogModelCount: getProviderModels(catalogProvider).length, + hasManagedAPIKey, + hasCatalogAPIKey, + hasEffectiveAPIKey: providerConfigEntry + ? hasProviderEntryAPIKey + : hasManagedAPIKey || hasCatalogAPIKey, + isEnvPreset, + baseURL: getProviderBaseURL(providerConfigEntry), + }; + }); + }, [modelConfigs, catalogData, providerConfigsData]); + +// ── Component ────────────────────────────────────────────────── + +type ChatModelAdminPanelProps = { + className?: string; + section?: ChatModelAdminSection; +}; + +export const ChatModelAdminPanel: FC = ({ + className, + section = "providers", +}) => { + const queryClient = useQueryClient(); + const [requestedProvider, setRequestedProvider] = useState( + null, + ); + + // ── Queries ──────────────────────────────────────────────── + const providerConfigsQuery = useQuery(chatProviderConfigs()); + const modelConfigsQuery = useQuery(chatModelConfigs()); + const modelCatalogQuery = useQuery(chatModels()); + + // ── Mutations ────────────────────────────────────────────── + const createProviderMut = useMutation( + createChatProviderConfigMutation(queryClient), + ); + const updateProviderMut = useMutation( + updateChatProviderConfigMutation(queryClient), + ); + const createModelMut = useMutation( + createChatModelConfigMutation(queryClient), + ); + const updateModelMut = useMutation( + updateChatModelConfigMutation(queryClient), + ); + const deleteModelMut = useMutation( + deleteChatModelConfigMutation(queryClient), + ); + + // ── Sorted model configs ─────────────────────────────────── + const modelConfigs = useMemo( + () => + (modelConfigsQuery.data ?? []).slice().sort((a, b) => { + const cmp = a.provider.localeCompare(b.provider); + return cmp !== 0 ? cmp : a.model.localeCompare(b.model); + }), + [modelConfigsQuery.data], + ); + + // ── Provider states ──────────────────────────────────────── + const providerStates = useProviderStates( + modelConfigs, + providerConfigsQuery.data, + modelCatalogQuery.data, + ); + + // Derive the effective selected provider from user intent + available + // providers. This avoids a useEffect + setState cycle that would cause + // an extra render with a stale value. + const selectedProvider = useMemo(() => { + if ( + requestedProvider && + providerStates.some((ps) => ps.provider === requestedProvider) + ) { + return requestedProvider; + } + return providerStates[0]?.provider ?? null; + }, [requestedProvider, providerStates]); + + const selectedProviderState = useMemo( + () => + selectedProvider + ? (providerStates.find((ps) => ps.provider === selectedProvider) ?? + null) + : null, + [providerStates, selectedProvider], + ); + + // ── Derived state ────────────────────────────────────────── + const isLoading = + providerConfigsQuery.isLoading || + modelConfigsQuery.isLoading || + modelCatalogQuery.isLoading; + const providerConfigsUnavailable = providerConfigsQuery.data === null; + const modelConfigsUnavailable = modelConfigsQuery.data === null; + const isProviderMutationPending = + createProviderMut.isPending || updateProviderMut.isPending; + const providerMutationError = + createProviderMut.error ?? updateProviderMut.error; + const modelMutationError = + createModelMut.error ?? updateModelMut.error ?? deleteModelMut.error; + + return ( +
+ {/* Header */} +
+

+ {section === "providers" + ? "Configure provider credentials and network settings." + : "Manage models available in Agents across all providers."} +

+ {isLoading && ( +
+ + Loading +
+ )} +
+ + {/* Alerts */} + {providerConfigsQuery.isError && ( + + )} + {modelConfigsQuery.isError && ( + + )} + {modelCatalogQuery.isError && ( + + )} + {providerMutationError && } + {modelMutationError && } + + {providerConfigsUnavailable && ( + + + Chat provider admin API is unavailable on this deployment. + + /api/v2/chats/providers is missing. + + )} + + {modelConfigsUnavailable && ( + + + Chat model admin API is unavailable on this deployment. + + /api/v2/chats/model-configs is missing. + + )} + + {/* Content */} + {section === "providers" ? ( + createProviderMut.mutateAsync(req)} + onUpdateProvider={(providerConfigId, req) => + updateProviderMut.mutateAsync({ + providerConfigId, + req, + }) + } + onSelectedProviderChange={setRequestedProvider} + /> + ) : ( + createModelMut.mutateAsync(req)} + onUpdateModel={(modelConfigId, req) => + updateModelMut.mutateAsync({ + modelConfigId, + req, + }) + } + onDeleteModel={(id) => deleteModelMut.mutateAsync(id)} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx new file mode 100644 index 0000000000..49f302a2ae --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx @@ -0,0 +1,536 @@ +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Textarea } from "components/Textarea/Textarea"; +import { type FormikContextType, getIn } from "formik"; +import type { FC } from "react"; +import { cn } from "utils/cn"; +import { normalizeProvider } from "./helpers"; +import type { + ModelConfigFormBuildResult, + ModelFormValues, +} from "./modelConfigFormLogic"; + +export const modelConfigReasoningEffortOptions = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "none", +] as const; + +export const modelConfigAnthropicEffortOptions = [ + "low", + "medium", + "high", + "max", +] as const; + +export const modelConfigTextVerbosityOptions = [ + "low", + "medium", + "high", +] as const; + +/** Sentinel value for Select components to represent "no selection". */ +const unsetSelectValue = "__unset__"; + +// ── Generic field renderers ──────────────────────────────────── + +type FieldRenderContext = { + form: FormikContextType; + fieldErrors: ModelConfigFormBuildResult["fieldErrors"]; + disabled: boolean; +}; + +const InputField: FC< + FieldRenderContext & { + fieldKey: string; + label: string; + placeholder: string; + } +> = ({ form, fieldErrors, disabled, fieldKey, label, placeholder }) => { + const errorId = `${fieldKey}-error`; + const fieldError = fieldErrors[fieldKey]; + const fieldProps = form.getFieldProps(fieldKey); + return ( +
+ + + {fieldError && ( +

+ {fieldError} +

+ )} +
+ ); +}; + +const SelectField: FC< + FieldRenderContext & { + fieldKey: string; + label: string; + options: readonly string[]; + } +> = ({ form, fieldErrors, disabled, fieldKey, label, options }) => { + const errorId = `${fieldKey}-error`; + const fieldError = fieldErrors[fieldKey]; + const currentValue = (getIn(form.values, fieldKey) as string) || ""; + return ( +
+ + + {fieldError && ( +

+ {fieldError} +

+ )} +
+ ); +}; + +const JSONField: FC< + FieldRenderContext & { + fieldKey: string; + label: string; + placeholder: string; + } +> = ({ form, fieldErrors, disabled, fieldKey, label, placeholder }) => { + const errorId = `${fieldKey}-error`; + const fieldError = fieldErrors[fieldKey]; + const fieldProps = form.getFieldProps(fieldKey); + return ( +
+ +