diff --git a/internal/acp/client.go b/internal/acp/client.go index 8388bf4..5ef360b 100644 --- a/internal/acp/client.go +++ b/internal/acp/client.go @@ -238,13 +238,13 @@ func (c *Client) handleNotification(method string, params json.RawMessage) { switch up.Update.SessionUpdate { case "agent_message_chunk": if up.Update.Content.Type == "text" { - c.history.Add("agent", up.Update.Content.Text) + c.history.AppendOrCreate("agent", up.Update.MessageID, up.Update.Content.Text) } else { c.lg.Debug("acp drop non-text agent chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type) } case "user_message_chunk": if up.Update.Content.Type == "text" { - c.history.Add("user", up.Update.Content.Text) + c.history.AppendOrCreate("user", up.Update.MessageID, up.Update.Content.Text) } else { c.lg.Debug("acp drop non-text user chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type) } diff --git a/internal/acp/client_test.go b/internal/acp/client_test.go index ef51077..fd1408a 100644 --- a/internal/acp/client_test.go +++ b/internal/acp/client_test.go @@ -276,3 +276,78 @@ func TestAgentRequestReadFile(t *testing.T) { t.Fatalf("content = %q, want 'package main\\n'", res.Content) } } + +func TestHistoryAppendOrCreateGroupsByMessageID(t *testing.T) { + h := NewHistory() + + // 1. First chunk for a new messageId creates an entry. + time.Sleep(10 * time.Millisecond) + beforeFirst := time.Now() + time.Sleep(10 * time.Millisecond) + firstTime := h.AppendOrCreate("agent", "msg-1", "hello") + time.Sleep(10 * time.Millisecond) + afterFirst := time.Now() + + if firstTime.Before(beforeFirst) || firstTime.After(afterFirst) { + t.Fatalf("first chunk timestamp %v out of range [%v, %v]", firstTime, beforeFirst, afterFirst) + } + msgs := h.List() + if len(msgs) != 1 { + t.Fatalf("after first chunk: len = %d, want 1", len(msgs)) + } + if msgs[0].Role != "agent" || msgs[0].Text != "hello" || msgs[0].MessageID != "msg-1" { + t.Fatalf("first chunk message = %+v, want role=agent text=hello messageId=msg-1", msgs[0]) + } + + // 2. Second chunk for the same messageId appends and preserves timestamp. + time.Sleep(10 * time.Millisecond) + secondTime := h.AppendOrCreate("agent", "msg-1", " world") + if !secondTime.Equal(firstTime) { + t.Fatalf("second chunk timestamp changed: got %v, want %v", secondTime, firstTime) + } + msgs = h.List() + if len(msgs) != 1 { + t.Fatalf("after second chunk: len = %d, want 1", len(msgs)) + } + if msgs[0].Text != "hello world" { + t.Fatalf("after second chunk: text = %q, want \"hello world\"", msgs[0].Text) + } + + // 3. A different messageId creates a new entry. + h.AppendOrCreate("agent", "msg-2", "second") + msgs = h.List() + if len(msgs) != 2 { + t.Fatalf("after different id: len = %d, want 2", len(msgs)) + } + if msgs[1].Text != "second" || msgs[1].MessageID != "msg-2" { + t.Fatalf("second message = %+v, want text=second messageId=msg-2", msgs[1]) + } + + // 4. Empty messageId always appends a new entry and never collides. + h.AppendOrCreate("user", "", "a") + h.AppendOrCreate("user", "", "b") + msgs = h.List() + if len(msgs) != 4 { + t.Fatalf("after two empty-id chunks: len = %d, want 4", len(msgs)) + } + if msgs[2].Text != "a" || msgs[2].MessageID != "" { + t.Fatalf("empty-id message[2] = %+v, want text=a messageId=\"\"", msgs[2]) + } + if msgs[3].Text != "b" || msgs[3].MessageID != "" { + t.Fatalf("empty-id message[3] = %+v, want text=b messageId=\"\"", msgs[3]) + } + + // 5. Clear resets the index map so the same id can be reused. + h.Clear() + if h.Len() != 0 { + t.Fatalf("after Clear: len = %d, want 0", h.Len()) + } + h.AppendOrCreate("agent", "msg-1", "after clear") + msgs = h.List() + if len(msgs) != 1 { + t.Fatalf("after clear + chunk: len = %d, want 1", len(msgs)) + } + if msgs[0].Text != "after clear" || msgs[0].MessageID != "msg-1" { + t.Fatalf("after clear message = %+v, want text=\"after clear\" messageId=msg-1", msgs[0]) + } +} diff --git a/internal/acp/history.go b/internal/acp/history.go index 98866cc..ac7b4d2 100644 --- a/internal/acp/history.go +++ b/internal/acp/history.go @@ -7,27 +7,51 @@ import ( // Message is one entry in the conversation history. type Message struct { - Role string `json:"role"` - Text string `json:"text"` - Time time.Time `json:"time"` + Role string `json:"role"` + Text string `json:"text"` + Time time.Time `json:"time"` + MessageID string `json:"messageId,omitempty"` } // History accumulates prompt/response messages in memory for a session. type History struct { - mu sync.RWMutex - messages []Message + mu sync.RWMutex + messages []Message + indexByID map[string]int } // NewHistory creates an empty History. func NewHistory() *History { - return &History{} + return &History{indexByID: make(map[string]int)} } -// Add appends a message with the current time. +// Add appends a message with the current time. It is used for messages that +// have no messageId (e.g. the user prompt path). func (h *History) Add(role, text string) { + h.AppendOrCreate(role, "", text) +} + +// AppendOrCreate finds the existing entry with the same messageId and appends +// text to it; otherwise creates a new entry. When messageId is empty this +// behaves like Add(role, text). It returns the time of the created or existing +// entry. +func (h *History) AppendOrCreate(role, messageId, text string) time.Time { h.mu.Lock() defer h.mu.Unlock() - h.messages = append(h.messages, Message{Role: role, Text: text, Time: time.Now()}) + + if messageId == "" { + h.messages = append(h.messages, Message{Role: role, Text: text, Time: time.Now()}) + return h.messages[len(h.messages)-1].Time + } + + if idx, ok := h.indexByID[messageId]; ok { + h.messages[idx].Text += text + return h.messages[idx].Time + } + + h.messages = append(h.messages, Message{Role: role, Text: text, Time: time.Now(), MessageID: messageId}) + h.indexByID[messageId] = len(h.messages) - 1 + return h.messages[len(h.messages)-1].Time } // List returns a copy of all messages, oldest first. @@ -66,4 +90,5 @@ func (h *History) Clear() { h.mu.Lock() defer h.mu.Unlock() h.messages = h.messages[:0] + h.indexByID = make(map[string]int) } diff --git a/internal/model/acp.go b/internal/model/acp.go index af20d58..16c8151 100644 --- a/internal/model/acp.go +++ b/internal/model/acp.go @@ -14,9 +14,10 @@ type AcpStatusResponse struct { // AcpMessage is a single role/text/time entry in the ACP history. type AcpMessage struct { - Role string `json:"role"` - Text string `json:"text"` - Time time.Time `json:"time"` + Role string `json:"role"` + Text string `json:"text"` + Time time.Time `json:"time"` + MessageID string `json:"messageId,omitempty"` } // AcpHistoryResponse is the API response for ACP history. diff --git a/internal/service/acp_service.go b/internal/service/acp_service.go index 8578f4e..d5d2135 100644 --- a/internal/service/acp_service.go +++ b/internal/service/acp_service.go @@ -50,7 +50,7 @@ func (s *AcpService) History(workspaceID string) (model.AcpHistoryResponse, erro } messages := make([]model.AcpMessage, len(hist.Messages)) for i, m := range hist.Messages { - messages[i] = model.AcpMessage{Role: m.Role, Text: m.Text, Time: m.Time} + messages[i] = model.AcpMessage{Role: m.Role, Text: m.Text, Time: m.Time, MessageID: m.MessageID} } return model.AcpHistoryResponse{SessionID: hist.SessionID, Messages: messages}, nil } diff --git a/web/src/components/acp/AcpPanel.tsx b/web/src/components/acp/AcpPanel.tsx index ecc39e2..4f97c4d 100644 --- a/web/src/components/acp/AcpPanel.tsx +++ b/web/src/components/acp/AcpPanel.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./MessageBubble"; import { ThinkingIndicator } from "./ThinkingIndicator"; +import { groupMessages } from "./groupMessages"; import { useAcpCancel, @@ -82,6 +83,7 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { () => acpHistory.data?.messages ?? [], [acpHistory.data], ); + const coalesced = useMemo(() => groupMessages(messages), [messages]); useEffect(() => { if (prompt.error) { @@ -112,7 +114,7 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { if (distanceFromBottom <= 80) { sentinel.scrollIntoView({ behavior: "smooth", block: "end" }); } - }, [messages, prompt.isPending]); + }, [coalesced, prompt.isPending]); const handleSend = () => { const content = input.trim(); @@ -167,8 +169,8 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { Send a message to start the conversation. )} - {messages.map((message, index) => ( - + {coalesced.map((message) => ( + ))} {prompt.isPending && } {localErrors.map((err) => ( diff --git a/web/src/components/acp/groupMessages.ts b/web/src/components/acp/groupMessages.ts new file mode 100644 index 0000000..8fb189d --- /dev/null +++ b/web/src/components/acp/groupMessages.ts @@ -0,0 +1,29 @@ +import type { AcpMessage } from "@/lib/api/acp"; + +export interface CoalescedMessage { + role: "user" | "agent"; + text: string; + time: string; + messageId?: string; +} + +export function groupMessages(messages: AcpMessage[]): CoalescedMessage[] { + const result: CoalescedMessage[] = []; + + for (const message of messages) { + const last = result[result.length - 1]; + + if (last && last.role === message.role) { + last.text += message.text; + } else { + result.push({ + role: message.role, + text: message.text, + time: message.time, + messageId: (message as AcpMessage & { messageId?: string }).messageId, + }); + } + } + + return result; +}