Implement missing RFC chat backend features

This commit is contained in:
Claude 2026-04-14 14:51:19 +01:00
parent 4f1863f9eb
commit c38711ec7d
2 changed files with 622 additions and 10 deletions

View file

@ -1,6 +1,7 @@
package display
import (
"encoding/json"
"sort"
"strings"
"sync"
@ -34,6 +35,7 @@ type ChatSettings struct {
}
type ImageAttachment struct {
ID string `json:"id,omitempty"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
@ -68,13 +70,15 @@ type ThinkingState struct {
}
type ChatMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Attachments []ImageAttachment `json:"attachments,omitempty"`
Thinking *ThinkingState `json:"thinking,omitempty"`
ToolCalls []ToolInvocation `json:"tool_calls,omitempty"`
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Streaming bool `json:"streaming,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Attachments []ImageAttachment `json:"attachments,omitempty"`
Thinking *ThinkingState `json:"thinking,omitempty"`
ToolCalls []ToolInvocation `json:"tool_calls,omitempty"`
}
type Conversation struct {
@ -219,8 +223,21 @@ func (s *ChatStore) Load(cfg *config.Config) {
if n := parseCounter(msg.ID); n > s.nextID {
s.nextID = n
}
for _, attachment := range msg.Attachments {
if n := parseCounter(attachment.ID); n > s.nextID {
s.nextID = n
}
}
}
}
for _, attachments := range s.queuedImages {
for _, attachment := range attachments {
if n := parseCounter(attachment.ID); n > s.nextID {
s.nextID = n
}
}
}
s.ensureAttachmentIdentifiersLocked()
}
func (s *ChatStore) Snapshot() ChatSnapshot {
@ -404,7 +421,7 @@ func (s *ChatStore) History(conversationID string) ([]ChatMessage, error) {
if !ok {
return nil, coreerr.E("display.chat.History", "conversation not found: "+conversationID, nil)
}
return append([]ChatMessage(nil), conv.Messages...), nil
return cloneMessages(conv.Messages), nil
}
func (s *ChatStore) ClearConversation(conversationID string) (Conversation, error) {
@ -432,10 +449,45 @@ func (s *ChatStore) QueueImage(conversationID string, attachment ImageAttachment
if _, ok := s.conversations[conversationID]; !ok {
return nil, coreerr.E("display.chat.QueueImage", "conversation not found: "+conversationID, nil)
}
if attachment.ID == "" {
attachment.ID = s.nextIdentifier("img")
}
s.queuedImages[conversationID] = append(s.queuedImages[conversationID], attachment)
return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil
}
func (s *ChatStore) RemoveQueuedImage(conversationID, attachmentID string) ([]ImageAttachment, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.conversations[conversationID]; !ok {
return nil, coreerr.E("display.chat.RemoveQueuedImage", "conversation not found: "+conversationID, nil)
}
if attachmentID == "" {
return nil, coreerr.E("display.chat.RemoveQueuedImage", "attachment id is required", nil)
}
attachments := s.queuedImages[conversationID]
filtered := attachments[:0]
removed := false
for _, attachment := range attachments {
if attachment.ID == attachmentID {
removed = true
continue
}
filtered = append(filtered, attachment)
}
if !removed {
return nil, coreerr.E("display.chat.RemoveQueuedImage", "attachment not found: "+attachmentID, nil)
}
if len(filtered) == 0 {
delete(s.queuedImages, conversationID)
return []ImageAttachment{}, nil
}
s.queuedImages[conversationID] = append([]ImageAttachment(nil), filtered...)
return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil
}
func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, ChatMessage, ChatMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -461,6 +513,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
Role: "assistant",
Content: buildAssistantPlaceholder(conv.Model, content),
CreatedAt: now.Add(250 * time.Millisecond),
Streaming: false,
}
if thinking, ok := s.thinking[conversationID]; ok && strings.TrimSpace(thinking.Content) != "" {
copyThinking := thinking
@ -485,6 +538,106 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
return cloneConversation(conv), userMessage, assistantMessage, nil
}
func (s *ChatStore) StartStreaming(conversationID string) (Conversation, ChatMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
conv, ok := s.conversations[conversationID]
if !ok {
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.StartStreaming", "conversation not found: "+conversationID, nil)
}
now := time.Now().UTC()
var assistantMessage *ChatMessage
if canReuseAssistantForStreaming(conv) {
assistantMessage = &conv.Messages[len(conv.Messages)-1]
assistantMessage.Content = ""
assistantMessage.Streaming = true
assistantMessage.FinishReason = ""
if thinking, ok := s.thinking[conversationID]; ok {
copyThinking := thinking
assistantMessage.Thinking = &copyThinking
}
} else {
message := ChatMessage{
ID: s.nextIdentifier("msg"),
Role: "assistant",
Content: "",
CreatedAt: now,
Streaming: true,
}
if thinking, ok := s.thinking[conversationID]; ok {
copyThinking := thinking
message.Thinking = &copyThinking
}
conv.Messages = append(conv.Messages, message)
assistantMessage = &conv.Messages[len(conv.Messages)-1]
}
s.streamingMessage[conversationID] = assistantMessage.Content
conv.UpdatedAt = now
s.conversations[conversationID] = conv
return cloneConversation(conv), cloneMessage(*assistantMessage), nil
}
func (s *ChatStore) AppendStreaming(conversationID, content string) (Conversation, ChatMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
conv, ok := s.conversations[conversationID]
if !ok {
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.AppendStreaming", "conversation not found: "+conversationID, nil)
}
index := latestStreamingAssistantIndex(conv.Messages)
if index < 0 {
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.AppendStreaming", "streaming assistant message not found: "+conversationID, nil)
}
conv.Messages[index].Content += content
if thinking, ok := s.thinking[conversationID]; ok {
copyThinking := thinking
conv.Messages[index].Thinking = &copyThinking
}
s.streamingMessage[conversationID] = conv.Messages[index].Content
conv.UpdatedAt = time.Now().UTC()
s.conversations[conversationID] = conv
return cloneConversation(conv), cloneMessage(conv.Messages[index]), nil
}
func (s *ChatStore) FinishStreaming(conversationID, finishReason string) (Conversation, ChatMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
conv, ok := s.conversations[conversationID]
if !ok {
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.FinishStreaming", "conversation not found: "+conversationID, nil)
}
index := latestStreamingAssistantIndex(conv.Messages)
if index < 0 {
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.FinishStreaming", "streaming assistant message not found: "+conversationID, nil)
}
conv.Messages[index].Streaming = false
conv.Messages[index].FinishReason = strings.TrimSpace(finishReason)
if thinking, ok := s.thinking[conversationID]; ok {
copyThinking := thinking
if copyThinking.Active {
copyThinking.Active = false
if copyThinking.FinishedAt.IsZero() {
copyThinking.FinishedAt = time.Now().UTC()
}
s.thinking[conversationID] = copyThinking
}
conv.Messages[index].Thinking = &copyThinking
}
delete(s.streamingMessage, conversationID)
conv.UpdatedAt = time.Now().UTC()
s.conversations[conversationID] = conv
return cloneConversation(conv), cloneMessage(conv.Messages[index]), nil
}
func (s *ChatStore) StartThinking(conversationID string) (ThinkingState, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -497,6 +650,7 @@ func (s *ChatStore) StartThinking(conversationID string) (ThinkingState, error)
StartedAt: time.Now().UTC(),
}
s.thinking[conversationID] = state
s.syncThinkingToStreamingMessageLocked(conversationID, state)
return state, nil
}
@ -511,6 +665,7 @@ func (s *ChatStore) AppendThinking(conversationID, content string) (ThinkingStat
state.Active = true
state.Content += content
s.thinking[conversationID] = state
s.syncThinkingToStreamingMessageLocked(conversationID, state)
return state, nil
}
@ -525,6 +680,7 @@ func (s *ChatStore) EndThinking(conversationID string) (ThinkingState, error) {
state.Active = false
state.FinishedAt = time.Now().UTC()
s.thinking[conversationID] = state
s.syncThinkingToStreamingMessageLocked(conversationID, state)
return state, nil
}
@ -552,6 +708,103 @@ func (s *ChatStore) RecordToolResult(conversationID string, call ToolCall, resul
return cloneConversation(conv), nil
}
func (s *ChatStore) RenameConversation(id, title string) (Conversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
conv, ok := s.conversations[id]
if !ok {
return Conversation{}, coreerr.E("display.chat.RenameConversation", "conversation not found: "+id, nil)
}
cleanTitle := strings.TrimSpace(title)
if cleanTitle == "" {
cleanTitle = "Untitled conversation"
}
conv.Title = cleanTitle
conv.UpdatedAt = time.Now().UTC()
s.conversations[id] = conv
return cloneConversation(conv), nil
}
func (s *ChatStore) ExportConversationMarkdown(id string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
conv, ok := s.conversations[id]
if !ok {
return "", coreerr.E("display.chat.ExportConversationMarkdown", "conversation not found: "+id, nil)
}
var builder strings.Builder
builder.WriteString("# ")
builder.WriteString(conv.Title)
builder.WriteString("\n\n")
builder.WriteString("- Conversation ID: ")
builder.WriteString(conv.ID)
builder.WriteString("\n")
builder.WriteString("- Model: ")
builder.WriteString(conv.Model)
builder.WriteString("\n")
builder.WriteString("- Updated: ")
builder.WriteString(conv.UpdatedAt.UTC().Format(time.RFC3339))
builder.WriteString("\n")
for _, message := range conv.Messages {
builder.WriteString("\n## ")
builder.WriteString(roleHeading(message.Role))
builder.WriteString("\n\n")
builder.WriteString(message.Content)
builder.WriteString("\n")
if len(message.Attachments) > 0 {
builder.WriteString("\n### Attachments\n")
for _, attachment := range message.Attachments {
builder.WriteString("- ")
builder.WriteString(attachment.Filename)
builder.WriteString(" (")
builder.WriteString(attachment.MimeType)
builder.WriteString(")\n")
}
}
if message.Thinking != nil && strings.TrimSpace(message.Thinking.Content) != "" {
builder.WriteString("\n### Thinking\n\n")
builder.WriteString(message.Thinking.Content)
builder.WriteString("\n")
}
if len(message.ToolCalls) > 0 {
builder.WriteString("\n### Tool Calls\n")
for _, invocation := range message.ToolCalls {
builder.WriteString("\n#### ")
builder.WriteString(invocation.Call.Name)
builder.WriteString("\n\n")
if len(invocation.Call.Arguments) > 0 {
arguments, err := json.MarshalIndent(invocation.Call.Arguments, "", " ")
if err == nil {
builder.WriteString("```json\n")
builder.Write(arguments)
builder.WriteString("\n```\n")
}
}
if invocation.Result.Content != "" {
builder.WriteString("\n")
builder.WriteString(invocation.Result.Content)
builder.WriteString("\n")
}
if invocation.Error != "" {
builder.WriteString("\nError: ")
builder.WriteString(invocation.Error)
builder.WriteString("\n")
}
}
}
}
return builder.String(), nil
}
type QueryChatHistory struct {
ConversationID string `json:"conversation_id"`
}
@ -589,17 +842,31 @@ type QueryConversationsSearch struct {
Query string `json:"q"`
}
type QueryConversationExport struct {
ID string `json:"id"`
}
type TaskConversationDelete struct {
ID string `json:"id"`
}
type TaskConversationNew struct{}
type TaskConversationRename struct {
ID string `json:"id"`
Title string `json:"title"`
}
type TaskAttachImage struct {
ConversationID string `json:"conversation_id"`
Attachment ImageAttachment `json:"attachment"`
}
type TaskDetachImage struct {
ConversationID string `json:"conversation_id"`
AttachmentID string `json:"attachment_id"`
}
type TaskThinkingStart struct {
ConversationID string `json:"conversation_id"`
}
@ -620,6 +887,20 @@ type TaskRecordToolCall struct {
Error string `json:"error,omitempty"`
}
type TaskChatStreamStart struct {
ConversationID string `json:"conversation_id"`
}
type TaskChatStreamAppend struct {
ConversationID string `json:"conversation_id"`
Content string `json:"content"`
}
type TaskChatStreamFinish struct {
ConversationID string `json:"conversation_id"`
FinishReason string `json:"finish_reason,omitempty"`
}
func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryChatHistory:
@ -639,6 +920,9 @@ func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error)
return conv, true, nil
case QueryConversationsSearch:
return s.chat.SearchConversations(q.Query), true, nil
case QueryConversationExport:
content, err := s.chat.ExportConversationMarkdown(q.ID)
return content, true, err
default:
return nil, false, nil
}
@ -649,6 +933,21 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
case TaskConversationNew:
conv := s.chat.NewConversation()
return conv, true, s.chat.persist(s.configFile)
case TaskConversationRename:
conv, err := s.chat.RenameConversation(t.ID, t.Title)
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.conversation.renamed",
Data: map[string]any{
"conversation_id": conv.ID,
"title": conv.Title,
},
})
}
return conv, true, s.chat.persist(s.configFile)
case TaskChatSend:
conv, userMessage, assistantMessage, err := s.chat.SendMessage(t.ConversationID, t.Content)
if err != nil {
@ -694,6 +993,30 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.attachments.updated",
Data: map[string]any{
"conversation_id": t.ConversationID,
"attachments": attachments,
},
})
}
return attachments, true, s.chat.persist(s.configFile)
case TaskDetachImage:
attachments, err := s.chat.RemoveQueuedImage(t.ConversationID, t.AttachmentID)
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.attachments.updated",
Data: map[string]any{
"conversation_id": t.ConversationID,
"attachments": attachments,
},
})
}
return attachments, true, s.chat.persist(s.configFile)
case TaskThinkingStart:
state, err := s.chat.StartThinking(t.ConversationID)
@ -719,6 +1042,53 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
return nil, true, err
}
return conv, true, s.chat.persist(s.configFile)
case TaskChatStreamStart:
conv, message, err := s.chat.StartStreaming(t.ConversationID)
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.stream.start",
Data: map[string]any{
"conversation_id": t.ConversationID,
"message": message,
},
})
}
return conv, true, s.chat.persist(s.configFile)
case TaskChatStreamAppend:
conv, message, err := s.chat.AppendStreaming(t.ConversationID, t.Content)
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.stream.delta",
Data: map[string]any{
"conversation_id": t.ConversationID,
"message": message,
"delta": t.Content,
},
})
}
return conv, true, s.chat.persist(s.configFile)
case TaskChatStreamFinish:
conv, message, err := s.chat.FinishStreaming(t.ConversationID, t.FinishReason)
if err != nil {
return nil, true, err
}
if s.events != nil {
s.events.Emit(Event{
Type: "chat.stream.end",
Data: map[string]any{
"conversation_id": t.ConversationID,
"message": message,
"finish_reason": t.FinishReason,
},
})
}
return conv, true, s.chat.persist(s.configFile)
default:
return nil, false, nil
}
@ -748,6 +1118,35 @@ func buildAssistantPlaceholder(model, prompt string) string {
"Captured your prompt for " + model + " and stored it in chat history."
}
func roleHeading(role string) string {
if role == "" {
return "Message"
}
lower := strings.ToLower(role)
return strings.ToUpper(lower[:1]) + lower[1:]
}
func canReuseAssistantForStreaming(conv Conversation) bool {
if len(conv.Messages) == 0 {
return false
}
last := conv.Messages[len(conv.Messages)-1]
if last.Role != "assistant" {
return false
}
if last.Streaming {
return true
}
if len(conv.Messages) < 2 {
return false
}
previous := conv.Messages[len(conv.Messages)-2]
if previous.Role != "user" {
return false
}
return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content)
}
func parseCounter(value string) uint64 {
if idx := strings.LastIndex(value, "-"); idx >= 0 && idx < len(value)-1 {
value = value[idx+1:]
@ -791,7 +1190,44 @@ func cloneConversationMap(src map[string]Conversation) map[string]Conversation {
func cloneConversation(conv Conversation) Conversation {
clone := conv
clone.Messages = append([]ChatMessage(nil), conv.Messages...)
clone.Messages = cloneMessages(conv.Messages)
return clone
}
func cloneMessages(messages []ChatMessage) []ChatMessage {
clones := make([]ChatMessage, 0, len(messages))
for _, message := range messages {
clones = append(clones, cloneMessage(message))
}
return clones
}
func cloneMessage(message ChatMessage) ChatMessage {
clone := message
clone.Attachments = append([]ImageAttachment(nil), message.Attachments...)
if message.Thinking != nil {
copyThinking := *message.Thinking
clone.Thinking = &copyThinking
}
if len(message.ToolCalls) > 0 {
clone.ToolCalls = make([]ToolInvocation, 0, len(message.ToolCalls))
for _, invocation := range message.ToolCalls {
clone.ToolCalls = append(clone.ToolCalls, ToolInvocation{
Call: ToolCall{
ID: invocation.Call.ID,
Name: invocation.Call.Name,
Arguments: cloneAnyMap(invocation.Call.Arguments),
},
Result: ToolResult{
ToolCallID: invocation.Result.ToolCallID,
Content: invocation.Result.Content,
},
StartedAt: invocation.StartedAt,
EndedAt: invocation.EndedAt,
Error: invocation.Error,
})
}
}
return clone
}
@ -818,3 +1254,63 @@ func cloneStringMap(src map[string]string) map[string]string {
}
return dst
}
func cloneAnyMap(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
dst := make(map[string]any, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
func latestStreamingAssistantIndex(messages []ChatMessage) int {
for index := len(messages) - 1; index >= 0; index-- {
if messages[index].Role == "assistant" && messages[index].Streaming {
return index
}
}
return -1
}
func (s *ChatStore) syncThinkingToStreamingMessageLocked(conversationID string, state ThinkingState) {
conv, ok := s.conversations[conversationID]
if !ok {
return
}
index := latestStreamingAssistantIndex(conv.Messages)
if index < 0 {
return
}
copyThinking := state
conv.Messages[index].Thinking = &copyThinking
s.conversations[conversationID] = conv
}
func (s *ChatStore) ensureAttachmentIdentifiersLocked() {
for conversationID, attachments := range s.queuedImages {
for index := range attachments {
if attachments[index].ID == "" {
attachments[index].ID = s.nextIdentifier("img")
}
}
s.queuedImages[conversationID] = attachments
}
for conversationID, conv := range s.conversations {
changed := false
for messageIndex := range conv.Messages {
for attachmentIndex := range conv.Messages[messageIndex].Attachments {
if conv.Messages[messageIndex].Attachments[attachmentIndex].ID != "" {
continue
}
conv.Messages[messageIndex].Attachments[attachmentIndex].ID = s.nextIdentifier("img")
changed = true
}
}
if changed {
s.conversations[conversationID] = conv
}
}
}

View file

@ -30,7 +30,31 @@ func TestChatLifecycle_Good(t *testing.T) {
conv := convResult.(Conversation)
require.NotEmpty(t, conv.ID)
_, handled, err = c.PERFORM(TaskAttachImage{
updatedAttachments, handled, err := c.PERFORM(TaskAttachImage{
ConversationID: conv.ID,
Attachment: ImageAttachment{
Filename: "diagram.png",
MimeType: "image/png",
Data: "ZmFrZQ==",
Width: 640,
Height: 480,
},
})
require.NoError(t, err)
require.True(t, handled)
attachments := updatedAttachments.([]ImageAttachment)
require.Len(t, attachments, 1)
require.NotEmpty(t, attachments[0].ID)
remainingResult, handled, err := c.PERFORM(TaskDetachImage{
ConversationID: conv.ID,
AttachmentID: attachments[0].ID,
})
require.NoError(t, err)
require.True(t, handled)
assert.Empty(t, remainingResult.([]ImageAttachment))
updatedAttachments, handled, err = c.PERFORM(TaskAttachImage{
ConversationID: conv.ID,
Attachment: ImageAttachment{
Filename: "diagram.png",
@ -81,6 +105,22 @@ func TestChatLifecycle_Good(t *testing.T) {
require.True(t, handled)
require.Len(t, searchResult.([]Conversation), 1)
renamedResult, handled, err := c.PERFORM(TaskConversationRename{
ID: conv.ID,
Title: "Local inference notes",
})
require.NoError(t, err)
require.True(t, handled)
assert.Equal(t, "Local inference notes", renamedResult.(Conversation).Title)
exportedResult, handled, err := c.QUERY(QueryConversationExport{ID: conv.ID})
require.NoError(t, err)
require.True(t, handled)
exported := exportedResult.(string)
assert.Contains(t, exported, "# Local inference notes")
assert.Contains(t, exported, "## User")
assert.Contains(t, exported, "diagram.png")
settingsResult, handled, err := c.PERFORM(TaskChatSettingsSave{
Settings: ChatSettings{
Temperature: 0.7,
@ -105,6 +145,82 @@ func TestChatLifecycle_Good(t *testing.T) {
require.NoError(t, svc.chat.persist(svc.configFile))
}
func TestChatStreamingLifecycle_Good(t *testing.T) {
_, c := newTestDisplayService(t)
convResult, handled, err := c.PERFORM(TaskConversationNew{})
require.NoError(t, err)
require.True(t, handled)
conv := convResult.(Conversation)
_, handled, err = c.PERFORM(TaskChatSend{
ConversationID: conv.ID,
Content: "Stream the answer instead.",
})
require.NoError(t, err)
require.True(t, handled)
startResult, handled, err := c.PERFORM(TaskChatStreamStart{ConversationID: conv.ID})
require.NoError(t, err)
require.True(t, handled)
started := startResult.(Conversation)
require.Len(t, started.Messages, 2)
assert.True(t, started.Messages[1].Streaming)
assert.Empty(t, started.Messages[1].Content)
_, handled, err = c.PERFORM(TaskThinkingStart{ConversationID: conv.ID})
require.NoError(t, err)
require.True(t, handled)
_, handled, err = c.PERFORM(TaskThinkingAppend{
ConversationID: conv.ID,
Content: "Streaming through the local bridge.",
})
require.NoError(t, err)
require.True(t, handled)
appendResult, handled, err := c.PERFORM(TaskChatStreamAppend{
ConversationID: conv.ID,
Content: "Hello",
})
require.NoError(t, err)
require.True(t, handled)
appended := appendResult.(Conversation)
require.Len(t, appended.Messages, 2)
assert.Equal(t, "Hello", appended.Messages[1].Content)
if assert.NotNil(t, appended.Messages[1].Thinking) {
assert.Contains(t, appended.Messages[1].Thinking.Content, "local bridge")
}
appendResult, handled, err = c.PERFORM(TaskChatStreamAppend{
ConversationID: conv.ID,
Content: " world",
})
require.NoError(t, err)
require.True(t, handled)
appended = appendResult.(Conversation)
assert.Equal(t, "Hello world", appended.Messages[1].Content)
finishResult, handled, err := c.PERFORM(TaskChatStreamFinish{
ConversationID: conv.ID,
FinishReason: "stop",
})
require.NoError(t, err)
require.True(t, handled)
finished := finishResult.(Conversation)
require.Len(t, finished.Messages, 2)
assert.False(t, finished.Messages[1].Streaming)
assert.Equal(t, "stop", finished.Messages[1].FinishReason)
historyResult, handled, err := c.QUERY(QueryChatHistory{ConversationID: conv.ID})
require.NoError(t, err)
require.True(t, handled)
history := historyResult.([]ChatMessage)
require.Len(t, history, 2)
assert.Equal(t, "Hello world", history[1].Content)
assert.False(t, history[1].Streaming)
}
func TestChatPersistence_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui.yaml")