This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/internal/acp/client_test.go
T
tao.chen 789f62669c 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.
2026-07-06 13:48:56 +08:00

354 lines
9.3 KiB
Go

package acp
import (
"bytes"
"encoding/json"
"log/slog"
"os"
"strings"
"sync"
"testing"
"time"
"codespace/internal/fs"
)
// fakeSubscription implements process.Subscription for tests.
type fakeSubscription struct {
ch chan []byte
once sync.Once
}
func newFakeSubscription() *fakeSubscription {
return &fakeSubscription{ch: make(chan []byte, 16)}
}
func (s *fakeSubscription) Output() <-chan []byte {
return s.ch
}
func (s *fakeSubscription) send(b []byte) {
s.ch <- b
}
func (s *fakeSubscription) Close() error {
s.once.Do(func() { close(s.ch) })
return nil
}
// recordingWriteCloser captures every byte written to it.
type recordingWriteCloser struct {
mu sync.Mutex
buf bytes.Buffer
closed bool
}
func (w *recordingWriteCloser) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Write(p)
}
func (w *recordingWriteCloser) Close() error {
w.closed = true
return nil
}
func (w *recordingWriteCloser) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// stubFS implements fs.FileSystem for read/write tests.
type stubFS struct {
mu sync.Mutex
files map[string]string
}
func newStubFS() *stubFS {
return &stubFS{files: make(map[string]string)}
}
func (s *stubFS) Read(path string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.files[path]; ok {
return []byte(data), nil
}
return nil, os.ErrNotExist
}
func (s *stubFS) Write(path string, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.files[path] = string(data)
return nil
}
func (s *stubFS) List(path string) ([]fs.FileInfo, error) { return nil, nil }
func (s *stubFS) Mkdir(path string) error { return nil }
func (s *stubFS) Remove(path string) error { return nil }
func (s *stubFS) Rename(oldPath, newPath string) error { return nil }
func (s *stubFS) Stat(path string) (*fs.FileInfo, error) { return nil, nil }
func TestNDJSONFramingHandlesSplitLines(t *testing.T) {
sub := newFakeSubscription()
stdin := &recordingWriteCloser{}
tr := newTransport(stdin, sub, slog.Default())
tr.start()
defer tr.Close()
got := make(chan struct {
method string
content string
}, 2)
tr.onNotification = func(method string, params json.RawMessage) {
if method != "session/update" {
return
}
var up SessionUpdateParams
if err := json.Unmarshal(params, &up); err != nil {
t.Errorf("unmarshal notification: %v", err)
return
}
got <- struct {
method string
content string
}{method, up.Update.Content.Text}
}
line1 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}`
line2 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" world"}}}}`
// Send one complete line plus a partial second line.
sub.send([]byte(line1 + "\n" + line2[:len(line2)-2]))
select {
case n := <-got:
if n.content != "hello" {
t.Fatalf("first content = %q, want hello", n.content)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for first notification")
}
// Complete the second line.
sub.send([]byte(line2[len(line2)-2:] + "\n"))
select {
case n := <-got:
if n.content != " world" {
t.Fatalf("second content = %q, want ' world'", n.content)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for second notification")
}
}
func TestRequestResponseCorrelation(t *testing.T) {
sub := newFakeSubscription()
stdin := &recordingWriteCloser{}
tr := newTransport(stdin, sub, slog.Default())
tr.start()
defer tr.Close()
ch := make(chan *jsonRPCMessage, 1)
tr.pendingMu.Lock()
tr.pending[1] = ch
tr.pendingMu.Unlock()
sub.send([]byte(`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}` + "\n"))
select {
case resp := <-ch:
if resp.ID == nil || *resp.ID != 1 {
t.Fatalf("id = %v, want 1", resp.ID)
}
var res SessionNewResult
if err := json.Unmarshal(resp.Result, &res); err != nil {
t.Fatal(err)
}
if res.SessionID != "sess-1" {
t.Fatalf("sessionId = %q, want sess-1", res.SessionID)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for response")
}
}
func TestNotificationDispatch(t *testing.T) {
sub := newFakeSubscription()
stdin := &recordingWriteCloser{}
tr := newTransport(stdin, sub, slog.Default())
tr.start()
defer tr.Close()
got := make(chan string, 1)
tr.onNotification = func(method string, params json.RawMessage) {
got <- method
}
sub.send([]byte(`{"jsonrpc":"2.0","method":"ping"}` + "\n"))
select {
case m := <-got:
if m != "ping" {
t.Fatalf("method = %q, want ping", m)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for notification")
}
}
func TestAgentRequestTerminalReturnsMethodNotFound(t *testing.T) {
c := &Client{
workspaceID: "ws",
fs: newStubFS(),
history: NewHistory(),
lg: slog.Default(),
}
stdin := &recordingWriteCloser{}
tr := newTransport(stdin, newFakeSubscription(), slog.Default())
c.transport = tr
c.ready = true
c.sessionID = "s1"
c.handleRequest("terminal/create", json.RawMessage(`{}`), 7)
line := strings.TrimSpace(stdin.String())
if line == "" {
t.Fatal("expected a response to be written")
}
var resp jsonRPCMessage
if err := json.Unmarshal([]byte(line), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.ID == nil || *resp.ID != 7 {
t.Fatalf("id = %v, want 7", resp.ID)
}
if resp.Error == nil {
t.Fatal("expected error response")
}
if resp.Error.Code != -32601 {
t.Fatalf("error code = %d, want -32601", resp.Error.Code)
}
}
func TestAgentRequestReadFile(t *testing.T) {
stub := newStubFS()
stub.files["main.go"] = "package main\n"
c := &Client{
workspaceID: "ws",
fs: stub,
history: NewHistory(),
lg: slog.Default(),
}
stdin := &recordingWriteCloser{}
tr := newTransport(stdin, newFakeSubscription(), slog.Default())
c.transport = tr
c.ready = true
c.sessionID = "s1"
params, _ := json.Marshal(FSReadParams{Path: "main.go", SessionID: "s1"})
c.handleRequest("fs/read_text_file", params, 3)
line := strings.TrimSpace(stdin.String())
if line == "" {
t.Fatal("expected a response to be written")
}
var resp jsonRPCMessage
if err := json.Unmarshal([]byte(line), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.ID == nil || *resp.ID != 3 {
t.Fatalf("id = %v, want 3", resp.ID)
}
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
var res FSReadResult
if err := json.Unmarshal(resp.Result, &res); err != nil {
t.Fatal(err)
}
if res.Content != "package main\n" {
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])
}
}