feat(gui): pkg/chat + gui.chat.* IPC actions (MVP chat surface)

pkg/chat/service.go + messages.go implement the RFC §15.1–§15.8
chat service with the full IPC surface:
- gui.chat.send — streams assistant reply, returns message id
- gui.chat.history — []Message for a conversation
- gui.chat.models — []Model (name, size, status)
- gui.chat.selectModel — sets active model
- gui.chat.conversations.list/load/delete
- gui.chat.thinking.start/stop — explicit thinking-state tracking

MCP tool registrations in pkg/mcp/tools_chat.go mirror the IPC
surface (chat_send, chat_history, chat_models, etc). WS bridge in
pkg/display/display.go wires chat:conversations:load and
chat🤔stop, keeping legacy chat:conversations:get and
chat🤔end paths pointed at the new handlers for compat.

Good/Bad/Ugly tests per action in service_test.go + godoc example
in service_example_test.go. go vet + go test ./pkg/chat/... +
./pkg/... all clean.

Closes tasks.lthn.sh/view.php?id=14

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-24 07:26:22 +01:00
parent dfff893f16
commit 7976b579a4
6 changed files with 867 additions and 662 deletions

View file

@ -24,7 +24,8 @@ type QueryConversationSearch struct {
Query string `json:"q"`
}
type ChatMessage struct {
// Message is the persisted chat transcript entry used by the MVP IPC surface.
type Message struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
@ -38,16 +39,23 @@ type ChatMessage struct {
FinishReason string `json:"finish_reason,omitempty"`
}
type ModelEntry struct {
type ChatMessage = Message
// Model is the transport shape exposed by gui.chat.models.
type Model struct {
Name string `json:"name"`
Architecture string `json:"architecture"`
QuantBits int `json:"quant_bits"`
SizeBytes int64 `json:"size_bytes"`
Loaded bool `json:"loaded"`
Backend string `json:"backend"`
SupportsVision bool `json:"supports_vision"`
Size int64 `json:"size"`
Status string `json:"status"`
Architecture string `json:"architecture,omitempty"`
QuantBits int `json:"quant_bits,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Loaded bool `json:"loaded,omitempty"`
Backend string `json:"backend,omitempty"`
SupportsVision bool `json:"supports_vision,omitempty"`
}
type ModelEntry = Model
type ChatSettings struct {
Temperature float32 `json:"temperature"`
TopP float32 `json:"top_p"`
@ -64,7 +72,7 @@ type Conversation struct {
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []ChatMessage `json:"messages"`
Messages []Message `json:"messages"`
Settings *ChatSettings `json:"settings,omitempty"`
}
@ -139,8 +147,8 @@ type ActionConversationDeleted struct {
}
type ActionMessageAdded struct {
ConversationID string `json:"conversation_id"`
Message ChatMessage `json:"message"`
ConversationID string `json:"conversation_id"`
Message Message `json:"message"`
}
type ActionConversationCleared struct {

View file

@ -44,6 +44,20 @@ type Options struct {
Now func() time.Time
}
type contract interface {
Send(context.Context, sendInput) (string, error)
History(string, int) ([]Message, error)
Models() []ModelEntry
SelectModel(selectModelInput) (ChatSettings, error)
ListConversations() ([]Conversation, error)
LoadConversation(string) (Conversation, error)
DeleteConversation(string) error
StartThinking(thinkingInput) (ThinkingState, error)
StopThinking(thinkingInput) (ThinkingState, error)
}
var _ contract = (*Service)(nil)
type Service struct {
*core.ServiceRuntime[Options]
options Options
@ -52,12 +66,20 @@ type Service struct {
toolExecutor ToolExecutor
toolHandler *ToolCallHandler
pendingAttachments map[string][]ImageAttachment
thinkingStates map[string]ThinkingState
mu sync.Mutex
}
type sendInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Content string `json:"content"`
Model string `json:"model,omitempty"`
}
type historyInput struct {
ID string `json:"id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
Limit int `json:"limit,omitempty"`
}
type conversationInput struct {
@ -83,6 +105,7 @@ type thinkingInput struct {
}
type selectModelInput struct {
Name string `json:"name,omitempty"`
Model string `json:"model"`
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
@ -180,6 +203,7 @@ func Register(optionFns ...func(*Options)) func(*core.Core) core.Result {
options: options,
httpClient: options.HTTPClient,
pendingAttachments: make(map[string][]ImageAttachment),
thinkingStates: make(map[string]ThinkingState),
}
return core.Result{Value: svc, OK: true}
}
@ -219,7 +243,7 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
conv, err := s.getConversation(typed.ID, typed.ConversationID)
return core.Result{}.New(conv, err)
case QueryModels:
return core.Result{Value: s.discoverModels(), OK: true}
return core.Result{Value: s.Models(), OK: true}
case QuerySettings:
return core.Result{Value: s.loadSettings(), OK: true}
case QuerySettingsDefaults:
@ -245,8 +269,8 @@ func (s *Service) registerActions() {
if err != nil {
return core.Result{Value: err, OK: false}
}
conv, err := s.send(ctx, input)
return core.Result{}.New(conv, err)
messageID, err := s.Send(ctx, input)
return core.Result{}.New(messageID, err)
})
c.Action("gui.chat.clear", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[conversationInput](opts)
@ -257,22 +281,22 @@ func (s *Service) registerActions() {
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.history", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[conversationInput](opts)
input, err := decodeInput[historyInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
conv, err := s.getConversation(input.ID, input.ConversationID)
return core.Result{}.New(conv, err)
messages, err := s.History(coalesce(input.ID, input.ConversationID), input.Limit)
return core.Result{}.New(messages, err)
})
c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: s.discoverModels(), OK: true}
return core.Result{Value: s.Models(), OK: true}
})
c.Action("gui.chat.selectModel", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[selectModelInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
settings, err := s.selectModel(input)
settings, err := s.SelectModel(input)
return core.Result{}.New(settings, err)
})
c.Action("gui.chat.settings.save", func(_ context.Context, opts core.Options) core.Result {
@ -295,24 +319,26 @@ func (s *Service) registerActions() {
return core.Result{}.New(settings, err)
})
c.Action("gui.chat.conversations.list", func(_ context.Context, _ core.Options) core.Result {
conversations, err := s.listConversationSummaries()
conversations, err := s.ListConversations()
return core.Result{}.New(conversations, err)
})
c.Action("gui.chat.conversations.get", func(_ context.Context, opts core.Options) core.Result {
loadConversation := func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[conversationInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
conv, err := s.getConversation(input.ID, input.ConversationID)
conv, err := s.LoadConversation(coalesce(input.ID, input.ConversationID))
return core.Result{}.New(conv, err)
})
}
c.Action("gui.chat.conversations.load", loadConversation)
c.Action("gui.chat.conversations.get", loadConversation)
c.Action("gui.chat.conversations.delete", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[conversationInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
err = s.deleteConversation(coalesce(input.ID, input.ConversationID))
return core.Result{}.New(nil, err)
err = s.DeleteConversation(coalesce(input.ID, input.ConversationID))
return core.Result{}.New(true, err)
})
c.Action("gui.chat.conversations.search", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[searchInput](opts)
@ -393,15 +419,8 @@ func (s *Service) registerActions() {
if err != nil {
return core.Result{Value: err, OK: false}
}
if strings.TrimSpace(input.ConversationID) == "" {
return core.Result{Value: coreerr.E("chat.thinking.start", "conversation id is required", nil), OK: false}
}
s.emit(ActionThinkingStarted{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
StartedAt: input.StartedAt,
})
return core.Result{Value: input, OK: true}
state, err := s.StartThinking(input)
return core.Result{}.New(state, err)
})
c.Action("gui.chat.thinking.append", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[thinkingInput](opts)
@ -416,30 +435,19 @@ func (s *Service) registerActions() {
MessageID: input.MessageID,
Content: input.Content,
})
s.appendThinking(input.ConversationID, input.Content)
return core.Result{Value: input.Content, OK: true}
})
c.Action("gui.chat.thinking.end", func(_ context.Context, opts core.Options) core.Result {
stopThinking := func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[thinkingInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
if strings.TrimSpace(input.ConversationID) == "" {
return core.Result{Value: coreerr.E("chat.thinking.end", "conversation id is required", nil), OK: false}
}
duration := input.DurationMS
if duration == 0 && !input.StartedAt.IsZero() {
duration = time.Since(input.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
}
s.emit(ActionThinkingEnded{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
DurationMS: duration,
})
return core.Result{Value: duration, OK: true}
})
state, err := s.StopThinking(input)
return core.Result{}.New(state, err)
}
c.Action("gui.chat.thinking.stop", stopThinking)
c.Action("gui.chat.thinking.end", stopThinking)
}
func decodeInput[T any](opts core.Options) (T, error) {
@ -483,6 +491,51 @@ func (s *Service) now() time.Time {
return time.Now()
}
func (s *Service) Send(ctx context.Context, input sendInput) (string, error) {
return s.send(ctx, input)
}
func (s *Service) History(conversationID string, limit int) ([]Message, error) {
if limit < 0 {
return nil, coreerr.E("chat.history", "limit must be greater than or equal to zero", nil)
}
conv, err := s.LoadConversation(conversationID)
if err != nil {
return nil, err
}
if limit == 0 || limit >= len(conv.Messages) {
return slices.Clone(conv.Messages), nil
}
return slices.Clone(conv.Messages[len(conv.Messages)-limit:]), nil
}
func (s *Service) Models() []ModelEntry {
models := s.discoverModels()
activeName := s.resolveModel("", s.loadSettings().DefaultModel)
if len(models) == 0 {
return []ModelEntry{{
Name: activeName,
Size: 0,
Status: "active",
Loaded: true,
}}
}
for index := range models {
models[index].Size = models[index].SizeBytes
switch {
case strings.EqualFold(models[index].Name, activeName):
models[index].Loaded = true
models[index].Status = "active"
case models[index].Loaded:
models[index].Status = "loaded"
default:
models[index].Status = "available"
}
}
return models
}
func (s *Service) saveSettings(settings ChatSettings) error {
if err := s.validateSettings(settings); err != nil {
return err
@ -504,12 +557,13 @@ func (s *Service) loadSettings() ChatSettings {
return settings
}
func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) {
if err := s.validateModelName(input.Model); err != nil {
func (s *Service) SelectModel(input selectModelInput) (ChatSettings, error) {
modelName := coalesce(input.Name, input.Model)
if err := s.validateModelName(modelName); err != nil {
return ChatSettings{}, err
}
settings := s.loadSettings()
settings.DefaultModel = input.Model
settings.DefaultModel = modelName
if err := s.saveSettings(settings); err != nil {
return ChatSettings{}, err
}
@ -523,7 +577,7 @@ func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) {
if err != nil {
return ChatSettings{}, err
}
conv.Model = input.Model
conv.Model = modelName
conv, err = s.saveConversation(conv)
if err != nil {
return ChatSettings{}, err
@ -532,6 +586,97 @@ func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) {
return settings, nil
}
func (s *Service) ListConversations() ([]Conversation, error) {
return s.listConversations()
}
func (s *Service) LoadConversation(id string) (Conversation, error) {
return s.getConversation(id, "")
}
func (s *Service) DeleteConversation(id string) error {
return s.deleteConversation(id)
}
func (s *Service) StartThinking(input thinkingInput) (ThinkingState, error) {
if strings.TrimSpace(input.ConversationID) == "" {
return ThinkingState{}, coreerr.E("chat.thinking.start", "conversation id is required", nil)
}
state := ThinkingState{
Active: true,
StartedAt: input.StartedAt,
}
if state.StartedAt.IsZero() {
state.StartedAt = s.now()
}
s.mu.Lock()
s.thinkingStates[input.ConversationID] = state
s.mu.Unlock()
s.emit(ActionThinkingStarted{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
StartedAt: state.StartedAt,
})
return state, nil
}
func (s *Service) StopThinking(input thinkingInput) (ThinkingState, error) {
if strings.TrimSpace(input.ConversationID) == "" {
return ThinkingState{}, coreerr.E("chat.thinking.stop", "conversation id is required", nil)
}
s.mu.Lock()
state, ok := s.thinkingStates[input.ConversationID]
delete(s.thinkingStates, input.ConversationID)
s.mu.Unlock()
if !ok && !input.StartedAt.IsZero() {
state.StartedAt = input.StartedAt
}
if state.StartedAt.IsZero() {
state.StartedAt = s.now()
}
state.Active = false
state.Content = strings.TrimSpace(state.Content)
state.EndedAt = s.now()
state.DurationMS = input.DurationMS
if state.DurationMS == 0 {
state.DurationMS = state.EndedAt.Sub(state.StartedAt).Milliseconds()
if state.DurationMS < 0 {
state.DurationMS = 0
}
}
s.emit(ActionThinkingEnded{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
DurationMS: state.DurationMS,
})
return state, nil
}
func (s *Service) appendThinking(conversationID, content string) {
key := strings.TrimSpace(conversationID)
if key == "" || strings.TrimSpace(content) == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
state := s.thinkingStates[key]
if state.StartedAt.IsZero() {
state.StartedAt = s.now()
}
state.Active = true
state.Content += content
s.thinkingStates[key] = state
}
func (s *Service) saveConversation(conv Conversation) (Conversation, error) {
if err := s.validateConversation(conv); err != nil {
return Conversation{}, err
@ -567,7 +712,7 @@ func (s *Service) loadConversation(id string) (Conversation, error) {
return conv, nil
}
func (s *Service) listConversationSummaries() ([]ConversationSummary, error) {
func (s *Service) listConversations() ([]Conversation, error) {
if s.store == nil {
return nil, nil
}
@ -575,13 +720,30 @@ func (s *Service) listConversationSummaries() ([]ConversationSummary, error) {
if err != nil {
return nil, err
}
summaries := make([]ConversationSummary, 0, len(items))
conversations := make([]Conversation, 0, len(items))
for _, payload := range items {
var conv Conversation
if result := core.JSONUnmarshalString(payload, &conv); result.OK {
summaries = append(summaries, conv.Summary())
conversations = append(conversations, conv)
}
}
sort.Slice(conversations, func(i, j int) bool {
return conversations[i].UpdatedAt.After(conversations[j].UpdatedAt)
})
return conversations, nil
}
func (s *Service) listConversationSummaries() ([]ConversationSummary, error) {
conversations, err := s.listConversations()
if err != nil {
return nil, err
}
summaries := make([]ConversationSummary, 0, len(conversations))
for _, conv := range conversations {
summaries = append(summaries, conv.Summary())
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
@ -851,16 +1013,17 @@ func (s *Service) mergedSettings(global ChatSettings, override *ChatSettings) Ch
return merged
}
func (s *Service) send(ctx context.Context, input sendInput) (Conversation, error) {
func (s *Service) send(ctx context.Context, input sendInput) (string, error) {
if strings.TrimSpace(input.Content) == "" && !s.hasPendingAttachments(input.ConversationID) {
return Conversation{}, coreerr.E("chat.send", "message content is required", nil)
return "", coreerr.E("chat.send", "message content is required", nil)
}
settings := s.loadSettings()
var (
conv Conversation
err error
created bool
conv Conversation
err error
created bool
lastAssistantMessageID string
)
if input.ConversationID != "" {
conv, err = s.loadConversation(input.ConversationID)
@ -869,7 +1032,7 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro
created = true
}
if err != nil {
return Conversation{}, err
return "", err
}
attachments := s.drainAttachments(conv.ID)
@ -878,6 +1041,12 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro
}
now := s.now()
if modelName := strings.TrimSpace(input.Model); modelName != "" {
if err := s.validateModelName(modelName); err != nil {
return "", err
}
conv.Model = modelName
}
conv.Model = s.resolveModel(conv.Model, settings.DefaultModel)
userMessage := ChatMessage{
ID: "msg-" + strconv.FormatInt(now.UnixNano(), 36),
@ -893,7 +1062,7 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro
}
conv, err = s.saveConversation(conv)
if err != nil {
return Conversation{}, err
return "", err
}
s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: userMessage})
s.emit(ActionConversationUpdated{Conversation: conv})
@ -902,18 +1071,19 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro
effectiveSettings := s.mergedSettings(settings, conv.Settings)
conv.Model = s.resolveModel(conv.Model, effectiveSettings.DefaultModel)
if err := s.validateAttachmentsForModel(conv.Model, attachmentsForConversationTurn(conv.Messages)); err != nil {
return conv, err
return "", err
}
assistantMessage, err := s.streamAssistant(ctx, conv, effectiveSettings)
if err != nil {
return conv, err
return "", err
}
lastAssistantMessageID = assistantMessage.ID
if hasRenderableContent(assistantMessage) {
conv.Messages = append(conv.Messages, assistantMessage)
conv, err = s.saveConversation(conv)
if err != nil {
return conv, err
return "", err
}
s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: assistantMessage})
s.emit(ActionConversationUpdated{Conversation: conv})
@ -940,12 +1110,15 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro
}
conv, err = s.saveConversation(conv)
if err != nil {
return conv, err
return "", err
}
s.emit(ActionConversationUpdated{Conversation: conv})
}
return conv, nil
if lastAssistantMessageID == "" {
lastAssistantMessageID = userMessage.ID
}
return lastAssistantMessageID, nil
}
func (s *Service) hasPendingAttachments(conversationID string) bool {
@ -982,6 +1155,7 @@ func (s *Service) streamAssistant(ctx context.Context, conv Conversation, settin
return ChatMessage{}, coreerr.E("chat.streamAssistant", strings.TrimSpace(string(body)), nil)
}
// TODO(mantis-14): switch these callbacks to a dedicated GUI stream group when one exists.
renderer := NewStreamRenderer(StreamCallbacks{
OnStart: func(streamID string) {
s.emit(ActionStreamStarted{ConversationID: conv.ID, MessageID: messageID, StreamID: streamID})
@ -1185,9 +1359,24 @@ func (s *Service) discoverModels() []ModelEntry {
names = append(names, name)
}
slices.Sort(names)
activeModel := strings.TrimSpace(settings.DefaultModel)
if activeModel == "" && len(names) > 0 {
activeModel = names[0]
}
results := make([]ModelEntry, 0, len(names))
for _, name := range names {
results = append(results, models[name])
entry := models[name]
entry.Size = entry.SizeBytes
switch {
case strings.EqualFold(entry.Name, activeModel):
entry.Loaded = true
entry.Status = "active"
case entry.Loaded:
entry.Status = "loaded"
default:
entry.Status = "available"
}
results = append(results, entry)
}
return results
}
@ -1325,11 +1514,13 @@ func discoverModelsOnDisk(root string) []ModelEntry {
for discovered := range inference.Discover(root) {
modelPath := discovered.Path
name := filepath.Base(modelPath)
size := directorySize(modelPath)
results = append(results, ModelEntry{
Name: name,
Size: size,
Architecture: strings.ToLower(discovered.ModelType),
QuantBits: coalesceQuantBits(discovered.QuantBits, quantBitsFromName(name)),
SizeBytes: directorySize(modelPath),
SizeBytes: size,
Backend: "local",
SupportsVision: architectureSupportsVision(discovered.ModelType),
})

View file

@ -0,0 +1,61 @@
package chat
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"time"
core "dappco.re/go/core"
)
func ExampleRegister() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello from chat"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}))
defer server.Close()
storeDir, err := os.MkdirTemp("", "chat-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(storeDir)
c := core.New(
core.WithService(Register(
func(o *Options) { o.APIURL = server.URL },
func(o *Options) { o.StorePath = filepath.Join(storeDir, "chat.db") },
func(o *Options) { o.ToolExecutor = &mockToolExecutor{} },
func(o *Options) { o.Now = func() time.Time { return time.Unix(1_700_000_000, 0).UTC() } },
)),
core.WithServiceLock(),
)
if !c.ServiceStartup(context.Background(), nil).OK {
panic("chat startup failed")
}
send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Hello"},
))
if !send.OK {
panic(send.Value)
}
conversations := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
history := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: conversations.Value.([]Conversation)[0].ID},
))
fmt.Println(len(history.Value.([]Message)))
fmt.Println(history.Value.([]Message)[1].Content)
// Output:
// 2
// Hello from chat
}

View file

@ -7,7 +7,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -71,76 +70,203 @@ func createDiscoveredModelRoot(t *testing.T, name, architecture string) string {
return root
}
func TestService_Good_SendAndHistory(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}, &mockToolExecutor{})
func sequencedNow(start time.Time) func() time.Time {
current := start.Add(-time.Second)
return func() time.Time {
current = current.Add(time.Second)
return current
}
}
func writeSSE(w http.ResponseWriter, payloads ...string) {
w.Header().Set("Content-Type", "text/event-stream")
for _, payload := range payloads {
_, _ = io.WriteString(w, "data: "+payload+"\n\n")
}
}
func latestConversation(t *testing.T, c *core.Core) Conversation {
t.Helper()
result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
conversations, ok := result.Value.([]Conversation)
require.True(t, ok)
require.NotEmpty(t, conversations)
return conversations[0]
}
func historyMessages(t *testing.T, c *core.Core, conversationID string, limit int) []Message {
t.Helper()
options := []core.Option{{
Key: "conversation_id",
Value: conversationID,
}}
if limit > 0 {
options = append(options, core.Option{Key: "limit", Value: limit})
}
result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(options...))
require.True(t, result.OK)
messages, ok := result.Value.([]Message)
require.True(t, ok)
return messages
}
func TestActionSend_Good_ReturnsAssistantMessageID(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3")
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"}}]}`,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":" world"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} })
send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Hi"},
core.Option{Key: "model", Value: "lemma"},
))
require.True(t, send.OK)
conv := send.Value.(Conversation)
messageID, ok := send.Value.(string)
require.True(t, ok)
require.NotEmpty(t, messageID)
conv := latestConversation(t, c)
require.Len(t, conv.Messages, 2)
assert.Equal(t, "user", conv.Messages[0].Role)
assert.Equal(t, "assistant", conv.Messages[1].Role)
assert.Equal(t, messageID, conv.Messages[1].ID)
assert.Equal(t, "lemma", conv.Model)
assert.Equal(t, "Hello world", conv.Messages[1].Content)
history := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: conv.ID},
))
require.True(t, history.OK)
assert.Equal(t, conv.ID, history.Value.(Conversation).ID)
queryHistory := c.QUERY(QueryHistory{ConversationID: conv.ID})
require.True(t, queryHistory.OK)
assert.Equal(t, conv.ID, queryHistory.Value.(Conversation).ID)
}
func TestService_Good_ToolCallRoundTrip(t *testing.T) {
toolExecutor := &mockToolExecutor{}
requests := 0
c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "text/event-stream")
if requests == 1 {
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"layout_suggest\",\"arguments\":\"{\\\"window_count\\\":2}\"}}]}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
return
}
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"delta\":{\"content\":\"Use a left-right split.\"}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}, toolExecutor)
func TestActionSend_Bad_RejectsEmptyMessage(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "message content is required")
}
func TestActionSend_Ugly_PropagatesUpstreamFailure(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "model unavailable", http.StatusBadGateway)
}, &mockToolExecutor{})
result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Hi"},
))
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "model unavailable")
}
func TestActionHistory_Good_HonoursLimit(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Alpha"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{})
send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Arrange these windows"},
core.Option{Key: "content", Value: "One"},
))
require.True(t, send.OK)
conv := send.Value.(Conversation)
require.GreaterOrEqual(t, len(conv.Messages), 4)
require.Len(t, toolExecutor.calls, 1)
assert.Equal(t, "layout_suggest", toolExecutor.calls[0].Name)
assert.Equal(t, 2.0, toolExecutor.calls[0].Arguments["window_count"])
assert.Equal(t, "tool", conv.Messages[2].Role)
assert.True(t, strings.Contains(conv.Messages[len(conv.Messages)-1].Content, "left-right"))
conv := latestConversation(t, c)
history := historyMessages(t, c, conv.ID, 1)
require.Len(t, history, 1)
assert.Equal(t, "assistant", history[0].Role)
assert.Equal(t, "Alpha", history[0].Content)
}
func TestService_Good_SelectModelUpdatesConversation(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
func TestActionHistory_Bad_RequiresConversationID(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "conversation id is required")
}
func TestActionHistory_Ugly_UnknownConversationFails(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "missing"},
))
require.False(t, result.OK)
require.Error(t, result.Value.(error))
}
func TestActionModels_Good_ReportsSizeAndStatus(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3")
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} })
result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
models, ok := result.Value.([]ModelEntry)
require.True(t, ok)
require.Len(t, models, 1)
assert.Equal(t, "lemma", models[0].Name)
assert.Equal(t, int64(4), models[0].Size)
assert.Equal(t, "active", models[0].Status)
}
func TestActionModels_Bad_ReturnsFallbackWhenNothingDiscovered(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
models, ok := result.Value.([]ModelEntry)
require.True(t, ok)
require.Len(t, models, 1)
assert.Equal(t, "default", models[0].Name)
assert.Equal(t, "active", models[0].Status)
}
func TestActionModels_Ugly_ReflectsSelectedModelStatus(t *testing.T) {
rootA := createDiscoveredModelRoot(t, "alpha", "gemma3")
rootB := createDiscoveredModelRoot(t, "beta", "gemma3")
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{rootA, rootB} })
selected := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions(
core.Option{Key: "model", Value: "beta"},
))
require.True(t, selected.OK)
result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
models, ok := result.Value.([]ModelEntry)
require.True(t, ok)
require.Len(t, models, 2)
assert.Equal(t, "available", models[0].Status)
assert.Equal(t, "active", models[1].Status)
}
func TestActionSelectModel_Good_UpdatesConversationAndSettings(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3")
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} })
created := c.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions())
require.True(t, created.OK)
conv := created.Value.(Conversation)
@ -151,80 +277,278 @@ func TestService_Good_SelectModelUpdatesConversation(t *testing.T) {
))
require.True(t, selected.OK)
updated := c.QUERY(QueryConversationGet{ConversationID: conv.ID})
require.True(t, updated.OK)
assert.Equal(t, "lemma", updated.Value.(Conversation).Model)
}
settings := selected.Value.(ChatSettings)
assert.Equal(t, "lemma", settings.DefaultModel)
func TestService_Good_SettingsDefaults(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}, &mockToolExecutor{})
result := c.QUERY(QuerySettingsDefaults{})
require.True(t, result.OK)
assert.Equal(t, DefaultSettings(), result.Value.(ChatSettings))
actionResult := c.Action("gui.chat.settings.defaults").Run(context.Background(), core.Options{})
require.True(t, actionResult.OK)
assert.Equal(t, DefaultSettings(), actionResult.Value.(ChatSettings))
}
func TestService_Bad_SettingsRejectOutOfRangeValues(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}, &mockToolExecutor{})
result := c.Action("gui.chat.settings.save").Run(context.Background(), core.NewOptions(
core.Option{Key: "temperature", Value: float32(2.5)},
core.Option{Key: "top_p", Value: float32(0.95)},
core.Option{Key: "top_k", Value: 64},
core.Option{Key: "max_tokens", Value: 2048},
core.Option{Key: "context_window", Value: 8192},
core.Option{Key: "system_prompt", Value: "You are a helpful assistant."},
loaded := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: conv.ID},
))
require.True(t, loaded.OK)
assert.Equal(t, "lemma", loaded.Value.(Conversation).Model)
}
func TestActionSelectModel_Bad_RequiresModelName(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "temperature must be between 0.0 and 2.0")
assert.Contains(t, result.Value.(error).Error(), "model is required")
}
func TestService_Bad_SelectModelRejectsUnknownModel(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemer", "gemma3")
func TestActionSelectModel_Ugly_RejectsUnknownDiscoveredModel(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3")
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} })
result := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions(
core.Option{Key: "model", Value: "missing-model"},
core.Option{Key: "model", Value: "missing"},
))
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "model is not available")
}
func TestService_Bad_SendImageRejectsNonVisionModel(t *testing.T) {
modelRoot := createDiscoveredModelRoot(t, "lemma", "qwen3")
func TestActionConversationsList_Good_ReturnsNewestFirst(t *testing.T) {
now := sequencedNow(time.Unix(1_700_000_000, 0).UTC())
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} })
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{}, func(o *Options) { o.Now = now })
attach := c.Action("gui.chat.attachImage").Run(context.Background(), core.NewOptions(
core.Option{Key: "filename", Value: "photo.png"},
core.Option{Key: "mime_type", Value: "image/png"},
core.Option{Key: "data", Value: "ZmFrZQ=="},
core.Option{Key: "width", Value: 32},
core.Option{Key: "height", Value: 32},
))
require.True(t, attach.OK)
require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "First"},
)).OK)
require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Second"},
)).OK)
send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Describe this image"},
))
require.False(t, send.OK)
require.Error(t, send.Value.(error))
assert.Contains(t, send.Value.(error).Error(), "does not support image input")
result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
conversations := result.Value.([]Conversation)
require.Len(t, conversations, 2)
assert.Equal(t, "Second", conversations[0].Messages[0].Content)
assert.Equal(t, "First", conversations[1].Messages[0].Content)
}
func TestActionConversationsList_Bad_EmptyStoreReturnsEmptySlice(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
conversations, ok := result.Value.([]Conversation)
require.True(t, ok)
assert.Empty(t, conversations)
}
func TestActionConversationsList_Ugly_IgnoresCorruptRows(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{})
require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Good"},
)).OK)
svc := core.MustServiceFor[*Service](c, "chat")
require.NoError(t, svc.store.Set(conversationsGroup, "broken", "{"))
result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
require.True(t, result.OK)
conversations := result.Value.([]Conversation)
require.Len(t, conversations, 1)
assert.Equal(t, "Good", conversations[0].Messages[0].Content)
}
func TestActionConversationsLoad_Good_ReturnsConversation(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{})
require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Hello"},
)).OK)
conv := latestConversation(t, c)
result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: conv.ID},
))
require.True(t, result.OK)
loaded := result.Value.(Conversation)
require.Len(t, loaded.Messages, 2)
assert.Equal(t, "Reply", loaded.Messages[1].Content)
}
func TestActionConversationsLoad_Bad_RequiresConversationID(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "conversation id is required")
}
func TestActionConversationsLoad_Ugly_UnknownConversationFails(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "missing"},
))
require.False(t, result.OK)
require.Error(t, result.Value.(error))
}
func TestActionConversationsDelete_Good_RemovesConversation(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w,
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`,
`{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`,
`[DONE]`,
)
}, &mockToolExecutor{})
require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "content", Value: "Hello"},
)).OK)
conv := latestConversation(t, c)
deleted := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: conv.ID},
))
require.True(t, deleted.OK)
assert.Equal(t, true, deleted.Value)
listed := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions())
require.True(t, listed.OK)
assert.Empty(t, listed.Value.([]Conversation))
}
func TestActionConversationsDelete_Bad_RequiresConversationID(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "conversation id is required")
}
func TestActionConversationsDelete_Ugly_IsIdempotentForMissingConversation(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "missing"},
))
require.True(t, result.OK)
assert.Equal(t, true, result.Value)
}
func TestActionThinkingStart_Good_ReturnsActiveState(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
))
require.True(t, result.OK)
state := result.Value.(ThinkingState)
assert.True(t, state.Active)
assert.False(t, state.StartedAt.IsZero())
}
func TestActionThinkingStart_Bad_RequiresConversationID(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "conversation id is required")
}
func TestActionThinkingStart_Ugly_RestartReplacesExistingState(t *testing.T) {
now := sequencedNow(time.Unix(1_700_000_000, 0).UTC())
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{}, func(o *Options) { o.Now = now })
first := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
))
second := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
))
require.True(t, first.OK)
require.True(t, second.OK)
assert.True(t, second.Value.(ThinkingState).StartedAt.After(first.Value.(ThinkingState).StartedAt))
}
func TestActionThinkingStop_Good_ClearsThinkingState(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
require.True(t, c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
core.Option{Key: "started_at", Value: time.Unix(1_700_000_000, 0).UTC()},
)).OK)
stopped := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
core.Option{Key: "duration_ms", Value: int64(25)},
))
require.True(t, stopped.OK)
state := stopped.Value.(ThinkingState)
assert.False(t, state.Active)
assert.Equal(t, int64(25), state.DurationMS)
}
func TestActionThinkingStop_Bad_RequiresConversationID(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions())
require.False(t, result.OK)
require.Error(t, result.Value.(error))
assert.Contains(t, result.Value.(error).Error(), "conversation id is required")
}
func TestActionThinkingStop_Ugly_AllowsStopWithoutStart(t *testing.T) {
c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) {
writeSSE(w, `[DONE]`)
}, &mockToolExecutor{})
result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: "conv-1"},
))
require.True(t, result.OK)
state := result.Value.(ThinkingState)
assert.False(t, state.Active)
assert.True(t, state.DurationMS >= 0)
}

View file

@ -676,8 +676,10 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result {
return c.Action("gui.chat.settings.reset").Run(ctx, wsOptions(msg.Data))
case "chat:conversations:list":
return c.Action("gui.chat.conversations.list").Run(ctx, wsOptions(msg.Data))
case "chat:conversations:load":
return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data))
case "chat:conversations:get":
return c.Action("gui.chat.conversations.get").Run(ctx, wsOptions(msg.Data))
return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data))
case "chat:conversations:delete":
return c.Action("gui.chat.conversations.delete").Run(ctx, wsOptions(msg.Data))
case "chat:conversations:search":
@ -700,8 +702,10 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result {
return c.Action("gui.chat.thinking.start").Run(ctx, wsOptions(msg.Data))
case "chat:thinking:append":
return c.Action("gui.chat.thinking.append").Run(ctx, wsOptions(msg.Data))
case "chat:thinking:stop":
return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data))
case "chat:thinking:end":
return c.Action("gui.chat.thinking.end").Run(ctx, wsOptions(msg.Data))
return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data))
case "marketplace:list":
return c.Action("display.marketplace.list").Run(ctx, wsOptions(msg.Data))
case "marketplace:fetch":

View file

@ -1,4 +1,3 @@
// pkg/mcp/tools_chat.go
package mcp
import (
@ -24,14 +23,16 @@ type ChatMessage struct {
FinishReason string `json:"finish_reason,omitempty"`
}
type ChatModelEntry struct {
type ChatModel struct {
Name string `json:"name"`
Architecture string `json:"architecture"`
QuantBits int `json:"quant_bits"`
SizeBytes int64 `json:"size_bytes"`
Loaded bool `json:"loaded"`
Backend string `json:"backend"`
SupportsVision bool `json:"supports_vision"`
Size int64 `json:"size"`
Status string `json:"status"`
Architecture string `json:"architecture,omitempty"`
QuantBits int `json:"quant_bits,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Loaded bool `json:"loaded,omitempty"`
Backend string `json:"backend,omitempty"`
SupportsVision bool `json:"supports_vision,omitempty"`
}
type ChatSettings struct {
@ -54,15 +55,6 @@ type ChatConversation struct {
Settings *ChatSettings `json:"settings,omitempty"`
}
type ChatConversationSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MessageCount int `json:"message_count"`
}
type ChatThinkingState struct {
Active bool `json:"active"`
Content string `json:"content"`
@ -90,99 +82,68 @@ type ChatImageAttachment struct {
Height int `json:"height"`
}
// --- chat_send ---
type ChatSendInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Content string `json:"content"`
Model string `json:"model,omitempty"`
}
type ChatSendOutput struct {
Conversation ChatConversation `json:"conversation"`
MessageID string `json:"message_id"`
}
func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatSendOutput, error) {
result := s.core.Action("gui.chat.send").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "content", Value: input.Content},
core.Option{Key: "model", Value: input.Model},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatSendOutput{}, err
}
return nil, ChatSendOutput{}, nil
return nil, ChatSendOutput{}, coreerr.E("mcp.chatSend", "chat send failed", nil)
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatSendOutput{}, err
messageID, ok := result.Value.(string)
if !ok {
return nil, ChatSendOutput{}, coreerr.E("mcp.chatSend", "unexpected result type", nil)
}
return nil, ChatSendOutput{Conversation: conversation}, nil
return nil, ChatSendOutput{MessageID: messageID}, nil
}
// --- chat_clear ---
type ChatClearInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
}
type ChatClearOutput struct {
Conversation ChatConversation `json:"conversation"`
}
func (s *Subsystem) chatClear(_ context.Context, _ *mcp.CallToolRequest, input ChatClearInput) (*mcp.CallToolResult, ChatClearOutput, error) {
result := s.core.Action("gui.chat.clear").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "id", Value: input.ID},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatClearOutput{}, err
}
return nil, ChatClearOutput{}, nil
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatClearOutput{}, err
}
return nil, ChatClearOutput{Conversation: conversation}, nil
}
// --- chat_history ---
type ChatHistoryInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
Limit int `json:"limit,omitempty"`
}
type ChatHistoryOutput struct {
Conversation ChatConversation `json:"conversation"`
Messages []ChatMessage `json:"messages"`
}
func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, error) {
result := s.core.Action("gui.chat.history").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "id", Value: input.ID},
core.Option{Key: "limit", Value: input.Limit},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatHistoryOutput{}, err
}
return nil, ChatHistoryOutput{}, nil
return nil, ChatHistoryOutput{}, coreerr.E("mcp.chatHistory", "chat history failed", nil)
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
messages, err := decodeChatValue[[]ChatMessage](result.Value)
if err != nil {
return nil, ChatHistoryOutput{}, err
}
return nil, ChatHistoryOutput{Conversation: conversation}, nil
return nil, ChatHistoryOutput{Messages: messages}, nil
}
// --- chat_models ---
type ChatModelsInput struct{}
type ChatModelsOutput struct {
Models []ChatModelEntry `json:"models"`
Models []ChatModel `json:"models"`
}
func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ ChatModelsInput) (*mcp.CallToolResult, ChatModelsOutput, error) {
@ -191,19 +152,18 @@ func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ Chat
if err, ok := result.Value.(error); ok {
return nil, ChatModelsOutput{}, err
}
return nil, ChatModelsOutput{}, nil
return nil, ChatModelsOutput{}, coreerr.E("mcp.chatModels", "chat models failed", nil)
}
models, err := decodeChatValue[[]ChatModelEntry](result.Value)
models, err := decodeChatValue[[]ChatModel](result.Value)
if err != nil {
return nil, ChatModelsOutput{}, err
}
return nil, ChatModelsOutput{Models: models}, nil
}
// --- chat_select_model ---
type ChatSelectModelInput struct {
Model string `json:"model"`
Name string `json:"name,omitempty"`
Model string `json:"model,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
}
@ -214,6 +174,7 @@ type ChatSelectModelOutput struct {
func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, input ChatSelectModelInput) (*mcp.CallToolResult, ChatSelectModelOutput, error) {
result := s.core.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions(
core.Option{Key: "name", Value: input.Name},
core.Option{Key: "model", Value: input.Model},
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "id", Value: input.ID},
@ -222,7 +183,7 @@ func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, i
if err, ok := result.Value.(error); ok {
return nil, ChatSelectModelOutput{}, err
}
return nil, ChatSelectModelOutput{}, nil
return nil, ChatSelectModelOutput{}, coreerr.E("mcp.chatSelectModel", "select model failed", nil)
}
settings, err := decodeChatValue[ChatSettings](result.Value)
if err != nil {
@ -231,112 +192,10 @@ func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, i
return nil, ChatSelectModelOutput{Settings: settings}, nil
}
// --- chat_settings_save ---
type ChatSettingsSaveInput ChatSettings
type ChatSettingsSaveOutput struct {
Settings ChatSettings `json:"settings"`
}
func (s *Subsystem) chatSettingsSave(_ context.Context, _ *mcp.CallToolRequest, input ChatSettingsSaveInput) (*mcp.CallToolResult, ChatSettingsSaveOutput, error) {
result := s.core.Action("gui.chat.settings.save").Run(context.Background(), core.NewOptions(
core.Option{Key: "temperature", Value: input.Temperature},
core.Option{Key: "top_p", Value: input.TopP},
core.Option{Key: "top_k", Value: input.TopK},
core.Option{Key: "max_tokens", Value: input.MaxTokens},
core.Option{Key: "context_window", Value: input.ContextWindow},
core.Option{Key: "system_prompt", Value: input.SystemPrompt},
core.Option{Key: "default_model", Value: input.DefaultModel},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatSettingsSaveOutput{}, err
}
return nil, ChatSettingsSaveOutput{}, nil
}
settings, err := decodeChatValue[ChatSettings](result.Value)
if err != nil {
return nil, ChatSettingsSaveOutput{}, err
}
return nil, ChatSettingsSaveOutput{Settings: settings}, nil
}
// --- chat_settings_load ---
type ChatSettingsLoadInput struct{}
type ChatSettingsLoadOutput struct {
Settings ChatSettings `json:"settings"`
}
func (s *Subsystem) chatSettingsLoad(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsLoadInput) (*mcp.CallToolResult, ChatSettingsLoadOutput, error) {
result := s.core.Action("gui.chat.settings.load").Run(context.Background(), core.NewOptions())
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatSettingsLoadOutput{}, err
}
return nil, ChatSettingsLoadOutput{}, nil
}
settings, err := decodeChatValue[ChatSettings](result.Value)
if err != nil {
return nil, ChatSettingsLoadOutput{}, err
}
return nil, ChatSettingsLoadOutput{Settings: settings}, nil
}
// --- chat_settings_defaults ---
type ChatSettingsDefaultsInput struct{}
type ChatSettingsDefaultsOutput struct {
Settings ChatSettings `json:"settings"`
}
func (s *Subsystem) chatSettingsDefaults(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsDefaultsInput) (*mcp.CallToolResult, ChatSettingsDefaultsOutput, error) {
result := s.core.Action("gui.chat.settings.defaults").Run(context.Background(), core.NewOptions())
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatSettingsDefaultsOutput{}, err
}
return nil, ChatSettingsDefaultsOutput{}, nil
}
settings, err := decodeChatValue[ChatSettings](result.Value)
if err != nil {
return nil, ChatSettingsDefaultsOutput{}, err
}
return nil, ChatSettingsDefaultsOutput{Settings: settings}, nil
}
// --- chat_settings_reset ---
type ChatSettingsResetInput struct{}
type ChatSettingsResetOutput struct {
Settings ChatSettings `json:"settings"`
}
func (s *Subsystem) chatSettingsReset(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsResetInput) (*mcp.CallToolResult, ChatSettingsResetOutput, error) {
result := s.core.Action("gui.chat.settings.reset").Run(context.Background(), core.NewOptions())
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatSettingsResetOutput{}, err
}
return nil, ChatSettingsResetOutput{}, nil
}
settings, err := decodeChatValue[ChatSettings](result.Value)
if err != nil {
return nil, ChatSettingsResetOutput{}, err
}
return nil, ChatSettingsResetOutput{Settings: settings}, nil
}
// --- chat_conversations_list ---
type ChatConversationsListInput struct{}
type ChatConversationsListOutput struct {
Conversations []ChatConversationSummary `json:"conversations"`
Conversations []ChatConversation `json:"conversations"`
}
func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsListInput) (*mcp.CallToolResult, ChatConversationsListOutput, error) {
@ -345,46 +204,42 @@ func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequ
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsListOutput{}, err
}
return nil, ChatConversationsListOutput{}, nil
return nil, ChatConversationsListOutput{}, coreerr.E("mcp.chatConversationsList", "list conversations failed", nil)
}
conversations, err := decodeChatValue[[]ChatConversationSummary](result.Value)
conversations, err := decodeChatValue[[]ChatConversation](result.Value)
if err != nil {
return nil, ChatConversationsListOutput{}, err
}
return nil, ChatConversationsListOutput{Conversations: conversations}, nil
}
// --- chat_conversations_get ---
type ChatConversationsGetInput struct {
type ChatConversationsLoadInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
}
type ChatConversationsGetOutput struct {
type ChatConversationsLoadOutput struct {
Conversation ChatConversation `json:"conversation"`
}
func (s *Subsystem) chatConversationsGet(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsGetInput) (*mcp.CallToolResult, ChatConversationsGetOutput, error) {
result := s.core.Action("gui.chat.conversations.get").Run(context.Background(), core.NewOptions(
func (s *Subsystem) chatConversationsLoad(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsLoadInput) (*mcp.CallToolResult, ChatConversationsLoadOutput, error) {
result := s.core.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "id", Value: input.ID},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsGetOutput{}, err
return nil, ChatConversationsLoadOutput{}, err
}
return nil, ChatConversationsGetOutput{}, nil
return nil, ChatConversationsLoadOutput{}, coreerr.E("mcp.chatConversationsLoad", "load conversation failed", nil)
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatConversationsGetOutput{}, err
return nil, ChatConversationsLoadOutput{}, err
}
return nil, ChatConversationsGetOutput{Conversation: conversation}, nil
return nil, ChatConversationsLoadOutput{Conversation: conversation}, nil
}
// --- chat_conversations_delete ---
type ChatConversationsDeleteInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
@ -403,218 +258,15 @@ func (s *Subsystem) chatConversationsDelete(_ context.Context, _ *mcp.CallToolRe
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsDeleteOutput{}, err
}
return nil, ChatConversationsDeleteOutput{}, nil
return nil, ChatConversationsDeleteOutput{}, coreerr.E("mcp.chatConversationsDelete", "delete conversation failed", nil)
}
return nil, ChatConversationsDeleteOutput{Success: true}, nil
}
// --- chat_conversations_search ---
type ChatConversationsSearchInput struct {
Query string `json:"q"`
}
type ChatConversationsSearchOutput struct {
Conversations []ChatConversationSummary `json:"conversations"`
}
func (s *Subsystem) chatConversationsSearch(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsSearchInput) (*mcp.CallToolResult, ChatConversationsSearchOutput, error) {
result := s.core.Action("gui.chat.conversations.search").Run(context.Background(), core.NewOptions(
core.Option{Key: "q", Value: input.Query},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsSearchOutput{}, err
}
return nil, ChatConversationsSearchOutput{}, nil
}
conversations, err := decodeChatValue[[]ChatConversationSummary](result.Value)
if err != nil {
return nil, ChatConversationsSearchOutput{}, err
}
return nil, ChatConversationsSearchOutput{Conversations: conversations}, nil
}
// --- chat_conversations_new ---
type ChatConversationsNewInput struct{}
type ChatConversationsNewOutput struct {
Conversation ChatConversation `json:"conversation"`
}
func (s *Subsystem) chatConversationsNew(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsNewInput) (*mcp.CallToolResult, ChatConversationsNewOutput, error) {
result := s.core.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions())
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsNewOutput{}, err
}
return nil, ChatConversationsNewOutput{}, nil
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatConversationsNewOutput{}, err
}
return nil, ChatConversationsNewOutput{Conversation: conversation}, nil
}
// --- chat_conversation_save ---
type ChatConversationSaveInput ChatConversation
type ChatConversationSaveOutput struct {
Conversation ChatConversation `json:"conversation"`
}
func (s *Subsystem) chatConversationSave(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationSaveInput) (*mcp.CallToolResult, ChatConversationSaveOutput, error) {
result := s.core.Action("gui.chat.conversation.save").Run(context.Background(), core.NewOptions(
core.Option{Key: "id", Value: input.ID},
core.Option{Key: "title", Value: input.Title},
core.Option{Key: "model", Value: input.Model},
core.Option{Key: "created_at", Value: input.CreatedAt},
core.Option{Key: "updated_at", Value: input.UpdatedAt},
core.Option{Key: "messages", Value: input.Messages},
core.Option{Key: "settings", Value: input.Settings},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationSaveOutput{}, err
}
return nil, ChatConversationSaveOutput{}, nil
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatConversationSaveOutput{}, err
}
return nil, ChatConversationSaveOutput{Conversation: conversation}, nil
}
// --- chat_conversations_rename ---
type ChatConversationsRenameInput struct {
ID string `json:"id"`
Title string `json:"title"`
}
type ChatConversationsRenameOutput struct {
Conversation ChatConversation `json:"conversation"`
}
func (s *Subsystem) chatConversationsRename(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsRenameInput) (*mcp.CallToolResult, ChatConversationsRenameOutput, error) {
result := s.core.Action("gui.chat.conversations.rename").Run(context.Background(), core.NewOptions(
core.Option{Key: "id", Value: input.ID},
core.Option{Key: "title", Value: input.Title},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsRenameOutput{}, err
}
return nil, ChatConversationsRenameOutput{}, nil
}
conversation, err := decodeChatValue[ChatConversation](result.Value)
if err != nil {
return nil, ChatConversationsRenameOutput{}, err
}
return nil, ChatConversationsRenameOutput{Conversation: conversation}, nil
}
// --- chat_conversations_export ---
type ChatConversationsExportInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
}
type ChatConversationsExportOutput struct {
Markdown string `json:"markdown"`
}
func (s *Subsystem) chatConversationsExport(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsExportInput) (*mcp.CallToolResult, ChatConversationsExportOutput, error) {
result := s.core.Action("gui.chat.conversations.export").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "id", Value: input.ID},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatConversationsExportOutput{}, err
}
return nil, ChatConversationsExportOutput{}, nil
}
markdown, ok := result.Value.(string)
success, ok := result.Value.(bool)
if !ok {
return nil, ChatConversationsExportOutput{}, coreerr.E("mcp.chatConversationsExport", "unexpected result type", nil)
return nil, ChatConversationsDeleteOutput{}, coreerr.E("mcp.chatConversationsDelete", "unexpected result type", nil)
}
return nil, ChatConversationsExportOutput{Markdown: markdown}, nil
return nil, ChatConversationsDeleteOutput{Success: success}, nil
}
// --- chat_attach_image ---
type ChatAttachImageInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
Width int `json:"width"`
Height int `json:"height"`
}
type ChatAttachImageOutput struct {
Attachment ChatImageAttachment `json:"attachment"`
}
func (s *Subsystem) chatAttachImage(_ context.Context, _ *mcp.CallToolRequest, input ChatAttachImageInput) (*mcp.CallToolResult, ChatAttachImageOutput, error) {
result := s.core.Action("gui.chat.attachImage").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "filename", Value: input.Filename},
core.Option{Key: "mime_type", Value: input.MimeType},
core.Option{Key: "data", Value: input.Data},
core.Option{Key: "width", Value: input.Width},
core.Option{Key: "height", Value: input.Height},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatAttachImageOutput{}, err
}
return nil, ChatAttachImageOutput{}, nil
}
attachment, err := decodeChatValue[ChatImageAttachment](result.Value)
if err != nil {
return nil, ChatAttachImageOutput{}, err
}
return nil, ChatAttachImageOutput{Attachment: attachment}, nil
}
// --- chat_remove_image ---
type ChatRemoveImageInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Index int `json:"index"`
}
type ChatRemoveImageOutput struct {
Attachment ChatImageAttachment `json:"attachment"`
}
func (s *Subsystem) chatRemoveImage(_ context.Context, _ *mcp.CallToolRequest, input ChatRemoveImageInput) (*mcp.CallToolResult, ChatRemoveImageOutput, error) {
result := s.core.Action("gui.chat.removeImage").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "index", Value: input.Index},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatRemoveImageOutput{}, err
}
return nil, ChatRemoveImageOutput{}, nil
}
attachment, err := decodeChatValue[ChatImageAttachment](result.Value)
if err != nil {
return nil, ChatRemoveImageOutput{}, err
}
return nil, ChatRemoveImageOutput{Attachment: attachment}, nil
}
// --- chat_thinking_start ---
type ChatThinkingStartInput struct {
ConversationID string `json:"conversation_id"`
MessageID string `json:"message_id,omitempty"`
@ -622,11 +274,7 @@ type ChatThinkingStartInput struct {
}
type ChatThinkingStartOutput struct {
ConversationID string `json:"conversation_id"`
MessageID string `json:"message_id,omitempty"`
Content string `json:"content,omitempty"`
StartedAt time.Time `json:"started_at"`
DurationMS int64 `json:"duration_ms,omitempty"`
State ChatThinkingState `json:"state"`
}
func (s *Subsystem) chatThinkingStart(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStartInput) (*mcp.CallToolResult, ChatThinkingStartOutput, error) {
@ -639,124 +287,93 @@ func (s *Subsystem) chatThinkingStart(_ context.Context, _ *mcp.CallToolRequest,
if err, ok := result.Value.(error); ok {
return nil, ChatThinkingStartOutput{}, err
}
return nil, ChatThinkingStartOutput{}, nil
return nil, ChatThinkingStartOutput{}, coreerr.E("mcp.chatThinkingStart", "thinking start failed", nil)
}
output, err := decodeChatValue[ChatThinkingStartOutput](result.Value)
state, err := decodeChatValue[ChatThinkingState](result.Value)
if err != nil {
return nil, ChatThinkingStartOutput{}, err
}
return nil, output, nil
return nil, ChatThinkingStartOutput{State: state}, nil
}
// --- chat_thinking_append ---
type ChatThinkingAppendInput struct {
ConversationID string `json:"conversation_id"`
MessageID string `json:"message_id,omitempty"`
Content string `json:"content"`
}
type ChatThinkingAppendOutput struct {
Content string `json:"content"`
}
func (s *Subsystem) chatThinkingAppend(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingAppendInput) (*mcp.CallToolResult, ChatThinkingAppendOutput, error) {
result := s.core.Action("gui.chat.thinking.append").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "message_id", Value: input.MessageID},
core.Option{Key: "content", Value: input.Content},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatThinkingAppendOutput{}, err
}
return nil, ChatThinkingAppendOutput{}, nil
}
content, ok := result.Value.(string)
if !ok {
return nil, ChatThinkingAppendOutput{}, coreerr.E("mcp.chatThinkingAppend", "unexpected result type", nil)
}
return nil, ChatThinkingAppendOutput{Content: content}, nil
}
// --- chat_thinking_end ---
type ChatThinkingEndInput struct {
type ChatThinkingStopInput struct {
ConversationID string `json:"conversation_id"`
MessageID string `json:"message_id,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
}
type ChatThinkingEndOutput struct {
DurationMS int64 `json:"duration_ms"`
type ChatThinkingStopOutput struct {
State ChatThinkingState `json:"state"`
}
func (s *Subsystem) chatThinkingEnd(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingEndInput) (*mcp.CallToolResult, ChatThinkingEndOutput, error) {
result := s.core.Action("gui.chat.thinking.end").Run(context.Background(), core.NewOptions(
func (s *Subsystem) chatThinkingStop(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStopInput) (*mcp.CallToolResult, ChatThinkingStopOutput, error) {
result := s.core.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions(
core.Option{Key: "conversation_id", Value: input.ConversationID},
core.Option{Key: "message_id", Value: input.MessageID},
core.Option{Key: "duration_ms", Value: input.DurationMS},
core.Option{Key: "started_at", Value: input.StartedAt},
core.Option{Key: "duration_ms", Value: input.DurationMS},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return nil, ChatThinkingEndOutput{}, err
return nil, ChatThinkingStopOutput{}, err
}
return nil, ChatThinkingEndOutput{}, nil
return nil, ChatThinkingStopOutput{}, coreerr.E("mcp.chatThinkingStop", "thinking stop failed", nil)
}
durationMS, ok := result.Value.(int64)
if !ok {
return nil, ChatThinkingEndOutput{}, coreerr.E("mcp.chatThinkingEnd", "unexpected result type", nil)
state, err := decodeChatValue[ChatThinkingState](result.Value)
if err != nil {
return nil, ChatThinkingStopOutput{}, err
}
return nil, ChatThinkingEndOutput{DurationMS: durationMS}, nil
return nil, ChatThinkingStopOutput{State: state}, nil
}
func decodeChatValue[T any](value any) (T, error) {
var output T
result := core.JSONUnmarshalString(core.JSONMarshalString(value), &output)
if !result.OK {
if err, ok := result.Value.(error); ok {
return output, err
}
return output, coreerr.E("mcp.decodeChatValue", "failed to decode chat value", nil)
if result.OK {
return output, nil
}
return output, nil
if err, ok := result.Value.(error); ok {
return output, err
}
return output, coreerr.E("mcp.decodeChatValue", "failed to decode chat value", nil)
}
func (s *Subsystem) registerChatTools(server *mcp.Server) {
addTool(s, server, &mcp.Tool{
Name: "chat_send",
Description: `Send a chat message and stream a reply through the chat service. Example: {"conversation_id":"conv-1","content":"Hello"}`,
Description: `Send a chat message and return the streamed assistant message id. Example: {"conversation_id":"conv-1","content":"Hello","model":"lemma"}`,
}, s.chatSend)
addTool(s, server, &mcp.Tool{
Name: "chat_clear",
Description: `Clear a conversation's message history. Example: {"id":"conv-1"}`,
}, s.chatClear)
addTool(s, server, &mcp.Tool{
Name: "chat_history",
Description: `Get a conversation and its message history. Example: {"id":"conv-1"}`,
Description: `Read chat message history for a conversation. Example: {"conversation_id":"conv-1","limit":20}`,
}, s.chatHistory)
addTool(s, server, &mcp.Tool{Name: "chat_models", Description: "List available chat models"}, s.chatModels)
addTool(s, server, &mcp.Tool{
Name: "chat_models",
Description: "List available chat models with size and status metadata",
}, s.chatModels)
addTool(s, server, &mcp.Tool{
Name: "chat_select_model",
Description: `Select the active chat model for the current settings or conversation. Example: {"model":"lemer","conversation_id":"conv-1"}`,
Description: `Set the active chat model. Example: {"name":"lemma","conversation_id":"conv-1"}`,
}, s.chatSelectModel)
addTool(s, server, &mcp.Tool{Name: "chat_settings_save", Description: "Persist chat settings"}, s.chatSettingsSave)
addTool(s, server, &mcp.Tool{Name: "chat_settings_load", Description: "Load persisted chat settings"}, s.chatSettingsLoad)
addTool(s, server, &mcp.Tool{Name: "chat_settings_defaults", Description: "Return the default chat settings"}, s.chatSettingsDefaults)
addTool(s, server, &mcp.Tool{Name: "chat_settings_reset", Description: "Reset chat settings to defaults"}, s.chatSettingsReset)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_list", Description: "List stored conversations"}, s.chatConversationsList)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_get", Description: "Get a stored conversation by id"}, s.chatConversationsGet)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_delete", Description: "Delete a stored conversation"}, s.chatConversationsDelete)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_search", Description: "Search stored conversations by text"}, s.chatConversationsSearch)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_new", Description: "Create a new conversation"}, s.chatConversationsNew)
addTool(s, server, &mcp.Tool{Name: "chat_conversation_save", Description: "Save a conversation object"}, s.chatConversationSave)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_rename", Description: "Rename a conversation"}, s.chatConversationsRename)
addTool(s, server, &mcp.Tool{Name: "chat_conversations_export", Description: "Export a conversation as Markdown"}, s.chatConversationsExport)
addTool(s, server, &mcp.Tool{Name: "chat_attach_image", Description: "Queue an image attachment for the next chat message"}, s.chatAttachImage)
addTool(s, server, &mcp.Tool{Name: "chat_remove_image", Description: "Remove a queued image attachment"}, s.chatRemoveImage)
addTool(s, server, &mcp.Tool{Name: "chat_thinking_start", Description: "Begin a thinking state for a conversation"}, s.chatThinkingStart)
addTool(s, server, &mcp.Tool{Name: "chat_thinking_append", Description: "Append thinking content for a conversation"}, s.chatThinkingAppend)
addTool(s, server, &mcp.Tool{Name: "chat_thinking_end", Description: "End a thinking state for a conversation"}, s.chatThinkingEnd)
addTool(s, server, &mcp.Tool{
Name: "chat_conversations_list",
Description: "List stored chat conversations",
}, s.chatConversationsList)
addTool(s, server, &mcp.Tool{
Name: "chat_conversations_load",
Description: `Load a stored chat conversation by id. Example: {"conversation_id":"conv-1"}`,
}, s.chatConversationsLoad)
addTool(s, server, &mcp.Tool{
Name: "chat_conversations_delete",
Description: `Delete a stored chat conversation. Example: {"conversation_id":"conv-1"}`,
}, s.chatConversationsDelete)
addTool(s, server, &mcp.Tool{
Name: "chat_thinking_start",
Description: `Mark a conversation as thinking. Example: {"conversation_id":"conv-1"}`,
}, s.chatThinkingStart)
addTool(s, server, &mcp.Tool{
Name: "chat_thinking_stop",
Description: `Clear the thinking state for a conversation. Example: {"conversation_id":"conv-1"}`,
}, s.chatThinkingStop)
}