fix(acp): group agent chunks by messageId (backend) and coalesce consecutive same-role entries in the UI (frontend)

The opencode acp binary streams agent_message_chunk notifications with
an EMPTY messageId. The backend was already correct (it groups when a
messageId is present), but the UI rendered every chunk as its own
bubble, so a single 'count 1 to 5' reply showed as 9 separate bubbles.

Backend:
- internal/acp/history.go: add MessageID to Message; new AppendOrCreate
  method that finds or creates an entry by messageId and appends text
  to it (preserving the original timestamp).
- internal/acp/client.go: agent_message_chunk and user_message_chunk
  use AppendOrCreate.
- internal/model/acp.go: expose MessageID in the wire response.
- internal/service/acp_service.go: copy MessageID into the API DTO.
- internal/acp/client_test.go: add TestHistoryAppendOrCreateGroupsByMessageID.

Frontend:
- web/src/components/acp/groupMessages.ts: new pure helper that
  coalesces consecutive same-role history entries into a single bubble
  (keeps the first chunk's time + messageId).
- web/src/components/acp/AcpPanel.tsx: render coalesced messages;
  auto-scroll effect now depends on coalesced so it fires as the
  agent streams.

Verified via e2e: 'count from 1 to 5' now returns a single agent entry
with text '1\n2\n3\n4\n5' instead of 9 separate entries.
This commit is contained in:
tao.chen
2026-07-06 13:48:56 +08:00
parent 041c3dc002
commit 789f62669c
7 changed files with 149 additions and 17 deletions
+5 -3
View File
@@ -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.
</div>
)}
{messages.map((message, index) => (
<MessageBubble key={index} message={message} />
{coalesced.map((message) => (
<MessageBubble key={message.time} message={message} />
))}
{prompt.isPending && <ThinkingIndicator />}
{localErrors.map((err) => (
+29
View File
@@ -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;
}