diff --git a/cmd/attach/attach.go b/cmd/attach/attach.go index 8bbb409..54a69ad 100644 --- a/cmd/attach/attach.go +++ b/cmd/attach/attach.go @@ -13,7 +13,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/coder/agentapi/lib/httpapi" + "github.com/coder/agentapi/lib/types" "github.com/spf13/cobra" sse "github.com/tmaxmax/go-sse" "golang.org/x/term" @@ -35,7 +35,7 @@ func (c *ChannelWriter) Receive() ([]byte, bool) { } type model struct { - screen string + conversation string } func (m model) Init() tea.Cmd { @@ -43,8 +43,8 @@ func (m model) Init() tea.Cmd { return nil } -type screenMsg struct { - screen string +type conversationMsg struct { + conversation string } type finishMsg struct{} @@ -52,10 +52,10 @@ type finishMsg struct{} //lint:ignore U1000 The Update function is used by the Bubble Tea framework func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case screenMsg: - m.screen = msg.screen - if m.screen != "" && m.screen[len(m.screen)-1] != '\n' { - m.screen += "\n" + case conversationMsg: + m.conversation = msg.conversation + if m.conversation != "" && m.conversation[len(m.conversation)-1] != '\n' { + m.conversation += "\n" } case tea.KeyMsg: if msg.String() == "ctrl+c" { @@ -69,10 +69,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - return m.screen + return m.conversation } -func ReadScreenOverHTTP(ctx context.Context, url string, ch chan<- httpapi.ScreenUpdateBody) error { +func ReadScreenOverHTTP(ctx context.Context, url string, ch chan<- types.ScreenUpdateBody) error { + fmt.Println(url) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req.Header.Set("Content-Type", "application/json") @@ -85,16 +86,16 @@ func ReadScreenOverHTTP(ctx context.Context, url string, ch chan<- httpapi.Scree }() for ev, err := range sse.Read(res.Body, &sse.ReadConfig{ - // 256KB: screen can be big. The default terminal size is 80x1000, + // 256KB: conversation can be big. The default terminal size is 80x1000, // which can be over 80000 bytes. MaxEventSize: 256 * 1024, }) { if err != nil { return xerrors.Errorf("failed to read sse: %w", err) } - var screen httpapi.ScreenUpdateBody + var screen types.ScreenUpdateBody if err := json.Unmarshal([]byte(ev.Data), &screen); err != nil { - return xerrors.Errorf("failed to unmarshal screen: %w", err) + return xerrors.Errorf("failed to unmarshal conversation: %w", err) } ch <- screen } @@ -102,8 +103,8 @@ func ReadScreenOverHTTP(ctx context.Context, url string, ch chan<- httpapi.Scree } func WriteRawInputOverHTTP(ctx context.Context, url string, msg string) error { - messageRequest := httpapi.MessageRequestBody{ - Type: httpapi.MessageTypeRaw, + messageRequest := types.MessageRequestBody{ + Type: types.MessageTypeRaw, Content: msg, } messageRequestBytes, err := json.Marshal(messageRequest) @@ -145,7 +146,7 @@ func runAttach(remoteUrl string) error { } tee := io.TeeReader(os.Stdin, stdinWriter) p := tea.NewProgram(model{}, tea.WithInput(tee), tea.WithAltScreen()) - screenCh := make(chan httpapi.ScreenUpdateBody, 64) + screenCh := make(chan types.ScreenUpdateBody, 64) readScreenErrCh := make(chan error, 1) go func() { @@ -154,7 +155,7 @@ func runAttach(remoteUrl string) error { if errors.Is(err, context.Canceled) { return } - readScreenErrCh <- xerrors.Errorf("failed to read screen: %w", err) + readScreenErrCh <- xerrors.Errorf("failed to read conversation: %w", err) } }() writeRawInputErrCh := make(chan error, 1) @@ -189,8 +190,8 @@ func runAttach(remoteUrl string) error { if !ok { return } - p.Send(screenMsg{ - screen: screenUpdate.Screen, + p.Send(conversationMsg{ + conversation: screenUpdate.Screen, }) } } @@ -227,6 +228,7 @@ var AttachCmd = &cobra.Command{ Long: `Attach to a running agent`, Run: func(cmd *cobra.Command, args []string) { remoteUrl := remoteUrlArg + fmt.Println(remoteUrl) if remoteUrl == "" { fmt.Fprintln(os.Stderr, "URL is required") os.Exit(1) diff --git a/cmd/server/server.go b/cmd/server/server.go index 56b07ea..8e06c4a 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -10,14 +10,15 @@ import ( "sort" "strings" + "github.com/coder/agentapi/lib/cli/msgfmt" + "github.com/coder/agentapi/lib/cli/termexec" + "github.com/coder/agentapi/lib/types" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/xerrors" "github.com/coder/agentapi/lib/httpapi" "github.com/coder/agentapi/lib/logctx" - "github.com/coder/agentapi/lib/msgfmt" - "github.com/coder/agentapi/lib/termexec" ) type AgentType = msgfmt.AgentType @@ -70,14 +71,25 @@ func parseAgentType(firstArg string, agentTypeVar string) (AgentType, error) { return AgentTypeCustom, nil } +func parseInteractionType(interactionModeVar string) (types.InteractionType, error) { + return types.InteractionTypeCLI, nil +} + func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) error { agent := argsToPass[0] agentTypeValue := viper.GetString(FlagType) + interactionTypeValue := viper.GetString(FlagInteractionType) + agentType, err := parseAgentType(agent, agentTypeValue) if err != nil { return xerrors.Errorf("failed to parse agent type: %w", err) } + interactionType, err := parseInteractionType(interactionTypeValue) + if err != nil { + return xerrors.Errorf("failed to parse interaction type: %w", err) + } + termWidth := viper.GetUint16(FlagTermWidth) termHeight := viper.GetUint16(FlagTermHeight) @@ -106,12 +118,13 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er } port := viper.GetInt(FlagPort) srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: agentType, - Process: process, - Port: port, - ChatBasePath: viper.GetString(FlagChatBasePath), - AllowedHosts: viper.GetStringSlice(FlagAllowedHosts), - AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins), + AgentType: agentType, + Process: process, + Port: port, + InteractionType: interactionType, + ChatBasePath: viper.GetString(FlagChatBasePath), + AllowedHosts: viper.GetStringSlice(FlagAllowedHosts), + AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins), }) if err != nil { return xerrors.Errorf("failed to create server: %w", err) @@ -120,7 +133,6 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er fmt.Println(srv.GetOpenAPI()) return nil } - srv.StartSnapshotLoop(ctx) logger.Info("Starting server on port", "port", port) processExitCh := make(chan error, 1) go func() { @@ -165,15 +177,16 @@ type flagSpec struct { } const ( - FlagType = "type" - FlagPort = "port" - FlagPrintOpenAPI = "print-openapi" - FlagChatBasePath = "chat-base-path" - FlagTermWidth = "term-width" - FlagTermHeight = "term-height" - FlagAllowedHosts = "allowed-hosts" - FlagAllowedOrigins = "allowed-origins" - FlagExit = "exit" + FlagType = "type" + FlagPort = "port" + FlagPrintOpenAPI = "print-openapi" + FlagChatBasePath = "chat-base-path" + FlagTermWidth = "term-width" + FlagTermHeight = "term-height" + FlagAllowedHosts = "allowed-hosts" + FlagAllowedOrigins = "allowed-origins" + FlagExit = "exit" + FlagInteractionType = "interaction" ) func CreateServerCmd() *cobra.Command { diff --git a/lib/httpapi/claude.go b/lib/cli/claude.go similarity index 90% rename from lib/httpapi/claude.go rename to lib/cli/claude.go index 641efe4..58ab001 100644 --- a/lib/httpapi/claude.go +++ b/lib/cli/claude.go @@ -1,8 +1,8 @@ -package httpapi +package cli import ( - mf "github.com/coder/agentapi/lib/msgfmt" - st "github.com/coder/agentapi/lib/screentracker" + mf "github.com/coder/agentapi/lib/cli/msgfmt" + st "github.com/coder/agentapi/lib/cli/screentracker" ) func formatPaste(message string) []st.MessagePart { diff --git a/lib/cli/cli_handler.go b/lib/cli/cli_handler.go new file mode 100644 index 0000000..f59e10c --- /dev/null +++ b/lib/cli/cli_handler.go @@ -0,0 +1,204 @@ +package cli + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + mf "github.com/coder/agentapi/lib/cli/msgfmt" + st "github.com/coder/agentapi/lib/cli/screentracker" + "github.com/coder/agentapi/lib/cli/termexec" + "github.com/coder/agentapi/lib/types" + "github.com/danielgtaylor/huma/v2/sse" + "golang.org/x/xerrors" +) + +type CLIHandler struct { + emitter *EventEmitter + conversation *st.Conversation + agentio *termexec.Process + mu sync.RWMutex + agentType mf.AgentType + logger *slog.Logger +} + +const snapshotInterval = 25 * time.Millisecond + +func convertStatus(status st.ConversationStatus) types.AgentStatus { + switch status { + case st.ConversationStatusInitializing: + return types.AgentStatusRunning + case st.ConversationStatusStable: + return types.AgentStatusStable + case st.ConversationStatusChanging: + return types.AgentStatusRunning + default: + panic(fmt.Sprintf("unknown conversation status: %s", status)) + } +} + +func NewCLIHandler(ctx context.Context, logger *slog.Logger, agentio *termexec.Process, agentType mf.AgentType) *CLIHandler { + formatMessage := func(message string, userInput string) string { + return mf.FormatAgentMessage(agentType, message, userInput) + } + + conversation := st.NewConversation(ctx, st.ConversationConfig{ + AgentIO: agentio, + GetTime: func() time.Time { + return time.Now() + }, + SnapshotInterval: 25 * time.Millisecond, + ScreenStabilityLength: 2 * time.Second, + FormatMessage: formatMessage, + }) + + emitter := NewEventEmitter(1024) + + handler := &CLIHandler{ + emitter: emitter, + conversation: conversation, + agentio: agentio, + agentType: agentType, + logger: logger, + } + + return handler +} + +// GetStatus handles GET /status +func (c *CLIHandler) GetStatus(ctx context.Context, input *struct{}) (*types.StatusResponse, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + status := c.conversation.Status() + agentStatus := convertStatus(status) + + resp := &types.StatusResponse{} + resp.Body.Status = agentStatus + + return resp, nil +} + +// GetMessages handles GET /messages +func (c *CLIHandler) GetMessages(ctx context.Context, input *struct{}) (*types.MessagesResponse, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + resp := &types.MessagesResponse{} + resp.Body.Messages = make([]types.Message, len(c.conversation.Messages())) + for i, msg := range c.conversation.Messages() { + resp.Body.Messages[i] = types.Message{ + Id: msg.Id, + Role: msg.Role, + Content: msg.Message, + Time: msg.Time, + } + } + + return resp, nil +} + +// CreateMessage handles POST /message +func (c *CLIHandler) CreateMessage(ctx context.Context, input *types.MessageRequest) (*types.MessageResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + + switch input.Body.Type { + case types.MessageTypeUser: + if err := c.conversation.SendMessage(FormatMessage(c.agentType, input.Body.Content)...); err != nil { + return nil, xerrors.Errorf("failed to send message: %w", err) + } + case types.MessageTypeRaw: + if _, err := c.agentio.Write([]byte(input.Body.Content)); err != nil { + return nil, xerrors.Errorf("failed to send message: %w", err) + } + } + + resp := &types.MessageResponse{} + resp.Body.Ok = true + + return resp, nil +} + +// SubscribeEvents is an SSE endpoint that sends events to the client +func (c *CLIHandler) SubscribeEvents(ctx context.Context, input *struct{}, send sse.Sender) { + subscriberId, ch, stateEvents := c.emitter.Subscribe() + defer c.emitter.Unsubscribe(subscriberId) + c.logger.Info("New subscriber", "subscriberId", subscriberId) + for _, event := range stateEvents { + if event.Type == EventTypeScreenUpdate { + continue + } + if err := send.Data(event.Payload); err != nil { + c.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) + return + } + } + for { + select { + case event, ok := <-ch: + if !ok { + c.logger.Info("Channel closed", "subscriberId", subscriberId) + return + } + if event.Type == EventTypeScreenUpdate { + continue + } + if err := send.Data(event.Payload); err != nil { + c.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) + return + } + case <-ctx.Done(): + c.logger.Info("Context done", "subscriberId", subscriberId) + return + } + } +} + +func (c *CLIHandler) SubscribeConversations(ctx context.Context, input *struct{}, send sse.Sender) { + subscriberId, ch, stateEvents := c.emitter.Subscribe() + defer c.emitter.Unsubscribe(subscriberId) + c.logger.Info("New screen subscriber", "subscriberId", subscriberId) + for _, event := range stateEvents { + if event.Type != EventTypeScreenUpdate { + continue + } + if err := send.Data(event.Payload); err != nil { + c.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) + return + } + } + for { + select { + case event, ok := <-ch: + if !ok { + c.logger.Info("Screen channel closed", "subscriberId", subscriberId) + return + } + if event.Type != EventTypeScreenUpdate { + continue + } + if err := send.Data(event.Payload); err != nil { + c.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) + return + } + case <-ctx.Done(): + c.logger.Info("Screen context done", "subscriberId", subscriberId) + return + } + } +} + +func (c *CLIHandler) StartSnapshotLoop(ctx context.Context) { + c.conversation.StartSnapshotLoop(ctx) + go func() { + for { + c.emitter.UpdateStatusAndEmitChanges(c.conversation.Status()) + c.emitter.UpdateMessagesAndEmitChanges(c.conversation.Messages()) + c.emitter.UpdateScreenAndEmitChanges(c.conversation.Screen()) + time.Sleep(snapshotInterval) + } + }() +} diff --git a/lib/httpapi/events.go b/lib/cli/events.go similarity index 61% rename from lib/httpapi/events.go rename to lib/cli/events.go index 1e6281d..2c2f51d 100644 --- a/lib/httpapi/events.go +++ b/lib/cli/events.go @@ -1,15 +1,12 @@ -package httpapi +package cli import ( - "fmt" "strings" "sync" - "time" - mf "github.com/coder/agentapi/lib/msgfmt" - st "github.com/coder/agentapi/lib/screentracker" - "github.com/coder/agentapi/lib/util" - "github.com/danielgtaylor/huma/v2" + mf "github.com/coder/agentapi/lib/cli/msgfmt" + st "github.com/coder/agentapi/lib/cli/screentracker" + "github.com/coder/agentapi/lib/types" ) type EventType string @@ -20,37 +17,6 @@ const ( EventTypeScreenUpdate EventType = "screen_update" ) -type AgentStatus string - -const ( - AgentStatusRunning AgentStatus = "running" - AgentStatusStable AgentStatus = "stable" -) - -var AgentStatusValues = []AgentStatus{ - AgentStatusStable, - AgentStatusRunning, -} - -func (a AgentStatus) Schema(r huma.Registry) *huma.Schema { - return util.OpenAPISchema(r, "AgentStatus", AgentStatusValues) -} - -type MessageUpdateBody struct { - Id int `json:"id" doc:"Unique identifier for the message. This identifier also represents the order of the message in the conversation history."` - Role st.ConversationRole `json:"role" doc:"Role of the message author"` - Message string `json:"message" doc:"Message content. The message is formatted as it appears in the agent's terminal session, meaning that, by default, it consists of lines of text with 80 characters per line."` - Time time.Time `json:"time" doc:"Timestamp of the message"` -} - -type StatusChangeBody struct { - Status AgentStatus `json:"status" doc:"Agent status"` -} - -type ScreenUpdateBody struct { - Screen string `json:"screen"` -} - type Event struct { Type EventType Payload any @@ -58,27 +24,14 @@ type Event struct { type EventEmitter struct { mu sync.Mutex - messages []st.ConversationMessage - status AgentStatus + messages []types.ConversationMessage + status types.AgentStatus chans map[int]chan Event chanIdx int subscriptionBufSize int screen string } -func convertStatus(status st.ConversationStatus) AgentStatus { - switch status { - case st.ConversationStatusInitializing: - return AgentStatusRunning - case st.ConversationStatusStable: - return AgentStatusStable - case st.ConversationStatusChanging: - return AgentStatusRunning - default: - panic(fmt.Sprintf("unknown conversation status: %s", status)) - } -} - // subscriptionBufSize is the size of the buffer for each subscription. // Once the buffer is full, the channel will be closed. // Listeners must actively drain the channel, so it's important to @@ -87,8 +40,8 @@ func convertStatus(status st.ConversationStatus) AgentStatus { func NewEventEmitter(subscriptionBufSize int) *EventEmitter { return &EventEmitter{ mu: sync.Mutex{}, - messages: make([]st.ConversationMessage, 0), - status: AgentStatusRunning, + messages: make([]types.ConversationMessage, 0), + status: types.AgentStatusRunning, chans: make(map[int]chan Event), chanIdx: 0, subscriptionBufSize: subscriptionBufSize, @@ -120,14 +73,14 @@ func (e *EventEmitter) notifyChannels(eventType EventType, payload any) { // Assumes that only the last message can change or new messages can be added. // If a new message is injected between existing messages (identified by Id), the behavior is undefined. -func (e *EventEmitter) UpdateMessagesAndEmitChanges(newMessages []st.ConversationMessage) { +func (e *EventEmitter) UpdateMessagesAndEmitChanges(newMessages []types.ConversationMessage) { e.mu.Lock() defer e.mu.Unlock() maxLength := max(len(e.messages), len(newMessages)) for i := range maxLength { - var oldMsg st.ConversationMessage - var newMsg st.ConversationMessage + var oldMsg types.ConversationMessage + var newMsg types.ConversationMessage if i < len(e.messages) { oldMsg = e.messages[i] } @@ -135,7 +88,7 @@ func (e *EventEmitter) UpdateMessagesAndEmitChanges(newMessages []st.Conversatio newMsg = newMessages[i] } if oldMsg != newMsg { - e.notifyChannels(EventTypeMessageUpdate, MessageUpdateBody{ + e.notifyChannels(EventTypeMessageUpdate, types.MessageUpdateBody{ Id: newMessages[i].Id, Role: newMessages[i].Role, Message: newMessages[i].Message, @@ -156,7 +109,7 @@ func (e *EventEmitter) UpdateStatusAndEmitChanges(newStatus st.ConversationStatu return } - e.notifyChannels(EventTypeStatusChange, StatusChangeBody{Status: newAgentStatus}) + e.notifyChannels(EventTypeStatusChange, types.StatusChangeBody{Status: newAgentStatus}) e.status = newAgentStatus } @@ -168,7 +121,7 @@ func (e *EventEmitter) UpdateScreenAndEmitChanges(newScreen string) { return } - e.notifyChannels(EventTypeScreenUpdate, ScreenUpdateBody{Screen: strings.TrimRight(newScreen, mf.WhiteSpaceChars)}) + e.notifyChannels(EventTypeScreenUpdate, types.ScreenUpdateBody{Screen: strings.TrimRight(newScreen, mf.WhiteSpaceChars)}) e.screen = newScreen } @@ -178,16 +131,16 @@ func (e *EventEmitter) currentStateAsEvents() []Event { for _, msg := range e.messages { events = append(events, Event{ Type: EventTypeMessageUpdate, - Payload: MessageUpdateBody{Id: msg.Id, Role: msg.Role, Message: msg.Message, Time: msg.Time}, + Payload: types.MessageUpdateBody{Id: msg.Id, Role: msg.Role, Message: msg.Message, Time: msg.Time}, }) } events = append(events, Event{ Type: EventTypeStatusChange, - Payload: StatusChangeBody{Status: e.status}, + Payload: types.StatusChangeBody{Status: e.status}, }) events = append(events, Event{ Type: EventTypeScreenUpdate, - Payload: ScreenUpdateBody{Screen: strings.TrimRight(e.screen, mf.WhiteSpaceChars)}, + Payload: types.ScreenUpdateBody{Screen: strings.TrimRight(e.screen, mf.WhiteSpaceChars)}, }) return events } diff --git a/lib/httpapi/events_test.go b/lib/cli/events_test.go similarity index 66% rename from lib/httpapi/events_test.go rename to lib/cli/events_test.go index 23a1d36..abe46e8 100644 --- a/lib/httpapi/events_test.go +++ b/lib/cli/events_test.go @@ -1,11 +1,12 @@ -package httpapi +package cli import ( "fmt" "testing" "time" - st "github.com/coder/agentapi/lib/screentracker" + st "github.com/coder/agentapi/lib/cli/screentracker" + "github.com/coder/agentapi/lib/types" "github.com/stretchr/testify/assert" ) @@ -17,45 +18,45 @@ func TestEventEmitter(t *testing.T) { assert.Equal(t, []Event{ { Type: EventTypeStatusChange, - Payload: StatusChangeBody{Status: AgentStatusRunning}, + Payload: types.StatusChangeBody{Status: types.AgentStatusRunning}, }, { Type: EventTypeScreenUpdate, - Payload: ScreenUpdateBody{Screen: ""}, + Payload: types.ScreenUpdateBody{Screen: ""}, }, }, stateEvents) now := time.Now() - emitter.UpdateMessagesAndEmitChanges([]st.ConversationMessage{ + emitter.UpdateMessagesAndEmitChanges([]types.ConversationMessage{ {Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, }) newEvent := <-ch assert.Equal(t, Event{ Type: EventTypeMessageUpdate, - Payload: MessageUpdateBody{Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, + Payload: types.MessageUpdateBody{Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, }, newEvent) - emitter.UpdateMessagesAndEmitChanges([]st.ConversationMessage{ + emitter.UpdateMessagesAndEmitChanges([]types.ConversationMessage{ {Id: 1, Message: "Hello, world! (updated)", Role: st.ConversationRoleUser, Time: now}, {Id: 2, Message: "What's up?", Role: st.ConversationRoleAgent, Time: now}, }) newEvent = <-ch assert.Equal(t, Event{ Type: EventTypeMessageUpdate, - Payload: MessageUpdateBody{Id: 1, Message: "Hello, world! (updated)", Role: st.ConversationRoleUser, Time: now}, + Payload: types.MessageUpdateBody{Id: 1, Message: "Hello, world! (updated)", Role: st.ConversationRoleUser, Time: now}, }, newEvent) newEvent = <-ch assert.Equal(t, Event{ Type: EventTypeMessageUpdate, - Payload: MessageUpdateBody{Id: 2, Message: "What's up?", Role: st.ConversationRoleAgent, Time: now}, + Payload: types.MessageUpdateBody{Id: 2, Message: "What's up?", Role: st.ConversationRoleAgent, Time: now}, }, newEvent) emitter.UpdateStatusAndEmitChanges(st.ConversationStatusStable) newEvent = <-ch assert.Equal(t, Event{ Type: EventTypeStatusChange, - Payload: StatusChangeBody{Status: AgentStatusStable}, + Payload: types.StatusChangeBody{Status: types.AgentStatusStable}, }, newEvent) }) @@ -68,14 +69,14 @@ func TestEventEmitter(t *testing.T) { } now := time.Now() - emitter.UpdateMessagesAndEmitChanges([]st.ConversationMessage{ + emitter.UpdateMessagesAndEmitChanges([]types.ConversationMessage{ {Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, }) for _, ch := range channels { newEvent := <-ch assert.Equal(t, Event{ Type: EventTypeMessageUpdate, - Payload: MessageUpdateBody{Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, + Payload: types.MessageUpdateBody{Id: 1, Message: "Hello, world!", Role: st.ConversationRoleUser, Time: now}, }, newEvent) } }) @@ -84,7 +85,7 @@ func TestEventEmitter(t *testing.T) { emitter := NewEventEmitter(1) _, ch, _ := emitter.Subscribe() for i := range 5 { - emitter.UpdateMessagesAndEmitChanges([]st.ConversationMessage{ + emitter.UpdateMessagesAndEmitChanges([]types.ConversationMessage{ {Id: i, Message: fmt.Sprintf("Hello, world! %d", i), Role: st.ConversationRoleUser, Time: time.Now()}, }) } diff --git a/lib/msgfmt/message_box.go b/lib/cli/msgfmt/message_box.go similarity index 100% rename from lib/msgfmt/message_box.go rename to lib/cli/msgfmt/message_box.go diff --git a/lib/msgfmt/msgfmt.go b/lib/cli/msgfmt/msgfmt.go similarity index 100% rename from lib/msgfmt/msgfmt.go rename to lib/cli/msgfmt/msgfmt.go diff --git a/lib/msgfmt/msgfmt_test.go b/lib/cli/msgfmt/msgfmt_test.go similarity index 100% rename from lib/msgfmt/msgfmt_test.go rename to lib/cli/msgfmt/msgfmt_test.go diff --git a/lib/msgfmt/testdata/format/aider/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/aider/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/aider/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/aider/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/aider/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/aider/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/aider/first_message/user.txt b/lib/cli/msgfmt/testdata/format/aider/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/aider/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/aider/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/aider/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/aider/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/aider/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/aider/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/aider/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/aider/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/aider/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/aider/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/aider/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/aider/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/aider/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/aider/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/aider/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/aider/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/aider/second_message/user.txt b/lib/cli/msgfmt/testdata/format/aider/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/aider/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/aider/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/amazonq/confirmation_box/expected.txt b/lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/confirmation_box/expected.txt rename to lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/expected.txt diff --git a/lib/msgfmt/testdata/format/amazonq/confirmation_box/msg.txt b/lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/confirmation_box/msg.txt rename to lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/msg.txt diff --git a/lib/msgfmt/testdata/format/amazonq/confirmation_box/user.txt b/lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/confirmation_box/user.txt rename to lib/cli/msgfmt/testdata/format/amazonq/confirmation_box/user.txt diff --git a/lib/msgfmt/testdata/format/amazonq/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/amazonq/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/amazonq/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/amazonq/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/amazonq/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/amazonq/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/amazonq/first_message/user.txt b/lib/cli/msgfmt/testdata/format/amazonq/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/amazonq/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/amazonq/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/amazonq/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/amazonq/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/amazonq/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/amazonq/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/amazonq/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/amazonq/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/amazonq/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/amazonq/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/amazonq/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/amazonq/second_message/user.txt b/lib/cli/msgfmt/testdata/format/amazonq/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/amazonq/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/amazonq/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/amazonq/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/amazonq/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/amazonq/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/amazonq/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/amazonq/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/amazonq/thinking/user.txt b/lib/cli/msgfmt/testdata/format/amazonq/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amazonq/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/amazonq/thinking/user.txt diff --git a/lib/msgfmt/testdata/format/amp/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/amp/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/amp/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/amp/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/amp/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/amp/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/amp/first_message/user.txt b/lib/cli/msgfmt/testdata/format/amp/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/amp/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/amp/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/amp/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/amp/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/amp/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/amp/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/amp/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/amp/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/amp/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/amp/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/amp/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/amp/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/amp/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/amp/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/amp/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/amp/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/amp/second_message/user.txt b/lib/cli/msgfmt/testdata/format/amp/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/amp/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/amp/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/auggie/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/auggie/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/auggie/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/auggie/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/auggie/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/auggie/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/auggie/first_message/user.txt b/lib/cli/msgfmt/testdata/format/auggie/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/auggie/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/auggie/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/auggie/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/auggie/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/auggie/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/auggie/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/auggie/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/auggie/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/auggie/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/auggie/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/auggie/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/auggie/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/auggie/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/auggie/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/auggie/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/auggie/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/auggie/second_message/user.txt b/lib/cli/msgfmt/testdata/format/auggie/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/auggie/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/auggie/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/auggie/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/auggie/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/auggie/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/auggie/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/auggie/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/auggie/thinking/user.txt b/lib/cli/msgfmt/testdata/format/auggie/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/auggie/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/auggie/thinking/user.txt diff --git a/lib/msgfmt/testdata/format/claude/auto-accept-edits/expected.txt b/lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/auto-accept-edits/expected.txt rename to lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/expected.txt diff --git a/lib/msgfmt/testdata/format/claude/auto-accept-edits/msg.txt b/lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/auto-accept-edits/msg.txt rename to lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/msg.txt diff --git a/lib/msgfmt/testdata/format/claude/auto-accept-edits/user.txt b/lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/auto-accept-edits/user.txt rename to lib/cli/msgfmt/testdata/format/claude/auto-accept-edits/user.txt diff --git a/lib/msgfmt/testdata/format/claude/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/claude/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/claude/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/claude/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/claude/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/claude/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/claude/first_message/user.txt b/lib/cli/msgfmt/testdata/format/claude/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/claude/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/claude/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/claude/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/claude/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/claude/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/claude/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/claude/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/claude/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/claude/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/claude/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/claude/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/claude/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/claude/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/claude/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/claude/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/claude/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/claude/second_message/user.txt b/lib/cli/msgfmt/testdata/format/claude/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/claude/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/claude/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/codex/confirmation_box/expected.txt b/lib/cli/msgfmt/testdata/format/codex/confirmation_box/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/confirmation_box/expected.txt rename to lib/cli/msgfmt/testdata/format/codex/confirmation_box/expected.txt diff --git a/lib/msgfmt/testdata/format/codex/confirmation_box/msg.txt b/lib/cli/msgfmt/testdata/format/codex/confirmation_box/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/confirmation_box/msg.txt rename to lib/cli/msgfmt/testdata/format/codex/confirmation_box/msg.txt diff --git a/lib/msgfmt/testdata/format/codex/confirmation_box/user.txt b/lib/cli/msgfmt/testdata/format/codex/confirmation_box/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/confirmation_box/user.txt rename to lib/cli/msgfmt/testdata/format/codex/confirmation_box/user.txt diff --git a/lib/msgfmt/testdata/format/codex/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/codex/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/codex/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/codex/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/codex/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/codex/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/codex/first_message/user.txt b/lib/cli/msgfmt/testdata/format/codex/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/codex/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/codex/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/codex/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/codex/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/codex/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/codex/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/codex/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/codex/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/codex/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/codex/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/codex/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/codex/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/codex/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/codex/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/codex/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/codex/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/codex/second_message/user.txt b/lib/cli/msgfmt/testdata/format/codex/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/codex/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/codex/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/codex/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/codex/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/codex/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/codex/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/codex/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/codex/thinking/user.txt b/lib/cli/msgfmt/testdata/format/codex/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/codex/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/codex/thinking/user.txt diff --git a/lib/msgfmt/testdata/format/copilot/confirmation_box/expected.txt b/lib/cli/msgfmt/testdata/format/copilot/confirmation_box/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/confirmation_box/expected.txt rename to lib/cli/msgfmt/testdata/format/copilot/confirmation_box/expected.txt diff --git a/lib/msgfmt/testdata/format/copilot/confirmation_box/msg.txt b/lib/cli/msgfmt/testdata/format/copilot/confirmation_box/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/confirmation_box/msg.txt rename to lib/cli/msgfmt/testdata/format/copilot/confirmation_box/msg.txt diff --git a/lib/msgfmt/testdata/format/copilot/confirmation_box/user.txt b/lib/cli/msgfmt/testdata/format/copilot/confirmation_box/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/confirmation_box/user.txt rename to lib/cli/msgfmt/testdata/format/copilot/confirmation_box/user.txt diff --git a/lib/msgfmt/testdata/format/copilot/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/copilot/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/copilot/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/copilot/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/copilot/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/copilot/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/copilot/first_message/user.txt b/lib/cli/msgfmt/testdata/format/copilot/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/copilot/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/copilot/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/copilot/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/copilot/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/copilot/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/copilot/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/copilot/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/copilot/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/copilot/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/copilot/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/copilot/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/copilot/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/copilot/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/copilot/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/copilot/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/copilot/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/copilot/thinking/user.txt b/lib/cli/msgfmt/testdata/format/copilot/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/copilot/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/copilot/thinking/user.txt diff --git a/lib/msgfmt/testdata/format/cursor/confirmation_box/expected.txt b/lib/cli/msgfmt/testdata/format/cursor/confirmation_box/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/confirmation_box/expected.txt rename to lib/cli/msgfmt/testdata/format/cursor/confirmation_box/expected.txt diff --git a/lib/msgfmt/testdata/format/cursor/confirmation_box/msg.txt b/lib/cli/msgfmt/testdata/format/cursor/confirmation_box/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/confirmation_box/msg.txt rename to lib/cli/msgfmt/testdata/format/cursor/confirmation_box/msg.txt diff --git a/lib/msgfmt/testdata/format/cursor/confirmation_box/user.txt b/lib/cli/msgfmt/testdata/format/cursor/confirmation_box/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/confirmation_box/user.txt rename to lib/cli/msgfmt/testdata/format/cursor/confirmation_box/user.txt diff --git a/lib/msgfmt/testdata/format/cursor/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/cursor/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/cursor/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/cursor/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/cursor/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/cursor/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/cursor/first_message/user.txt b/lib/cli/msgfmt/testdata/format/cursor/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/cursor/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/cursor/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/cursor/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/cursor/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/cursor/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/cursor/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/cursor/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/cursor/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/cursor/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/cursor/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/cursor/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/cursor/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/cursor/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/cursor/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/cursor/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/cursor/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/cursor/second_message/user.txt b/lib/cli/msgfmt/testdata/format/cursor/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/cursor/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/cursor/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/cursor/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/cursor/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/cursor/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/cursor/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/cursor/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/cursor/thinking/user.txt b/lib/cli/msgfmt/testdata/format/cursor/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/cursor/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/cursor/thinking/user.txt diff --git a/lib/msgfmt/testdata/format/gemini/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/gemini/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/gemini/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/gemini/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/gemini/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/gemini/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/gemini/first_message/user.txt b/lib/cli/msgfmt/testdata/format/gemini/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/gemini/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/gemini/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/gemini/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/gemini/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/gemini/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/gemini/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/gemini/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/gemini/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/gemini/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/gemini/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/gemini/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/gemini/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/gemini/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/gemini/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/gemini/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/gemini/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/gemini/second_message/user.txt b/lib/cli/msgfmt/testdata/format/gemini/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/gemini/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/gemini/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/goose/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/goose/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/goose/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/goose/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/goose/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/goose/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/goose/first_message/user.txt b/lib/cli/msgfmt/testdata/format/goose/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/goose/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/goose/multi-line-input/expected.txt b/lib/cli/msgfmt/testdata/format/goose/multi-line-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/multi-line-input/expected.txt rename to lib/cli/msgfmt/testdata/format/goose/multi-line-input/expected.txt diff --git a/lib/msgfmt/testdata/format/goose/multi-line-input/msg.txt b/lib/cli/msgfmt/testdata/format/goose/multi-line-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/multi-line-input/msg.txt rename to lib/cli/msgfmt/testdata/format/goose/multi-line-input/msg.txt diff --git a/lib/msgfmt/testdata/format/goose/multi-line-input/user.txt b/lib/cli/msgfmt/testdata/format/goose/multi-line-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/multi-line-input/user.txt rename to lib/cli/msgfmt/testdata/format/goose/multi-line-input/user.txt diff --git a/lib/msgfmt/testdata/format/goose/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/goose/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/goose/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/goose/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/goose/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/goose/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/goose/second_message/user.txt b/lib/cli/msgfmt/testdata/format/goose/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/goose/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/goose/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/opencode/first_message/expected.txt b/lib/cli/msgfmt/testdata/format/opencode/first_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/first_message/expected.txt rename to lib/cli/msgfmt/testdata/format/opencode/first_message/expected.txt diff --git a/lib/msgfmt/testdata/format/opencode/first_message/msg.txt b/lib/cli/msgfmt/testdata/format/opencode/first_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/first_message/msg.txt rename to lib/cli/msgfmt/testdata/format/opencode/first_message/msg.txt diff --git a/lib/msgfmt/testdata/format/opencode/first_message/user.txt b/lib/cli/msgfmt/testdata/format/opencode/first_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/first_message/user.txt rename to lib/cli/msgfmt/testdata/format/opencode/first_message/user.txt diff --git a/lib/msgfmt/testdata/format/opencode/second_message/expected.txt b/lib/cli/msgfmt/testdata/format/opencode/second_message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/second_message/expected.txt rename to lib/cli/msgfmt/testdata/format/opencode/second_message/expected.txt diff --git a/lib/msgfmt/testdata/format/opencode/second_message/msg.txt b/lib/cli/msgfmt/testdata/format/opencode/second_message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/second_message/msg.txt rename to lib/cli/msgfmt/testdata/format/opencode/second_message/msg.txt diff --git a/lib/msgfmt/testdata/format/opencode/second_message/user.txt b/lib/cli/msgfmt/testdata/format/opencode/second_message/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/second_message/user.txt rename to lib/cli/msgfmt/testdata/format/opencode/second_message/user.txt diff --git a/lib/msgfmt/testdata/format/opencode/thinking/expected.txt b/lib/cli/msgfmt/testdata/format/opencode/thinking/expected.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/thinking/expected.txt rename to lib/cli/msgfmt/testdata/format/opencode/thinking/expected.txt diff --git a/lib/msgfmt/testdata/format/opencode/thinking/msg.txt b/lib/cli/msgfmt/testdata/format/opencode/thinking/msg.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/thinking/msg.txt rename to lib/cli/msgfmt/testdata/format/opencode/thinking/msg.txt diff --git a/lib/msgfmt/testdata/format/opencode/thinking/user.txt b/lib/cli/msgfmt/testdata/format/opencode/thinking/user.txt similarity index 100% rename from lib/msgfmt/testdata/format/opencode/thinking/user.txt rename to lib/cli/msgfmt/testdata/format/opencode/thinking/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/aider/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/aider/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/aider/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/aider/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/aider/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/aider/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/aider/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/aider/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/aider/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/aider/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/aider/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/aider/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/claude/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/claude/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/claude/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/claude/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/claude/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/claude/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/claude/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/claude/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/claude/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/claude/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/claude/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/claude/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/empty-input/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/empty-input/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/empty-input/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/empty-input/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/empty-input/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/empty-input/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/empty-input/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/empty-input/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/empty-input/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/empty-input/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/empty-input/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/empty-input/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/full-match/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/full-match/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/full-match/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/full-match/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/full-match/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/full-match/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/full-match/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/full-match/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/full-match/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/full-match/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/full-match/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/full-match/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/goose/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/goose/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/goose/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/goose/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/goose/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/goose/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/goose/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/goose/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/goose/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/goose/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/goose/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/goose/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/no-user-input-in-message/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/no-user-input-in-message/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-2/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-2/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-2/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-2/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-2/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-2/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-2/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-3/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-3/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-3/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-3/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii-3/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii-3/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii-3/user.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii/expected.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii/expected.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii/expected.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii/expected.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii/msg.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii/msg.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii/msg.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii/msg.txt diff --git a/lib/msgfmt/testdata/remove-user-input/non-ascii/user.txt b/lib/cli/msgfmt/testdata/remove-user-input/non-ascii/user.txt similarity index 100% rename from lib/msgfmt/testdata/remove-user-input/non-ascii/user.txt rename to lib/cli/msgfmt/testdata/remove-user-input/non-ascii/user.txt diff --git a/lib/screentracker/conversation.go b/lib/cli/screentracker/conversation.go similarity index 92% rename from lib/screentracker/conversation.go rename to lib/cli/screentracker/conversation.go index 7777e04..ebc47db 100644 --- a/lib/screentracker/conversation.go +++ b/lib/cli/screentracker/conversation.go @@ -7,9 +7,9 @@ import ( "sync" "time" - "github.com/coder/agentapi/lib/msgfmt" + "github.com/coder/agentapi/lib/cli/msgfmt" + "github.com/coder/agentapi/lib/types" "github.com/coder/agentapi/lib/util" - "github.com/danielgtaylor/huma/v2" "golang.org/x/xerrors" ) @@ -43,35 +43,17 @@ type ConversationConfig struct { SkipSendMessageStatusCheck bool } -type ConversationRole string - const ( - ConversationRoleUser ConversationRole = "user" - ConversationRoleAgent ConversationRole = "agent" + ConversationRoleUser = types.ConversationRoleUser + ConversationRoleAgent = types.ConversationRoleAgent ) -var ConversationRoleValues = []ConversationRole{ - ConversationRoleUser, - ConversationRoleAgent, -} - -func (c ConversationRole) Schema(r huma.Registry) *huma.Schema { - return util.OpenAPISchema(r, "ConversationRole", ConversationRoleValues) -} - -type ConversationMessage struct { - Id int - Message string - Role ConversationRole - Time time.Time -} - type Conversation struct { cfg ConversationConfig // How many stable snapshots are required to consider the screen stable stableSnapshotsThreshold int snapshotBuffer *RingBuffer[screenSnapshot] - messages []ConversationMessage + messages []types.ConversationMessage screenBeforeLastUserMessage string lock sync.Mutex } @@ -100,7 +82,7 @@ func NewConversation(ctx context.Context, cfg ConversationConfig) *Conversation cfg: cfg, stableSnapshotsThreshold: threshold, snapshotBuffer: NewRingBuffer[screenSnapshot](threshold), - messages: []ConversationMessage{ + messages: []types.ConversationMessage{ { Message: "", Role: ConversationRoleAgent, @@ -182,13 +164,13 @@ func FindNewMessage(oldScreen, newScreen string, agentType msgfmt.AgentType) str return strings.Join(newSectionLines[startLine:endLine+1], "\n") } -func (c *Conversation) lastMessage(role ConversationRole) ConversationMessage { +func (c *Conversation) lastMessage(role types.ConversationRole) types.ConversationMessage { for i := len(c.messages) - 1; i >= 0; i-- { if c.messages[i].Role == role { return c.messages[i] } } - return ConversationMessage{} + return types.ConversationMessage{} } // This function assumes that the caller holds the lock @@ -203,7 +185,7 @@ func (c *Conversation) updateLastAgentMessage(screen string, timestamp time.Time if lastAgentMessage.Message == agentMessage { return } - conversationMessage := ConversationMessage{ + conversationMessage := types.ConversationMessage{ Message: agentMessage, Role: ConversationRoleAgent, Time: timestamp, @@ -359,7 +341,7 @@ func (c *Conversation) SendMessage(messageParts ...MessagePart) error { } c.screenBeforeLastUserMessage = screenBeforeMessage - c.messages = append(c.messages, ConversationMessage{ + c.messages = append(c.messages, types.ConversationMessage{ Id: len(c.messages), Message: message, Role: ConversationRoleUser, @@ -405,11 +387,11 @@ func (c *Conversation) Status() ConversationStatus { return c.statusInner() } -func (c *Conversation) Messages() []ConversationMessage { +func (c *Conversation) Messages() []types.ConversationMessage { c.lock.Lock() defer c.lock.Unlock() - result := make([]ConversationMessage, len(c.messages)) + result := make([]types.ConversationMessage, len(c.messages)) copy(result, c.messages) return result } diff --git a/lib/screentracker/conversation_test.go b/lib/cli/screentracker/conversation_test.go similarity index 91% rename from lib/screentracker/conversation_test.go rename to lib/cli/screentracker/conversation_test.go index 53c77fd..2f59e09 100644 --- a/lib/screentracker/conversation_test.go +++ b/lib/cli/screentracker/conversation_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/coder/agentapi/lib/msgfmt" + "github.com/coder/agentapi/lib/cli/msgfmt" + st "github.com/coder/agentapi/lib/cli/screentracker" + "github.com/coder/agentapi/lib/types" "github.com/stretchr/testify/assert" - - st "github.com/coder/agentapi/lib/screentracker" ) type statusTestStep struct { @@ -117,16 +117,16 @@ func TestConversation(t *testing.T) { func TestMessages(t *testing.T) { now := time.Now() - agentMsg := func(id int, msg string) st.ConversationMessage { - return st.ConversationMessage{ + agentMsg := func(id int, msg string) types.ConversationMessage { + return types.ConversationMessage{ Id: id, Message: msg, Role: st.ConversationRoleAgent, Time: now, } } - userMsg := func(id int, msg string) st.ConversationMessage { - return st.ConversationMessage{ + userMsg := func(id int, msg string) types.ConversationMessage { + return types.ConversationMessage{ Id: id, Message: msg, Role: st.ConversationRoleUser, @@ -153,13 +153,13 @@ func TestMessages(t *testing.T) { t.Run("messages are copied", func(t *testing.T) { c := newConversation() messages := c.Messages() - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, ""), }, messages) messages[0].Message = "modification" - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, ""), }, c.Messages()) }) @@ -184,7 +184,7 @@ func TestMessages(t *testing.T) { c.AddSnapshot("1") msgs := c.Messages() - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1"), }, msgs) nowWrapper.Time = nowWrapper.Add(1 * time.Second) @@ -199,27 +199,27 @@ func TestMessages(t *testing.T) { }) // agent message is recorded when the first snapshot is added c.AddSnapshot("1") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1"), }, c.Messages()) // agent message is updated when the screen changes c.AddSnapshot("2") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "2"), }, c.Messages()) // user message is recorded agent.screen = "2" assert.NoError(t, sendMsg(c, "3")) - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "2"), userMsg(1, "3"), }, c.Messages()) // agent message is added after a user message c.AddSnapshot("4") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "2"), userMsg(1, "3"), agentMsg(2, "4"), @@ -228,7 +228,7 @@ func TestMessages(t *testing.T) { // agent message is updated when the screen changes before a user message agent.screen = "5" assert.NoError(t, sendMsg(c, "6")) - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "2"), userMsg(1, "3"), agentMsg(2, "5"), @@ -242,7 +242,7 @@ func TestMessages(t *testing.T) { assert.Equal(t, st.ConversationStatusStable, c.Status()) agent.screen = "7" assert.NoError(t, sendMsg(c, "8")) - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "2"), userMsg(1, "3"), agentMsg(2, "5"), @@ -269,7 +269,7 @@ func TestMessages(t *testing.T) { agent.screen = "1" assert.NoError(t, sendMsg(c, "2")) c.AddSnapshot("1\n3") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1"), userMsg(1, "2"), agentMsg(2, "3"), @@ -278,7 +278,7 @@ func TestMessages(t *testing.T) { agent.screen = "1\n3x" assert.NoError(t, sendMsg(c, "4")) c.AddSnapshot("1\n3x\n5") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1"), userMsg(1, "2"), agentMsg(2, "3x"), @@ -297,13 +297,13 @@ func TestMessages(t *testing.T) { }) agent.screen = "1" assert.NoError(t, sendMsg(c, "2")) - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1 "), userMsg(1, "2"), }, c.Messages()) agent.screen = "x" c.AddSnapshot("x") - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ agentMsg(0, "1 "), userMsg(1, "2"), agentMsg(2, "x 2"), @@ -318,7 +318,7 @@ func TestMessages(t *testing.T) { return "formatted" } }) - assert.Equal(t, []st.ConversationMessage{ + assert.Equal(t, []types.ConversationMessage{ { Id: 0, Message: "", diff --git a/lib/screentracker/ringbuffer.go b/lib/cli/screentracker/ringbuffer.go similarity index 100% rename from lib/screentracker/ringbuffer.go rename to lib/cli/screentracker/ringbuffer.go diff --git a/lib/screentracker/testdata/diff/basic/after.txt b/lib/cli/screentracker/testdata/diff/basic/after.txt similarity index 100% rename from lib/screentracker/testdata/diff/basic/after.txt rename to lib/cli/screentracker/testdata/diff/basic/after.txt diff --git a/lib/screentracker/testdata/diff/basic/before.txt b/lib/cli/screentracker/testdata/diff/basic/before.txt similarity index 100% rename from lib/screentracker/testdata/diff/basic/before.txt rename to lib/cli/screentracker/testdata/diff/basic/before.txt diff --git a/lib/screentracker/testdata/diff/basic/expected.txt b/lib/cli/screentracker/testdata/diff/basic/expected.txt similarity index 100% rename from lib/screentracker/testdata/diff/basic/expected.txt rename to lib/cli/screentracker/testdata/diff/basic/expected.txt diff --git a/lib/screentracker/testdata/diff/no-change/after.txt b/lib/cli/screentracker/testdata/diff/no-change/after.txt similarity index 100% rename from lib/screentracker/testdata/diff/no-change/after.txt rename to lib/cli/screentracker/testdata/diff/no-change/after.txt diff --git a/lib/screentracker/testdata/diff/no-change/before.txt b/lib/cli/screentracker/testdata/diff/no-change/before.txt similarity index 100% rename from lib/screentracker/testdata/diff/no-change/before.txt rename to lib/cli/screentracker/testdata/diff/no-change/before.txt diff --git a/lib/screentracker/testdata/diff/no-change/expected.txt b/lib/cli/screentracker/testdata/diff/no-change/expected.txt similarity index 100% rename from lib/screentracker/testdata/diff/no-change/expected.txt rename to lib/cli/screentracker/testdata/diff/no-change/expected.txt diff --git a/lib/termexec/termexec.go b/lib/cli/termexec/termexec.go similarity index 100% rename from lib/termexec/termexec.go rename to lib/cli/termexec/termexec.go diff --git a/lib/httpapi/handler.go b/lib/httpapi/handler.go new file mode 100644 index 0000000..8d4d390 --- /dev/null +++ b/lib/httpapi/handler.go @@ -0,0 +1,20 @@ +package httpapi + +import ( + "context" + + "github.com/coder/agentapi/lib/types" + "github.com/danielgtaylor/huma/v2/sse" +) + +// AgentHandler defines the interface that all interaction modes must implement +type AgentHandler interface { + GetStatus(ctx context.Context, input *struct{}) (*types.StatusResponse, error) + CreateMessage(ctx context.Context, input *types.MessageRequest) (*types.MessageResponse, error) + GetMessages(ctx context.Context, input *struct{}) (*types.MessagesResponse, error) + SubscribeEvents(ctx context.Context, input *struct{}, send sse.Sender) + + // SubscribeConversations Was Initially SubscribeScreen, tbd whether we want to expose this in SDK mode TODO 1 + SubscribeConversations(ctx context.Context, input *struct{}, send sse.Sender) + StartSnapshotLoop(ctx context.Context) +} diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 08d92c4..d367fec 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -10,15 +10,14 @@ import ( "slices" "sort" "strings" - "sync" - "time" "unicode" "github.com/coder/agentapi/internal/version" + "github.com/coder/agentapi/lib/cli" + "github.com/coder/agentapi/lib/cli/msgfmt" + "github.com/coder/agentapi/lib/cli/termexec" "github.com/coder/agentapi/lib/logctx" - mf "github.com/coder/agentapi/lib/msgfmt" - st "github.com/coder/agentapi/lib/screentracker" - "github.com/coder/agentapi/lib/termexec" + "github.com/coder/agentapi/lib/types" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/danielgtaylor/huma/v2/sse" @@ -33,13 +32,10 @@ type Server struct { api huma.API port int srv *http.Server - mu sync.RWMutex logger *slog.Logger - conversation *st.Conversation - agentio *termexec.Process - agentType mf.AgentType - emitter *EventEmitter + agentType msgfmt.AgentType chatBasePath string + AgentHandler AgentHandler } func (s *Server) NormalizeSchema(schema any) any { @@ -84,17 +80,14 @@ func (s *Server) GetOpenAPI() string { return string(prettyJSON) } -// That's about 40 frames per second. It's slightly less -// because the action of taking a snapshot takes time too. -const snapshotInterval = 25 * time.Millisecond - type ServerConfig struct { - AgentType mf.AgentType - Process *termexec.Process - Port int - ChatBasePath string - AllowedHosts []string - AllowedOrigins []string + AgentType msgfmt.AgentType + InteractionType types.InteractionType + Process *termexec.Process + Port int + ChatBasePath string + AllowedHosts []string + AllowedOrigins []string } // Validate allowed hosts don't contain whitespace, commas, schemes, or ports. @@ -218,35 +211,29 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) { humaConfig := huma.DefaultConfig("AgentAPI", version.Version) humaConfig.Info.Description = "HTTP API for Claude Code, Goose, and Aider.\n\nhttps://github.com/coder/agentapi" api := humachi.New(router, humaConfig) - formatMessage := func(message string, userInput string) string { - return mf.FormatAgentMessage(config.AgentType, message, userInput) - } - conversation := st.NewConversation(ctx, st.ConversationConfig{ - AgentType: config.AgentType, - AgentIO: config.Process, - GetTime: func() time.Time { - return time.Now() - }, - SnapshotInterval: snapshotInterval, - ScreenStabilityLength: 2 * time.Second, - FormatMessage: formatMessage, - }) - emitter := NewEventEmitter(1024) s := &Server{ router: router, api: api, port: config.Port, - conversation: conversation, logger: logger, - agentio: config.Process, agentType: config.AgentType, - emitter: emitter, chatBasePath: strings.TrimSuffix(config.ChatBasePath, "/"), } + // Get the appropriate Interaction Handler + if config.InteractionType == types.InteractionTypeCLI { + s.AgentHandler = cli.NewCLIHandler(ctx, logger, config.Process, config.AgentType) + } else if config.InteractionType == types.InteractionTypeSDK { + // TODO add a SDKHandler for SDK + } else { + return nil, xerrors.Errorf("unknown interaction type %q", config.InteractionType) + } + // Register API routes s.registerRoutes() + s.AgentHandler.StartSnapshotLoop(ctx) + return s, nil } @@ -302,32 +289,20 @@ func sseMiddleware(ctx huma.Context, next func(huma.Context)) { next(ctx) } -func (s *Server) StartSnapshotLoop(ctx context.Context) { - s.conversation.StartSnapshotLoop(ctx) - go func() { - for { - s.emitter.UpdateStatusAndEmitChanges(s.conversation.Status()) - s.emitter.UpdateMessagesAndEmitChanges(s.conversation.Messages()) - s.emitter.UpdateScreenAndEmitChanges(s.conversation.Screen()) - time.Sleep(snapshotInterval) - } - }() -} - // registerRoutes sets up all API endpoints func (s *Server) registerRoutes() { // GET /status endpoint - huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) { + huma.Get(s.api, "/status", s.AgentHandler.GetStatus, func(o *huma.Operation) { o.Description = "Returns the current status of the agent." }) // GET /messages endpoint - huma.Get(s.api, "/messages", s.getMessages, func(o *huma.Operation) { + huma.Get(s.api, "/messages", s.AgentHandler.GetMessages, func(o *huma.Operation) { o.Description = "Returns a list of messages representing the conversation history with the agent." }) // POST /message endpoint - huma.Post(s.api, "/message", s.createMessage, func(o *huma.Operation) { + huma.Post(s.api, "/message", s.AgentHandler.CreateMessage, func(o *huma.Operation) { o.Description = "Send a message to the agent. For messages of type 'user', the agent's status must be 'stable' for the operation to complete successfully. Otherwise, this endpoint will return an error." }) @@ -341,9 +316,9 @@ func (s *Server) registerRoutes() { Middlewares: []func(huma.Context, func(huma.Context)){sseMiddleware}, }, map[string]any{ // Mapping of event type name to Go struct for that event. - "message_update": MessageUpdateBody{}, - "status_change": StatusChangeBody{}, - }, s.subscribeEvents) + "message_update": types.MessageUpdateBody{}, + "status_change": types.StatusChangeBody{}, + }, s.AgentHandler.SubscribeEvents) sse.Register(s.api, huma.Operation{ OperationID: "subscribeScreen", @@ -353,8 +328,8 @@ func (s *Server) registerRoutes() { Hidden: true, Middlewares: []func(huma.Context, func(huma.Context)){sseMiddleware}, }, map[string]any{ - "screen": ScreenUpdateBody{}, - }, s.subscribeScreen) + "screen": types.ScreenUpdateBody{}, + }, s.AgentHandler.SubscribeConversations) s.router.Handle("/", http.HandlerFunc(s.redirectToChat)) @@ -362,131 +337,6 @@ func (s *Server) registerRoutes() { s.registerStaticFileRoutes() } -// getStatus handles GET /status -func (s *Server) getStatus(ctx context.Context, input *struct{}) (*StatusResponse, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - status := s.conversation.Status() - agentStatus := convertStatus(status) - - resp := &StatusResponse{} - resp.Body.Status = agentStatus - - return resp, nil -} - -// getMessages handles GET /messages -func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesResponse, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - resp := &MessagesResponse{} - resp.Body.Messages = make([]Message, len(s.conversation.Messages())) - for i, msg := range s.conversation.Messages() { - resp.Body.Messages[i] = Message{ - Id: msg.Id, - Role: msg.Role, - Content: msg.Message, - Time: msg.Time, - } - } - - return resp, nil -} - -// createMessage handles POST /message -func (s *Server) createMessage(ctx context.Context, input *MessageRequest) (*MessageResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - - switch input.Body.Type { - case MessageTypeUser: - if err := s.conversation.SendMessage(FormatMessage(s.agentType, input.Body.Content)...); err != nil { - return nil, xerrors.Errorf("failed to send message: %w", err) - } - case MessageTypeRaw: - if _, err := s.agentio.Write([]byte(input.Body.Content)); err != nil { - return nil, xerrors.Errorf("failed to send message: %w", err) - } - } - - resp := &MessageResponse{} - resp.Body.Ok = true - - return resp, nil -} - -// subscribeEvents is an SSE endpoint that sends events to the client -func (s *Server) subscribeEvents(ctx context.Context, input *struct{}, send sse.Sender) { - subscriberId, ch, stateEvents := s.emitter.Subscribe() - defer s.emitter.Unsubscribe(subscriberId) - s.logger.Info("New subscriber", "subscriberId", subscriberId) - for _, event := range stateEvents { - if event.Type == EventTypeScreenUpdate { - continue - } - if err := send.Data(event.Payload); err != nil { - s.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) - return - } - } - - for { - select { - case event, ok := <-ch: - if !ok { - s.logger.Info("Channel closed", "subscriberId", subscriberId) - return - } - if event.Type == EventTypeScreenUpdate { - continue - } - if err := send.Data(event.Payload); err != nil { - s.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) - return - } - case <-ctx.Done(): - s.logger.Info("Context done", "subscriberId", subscriberId) - return - } - } -} - -func (s *Server) subscribeScreen(ctx context.Context, input *struct{}, send sse.Sender) { - subscriberId, ch, stateEvents := s.emitter.Subscribe() - defer s.emitter.Unsubscribe(subscriberId) - s.logger.Info("New screen subscriber", "subscriberId", subscriberId) - for _, event := range stateEvents { - if event.Type != EventTypeScreenUpdate { - continue - } - if err := send.Data(event.Payload); err != nil { - s.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) - return - } - } - for { - select { - case event, ok := <-ch: - if !ok { - s.logger.Info("Screen channel closed", "subscriberId", subscriberId) - return - } - if event.Type != EventTypeScreenUpdate { - continue - } - if err := send.Data(event.Payload); err != nil { - s.logger.Error("Failed to send screen event", "subscriberId", subscriberId, "error", err) - return - } - case <-ctx.Done(): - s.logger.Info("Screen context done", "subscriberId", subscriberId) - return - } - } -} - // Start starts the HTTP server func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index c209638..0096f6f 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -10,9 +10,10 @@ import ( "os" "testing" + "github.com/coder/agentapi/lib/cli/msgfmt" "github.com/coder/agentapi/lib/httpapi" "github.com/coder/agentapi/lib/logctx" - "github.com/coder/agentapi/lib/msgfmt" + "github.com/coder/agentapi/lib/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,12 +25,13 @@ func TestOpenAPISchema(t *testing.T) { ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: []string{"*"}, - AllowedOrigins: []string{"*"}, + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: []string{"*"}, + AllowedOrigins: []string{"*"}, + InteractionType: types.InteractionTypeCLI, }) require.NoError(t, err) currentSchemaStr := srv.GetOpenAPI() @@ -73,12 +75,13 @@ func TestServer_redirectToChat(t *testing.T) { t.Parallel() tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) s, err := httpapi.NewServer(tCtx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: tc.chatBasePath, - AllowedHosts: []string{"*"}, - AllowedOrigins: []string{"*"}, + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: tc.chatBasePath, + AllowedHosts: []string{"*"}, + AllowedOrigins: []string{"*"}, + InteractionType: types.InteractionTypeCLI, }) require.NoError(t, err) tsServer := httptest.NewServer(s.Handler()) @@ -237,12 +240,13 @@ func TestServer_AllowedHosts(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: tc.allowedHosts, - AllowedOrigins: []string{"https://example.com"}, // Set a default to isolate host testing + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: tc.allowedHosts, + AllowedOrigins: []string{"https://example.com"}, // Set a default to isolate host testing + InteractionType: types.InteractionTypeCLI, }) if tc.validationErrorMsg != "" { require.Error(t, err) @@ -320,12 +324,13 @@ func TestServer_CORSPreflightWithHosts(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: tc.allowedHosts, - AllowedOrigins: []string{"*"}, // Set wildcard origins to isolate host testing + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: tc.allowedHosts, + AllowedOrigins: []string{"*"}, // Set wildcard origins to isolate host testing + InteractionType: types.InteractionTypeCLI, }) require.NoError(t, err) tsServer := httptest.NewServer(s.Handler()) @@ -479,12 +484,13 @@ func TestServer_CORSOrigins(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: []string{"*"}, // Set wildcard to isolate CORS testing - AllowedOrigins: tc.allowedOrigins, + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: []string{"*"}, // Set wildcard to isolate CORS testing + AllowedOrigins: tc.allowedOrigins, + InteractionType: types.InteractionTypeCLI, }) if tc.validationErrorMsg != "" { require.Error(t, err) @@ -559,12 +565,13 @@ func TestServer_CORSPreflightOrigins(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: []string{"*"}, // Set wildcard to isolate CORS testing - AllowedOrigins: tc.allowedOrigins, + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: []string{"*"}, // Set wildcard to isolate CORS testing + AllowedOrigins: tc.allowedOrigins, + InteractionType: types.InteractionTypeCLI, }) require.NoError(t, err) tsServer := httptest.NewServer(s.Handler()) @@ -610,12 +617,13 @@ func TestServer_SSEMiddleware_Events(t *testing.T) { t.Parallel() ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: msgfmt.AgentTypeClaude, - Process: nil, - Port: 0, - ChatBasePath: "/chat", - AllowedHosts: []string{"*"}, - AllowedOrigins: []string{"*"}, + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: []string{"*"}, + AllowedOrigins: []string{"*"}, + InteractionType: types.InteractionTypeCLI, }) require.NoError(t, err) tsServer := httptest.NewServer(srv.Handler()) diff --git a/lib/httpapi/setup.go b/lib/httpapi/setup.go index 1620304..f8ddb30 100644 --- a/lib/httpapi/setup.go +++ b/lib/httpapi/setup.go @@ -9,9 +9,9 @@ import ( "syscall" "time" + mf "github.com/coder/agentapi/lib/cli/msgfmt" + "github.com/coder/agentapi/lib/cli/termexec" "github.com/coder/agentapi/lib/logctx" - mf "github.com/coder/agentapi/lib/msgfmt" - "github.com/coder/agentapi/lib/termexec" ) type SetupProcessConfig struct { diff --git a/lib/types/conversation.go b/lib/types/conversation.go new file mode 100644 index 0000000..a131712 --- /dev/null +++ b/lib/types/conversation.go @@ -0,0 +1,70 @@ +package types + +import ( + "time" + + "github.com/coder/agentapi/lib/util" + "github.com/danielgtaylor/huma/v2" +) + +type ConversationRole string + +const ( + ConversationRoleUser ConversationRole = "user" + ConversationRoleAgent ConversationRole = "agent" +) + +var ConversationRoleValues = []ConversationRole{ + ConversationRoleUser, + ConversationRoleAgent, +} + +type ConversationMessage struct { + Id int + Message string + Role ConversationRole + Time time.Time +} + +func (c ConversationRole) Schema(r huma.Registry) *huma.Schema { + return util.OpenAPISchema(r, "ConversationRole", ConversationRoleValues) +} + +type InteractionType string + +const ( + InteractionTypeSDK InteractionType = "sdk" + InteractionTypeCLI InteractionType = "cli" +) + +type MessageType string + +const ( + MessageTypeUser MessageType = "user" + MessageTypeRaw MessageType = "raw" +) + +var MessageTypeValues = []MessageType{ + MessageTypeUser, + MessageTypeRaw, +} + +func (m MessageType) Schema(r huma.Registry) *huma.Schema { + return util.OpenAPISchema(r, "MessageType", MessageTypeValues) +} + +type AgentStatus string + +const ( + AgentStatusRunning AgentStatus = "running" + AgentStatusStable AgentStatus = "stable" +) + +var AgentStatusValues = []AgentStatus{ + AgentStatusStable, + AgentStatusRunning, +} + +func (a AgentStatus) Schema(r huma.Registry) *huma.Schema { + return util.OpenAPISchema(r, "AgentStatus", AgentStatusValues) +} diff --git a/lib/httpapi/models.go b/lib/types/models.go similarity index 56% rename from lib/httpapi/models.go rename to lib/types/models.go index 8f969f9..bc03fa0 100644 --- a/lib/httpapi/models.go +++ b/lib/types/models.go @@ -1,35 +1,13 @@ -package httpapi +package types -import ( - "time" - - st "github.com/coder/agentapi/lib/screentracker" - "github.com/coder/agentapi/lib/util" - "github.com/danielgtaylor/huma/v2" -) - -type MessageType string - -const ( - MessageTypeUser MessageType = "user" - MessageTypeRaw MessageType = "raw" -) - -var MessageTypeValues = []MessageType{ - MessageTypeUser, - MessageTypeRaw, -} - -func (m MessageType) Schema(r huma.Registry) *huma.Schema { - return util.OpenAPISchema(r, "MessageType", MessageTypeValues) -} +import "time" // Message represents a message type Message struct { - Id int `json:"id" doc:"Unique identifier for the message. This identifier also represents the order of the message in the conversation history."` - Content string `json:"content" example:"Hello world" doc:"Message content. The message is formatted as it appears in the agent's terminal session, meaning that, by default, it consists of lines of text with 80 characters per line."` - Role st.ConversationRole `json:"role" doc:"Role of the message author"` - Time time.Time `json:"time" doc:"Timestamp of the message"` + Id int `json:"id" doc:"Unique identifier for the message. This identifier also represents the order of the message in the conversation history."` + Content string `json:"content" example:"Hello world" doc:"Message content. The message is formatted as it appears in the agent's terminal session, meaning that, by default, it consists of lines of text with 80 characters per line."` + Role ConversationRole `json:"role" doc:"Role of the message author"` + Time time.Time `json:"time" doc:"Timestamp of the message"` } // StatusResponse represents the server status @@ -62,3 +40,18 @@ type MessageResponse struct { Ok bool `json:"ok" doc:"Indicates whether the message was sent successfully. For messages of type 'user', success means detecting that the agent began executing the task described. For messages of type 'raw', success means the keystrokes were sent to the terminal."` } } + +type StatusChangeBody struct { + Status AgentStatus `json:"status" doc:"Agent status"` +} + +type ScreenUpdateBody struct { + Screen string `json:"screen"` +} + +type MessageUpdateBody struct { + Id int `json:"id" doc:"Unique identifier for the message. This identifier also represents the order of the message in the conversation history."` + Role ConversationRole `json:"role" doc:"Role of the message author"` + Message string `json:"message" doc:"Message content. The message is formatted as it appears in the agent's terminal session, meaning that, by default, it consists of lines of text with 80 characters per line."` + Time time.Time `json:"time" doc:"Timestamp of the message"` +}