Files
coder/cli/exp_agents.go
T
Cian Johnston d5a5be116d fix: fall back to name lookup for UUID-shaped workspace names (#24340)
`namedWorkspace` in `cli/root.go` parsed workspace identifiers with
`uuid.Parse` first and returned immediately on success, even when no
workspace had that UUID as its actual ID. This caused 404 errors for any
workspace whose name was a valid 32-char hex string (dashless UUID).

- Add `codersdk.ResolveWorkspace`: tries UUID lookup first, falls back
to name lookup on 404. `NameValid` guard skips the fallback for standard
dashed UUIDs (36 chars > 32-char name limit).
- Export `codersdk.SplitWorkspaceIdentifier`, replacing the duplicate
`splitNamedWorkspace` in `cli/root.go` (uses `strings.Cut`).
- Delete `namedWorkspace` from `cli/root.go`; all 28 call sites now use
`client.ResolveWorkspace` directly.
- Delete `namedWorkspace` and `splitNameAndOwner` from
`codersdk/toolsdk/bash.go`; inline `client.ResolveWorkspace`.
- Simplify `GetWorkspace` tool handler to a single `ResolveWorkspace`
call.
- Unit tests via httptest mock cover UUID, name, owner/name, UUID-like
fallback, not-found, server error, transport error, and invalid
identifier paths.
- Integration tests in `cli/show_test.go` and `codersdk/toolsdk` for
workspaces with UUID-like names.

> Generated with Coder Agents
2026-04-27 12:58:26 +01:00

208 lines
4.9 KiB
Go

package cli
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/muesli/termenv"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func installTUISignalHandler(p *tea.Program) func() {
ch := make(chan struct{})
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
defer func() {
signal.Stop(sig)
close(ch)
}()
for {
select {
case <-ch:
return
case <-sig:
p.Send(terminateTUIMsg{})
}
}
}()
return func() {
ch <- struct{}{}
}
}
func fitHelpText(width int, candidates ...string) string {
if len(candidates) == 0 {
return ""
}
if width <= 0 {
return candidates[0]
}
for _, candidate := range candidates {
if lipgloss.Width(candidate) <= width {
return candidate
}
}
return truncateText(candidates[len(candidates)-1], width, " •|│:", 1)
}
func truncateText(text string, width int, trimRightCutset string, ellipsisWidth int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(text) <= width {
return text
}
if width <= ellipsisWidth {
return "…"
}
for runes := []rune(text); len(runes) > 0; runes = runes[:len(runes)-1] {
truncated := strings.TrimRight(string(runes), trimRightCutset) + "…"
if lipgloss.Width(truncated) <= width {
return truncated
}
}
return "…"
}
func (r *RootCmd) agentsCommand() *serpent.Command {
var (
workspaceFlag string
modelFlag string
)
return &serpent.Command{
Use: "agents [chat-id]",
Short: "Interactive terminal UI for AI agents.",
Aliases: []string{"agent"},
Options: serpent.OptionSet{
{
Name: "workspace",
Flag: "workspace",
Description: "Associate the chat with a workspace by name, owner/name, or UUID.",
Value: serpent.StringOf(&workspaceFlag),
},
{
Name: "model",
Flag: "model",
Description: "Choose a model by ID, provider/model, or display name.",
Value: serpent.StringOf(&modelFlag),
},
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("list organizations: %w", err)
}
if len(orgs) == 0 {
return xerrors.New("no organizations found")
}
defaultOrgID := orgs[0].ID
expClient := codersdk.NewExperimentalClient(client)
if len(inv.Args) > 1 {
return xerrors.New("expected zero or one chat ID")
}
var initialChatID *uuid.UUID
if len(inv.Args) == 1 {
chatID, err := uuid.Parse(inv.Args[0])
if err != nil {
return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err)
}
initialChatID = &chatID
}
var workspaceID *uuid.UUID
if workspaceFlag != "" {
workspace, err := client.ResolveWorkspace(inv.Context(), workspaceFlag)
if err != nil {
return xerrors.Errorf("resolve workspace %q: %w", workspaceFlag, err)
}
workspaceID = &workspace.ID
}
modelID, err := resolveModel(inv.Context(), expClient, modelFlag)
if err != nil {
return err
}
// Set an explicit color profile before Bubble Tea acquires the
// terminal so lipgloss/termenv don't send OSC color queries that
// can leak back into stdin as literal input in some terminals.
renderer := lipgloss.NewRenderer(
inv.Stdout,
termenv.WithProfile(termenv.TrueColor),
)
renderer.SetHasDarkBackground(true)
model := newExpChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID)
model.setRenderer(renderer)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithoutSignalHandler(),
tea.WithContext(inv.Context()),
tea.WithInput(inv.Stdin),
tea.WithOutput(inv.Stdout),
)
closeSignalHandler := installTUISignalHandler(program)
defer closeSignalHandler()
runModel, err := program.Run()
if err != nil {
return err
}
if _, ok := runModel.(expChatsTUIModel); !ok {
return xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", runModel, runModel))
}
return nil
},
}
}
//nolint:nilnil // A nil string indicates that no model override was provided.
func resolveModel(ctx context.Context, client *codersdk.ExperimentalClient, modelFlag string) (*string, error) {
if modelFlag == "" {
return nil, nil
}
if _, err := uuid.Parse(modelFlag); err == nil {
return &modelFlag, nil
}
catalog, err := client.ListChatModels(ctx)
if err != nil {
return nil, xerrors.Errorf("listing models: %w", err)
}
for _, provider := range catalog.Providers {
for _, model := range provider.Models {
if model.ID == modelFlag || model.Provider+"/"+model.Model == modelFlag || model.DisplayName == modelFlag {
return &model.ID, nil
}
}
}
return nil, xerrors.Errorf("unknown model %q", modelFlag)
}