You've already forked agentic-coding-workflow
740 lines
23 KiB
Go
740 lines
23 KiB
Go
// relay_test.go: integration tests for relay.go using io.Pipe pairs to
|
|
// simulate the agent subprocess (no real exec). Tests cover:
|
|
// - Server-initiated Call round-trip (int id) + event persistence
|
|
// - Notification fanout to subscribers
|
|
// - Agent-initiated fs/read_text_file dispatched to real FsHandler
|
|
// - Slow subscriber gets disconnected with final envelope
|
|
package acp_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
|
)
|
|
|
|
// fakeEventRepo only implements InsertEvent meaningfully; everything else
|
|
// panics. Sufficient for relay tests, which only exercise event persistence.
|
|
type fakeEventRepo struct {
|
|
mu sync.Mutex
|
|
events []*acp.Event
|
|
|
|
// 回合状态机:relay turn 测试用。
|
|
turns []*acp.Turn
|
|
lastStopReason *string
|
|
|
|
// 成本核算:relay usage 测试用。
|
|
usageLedger []*acp.SessionUsageRecord
|
|
totPrompt int64
|
|
totComplete int64
|
|
totThink int64
|
|
totCost float64
|
|
dedupSources map[int64]struct{}
|
|
}
|
|
|
|
func (f *fakeEventRepo) InsertEvent(_ context.Context, e *acp.Event) (*acp.Event, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
cp := *e
|
|
cp.ID = int64(len(f.events) + 1)
|
|
cp.CreatedAt = time.Now()
|
|
f.events = append(f.events, &cp)
|
|
out := cp
|
|
return &out, nil
|
|
}
|
|
|
|
func (f *fakeEventRepo) snapshot() []*acp.Event {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]*acp.Event, len(f.events))
|
|
copy(out, f.events)
|
|
return out
|
|
}
|
|
|
|
// All other Repository methods panic — relay tests must not exercise them.
|
|
func (f *fakeEventRepo) CreateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) GetAgentKindByID(context.Context, uuid.UUID) (*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) GetAgentKindByName(context.Context, string) (*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListAgentKinds(context.Context) ([]*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListEnabledAgentKinds(context.Context) ([]*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) UpdateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") }
|
|
func (f *fakeEventRepo) CountAgentKindUsage(context.Context, uuid.UUID) (int64, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ResetSessionForResume(context.Context, uuid.UUID) error {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) UpdateSessionSandboxMode(context.Context, uuid.UUID, string) error {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) MarkSessionFailedIfActive(context.Context, uuid.UUID, string) (bool, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) CountActiveSessions(context.Context) (int64, error) { panic("n/a") }
|
|
func (f *fakeEventRepo) CountActiveSessionsByUser(context.Context, uuid.UUID) (int64, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ResetStuckSessionsOnRestart(context.Context) ([]*acp.Session, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) AcquireMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ReleaseMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32) ([]*acp.Event, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) InsertSessionUsage(_ context.Context, rec *acp.SessionUsageRecord) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if rec.SourceEventID != nil {
|
|
if f.dedupSources == nil {
|
|
f.dedupSources = map[int64]struct{}{}
|
|
}
|
|
if _, seen := f.dedupSources[*rec.SourceEventID]; seen {
|
|
return false, nil
|
|
}
|
|
f.dedupSources[*rec.SourceEventID] = struct{}{}
|
|
}
|
|
cp := *rec
|
|
f.usageLedger = append(f.usageLedger, &cp)
|
|
return true, nil
|
|
}
|
|
func (f *fakeEventRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.totPrompt += dp
|
|
f.totComplete += dc
|
|
f.totThink += dt
|
|
f.totCost += dCost
|
|
return f.totPrompt, f.totComplete, f.totThink, f.totCost, nil
|
|
}
|
|
func (f *fakeEventRepo) SumProjectCostUSD(context.Context, uuid.UUID, time.Time) (float64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.totCost, nil
|
|
}
|
|
func (f *fakeEventRepo) MarkSessionTerminatedReason(context.Context, uuid.UUID, string) error {
|
|
return nil
|
|
}
|
|
func (f *fakeEventRepo) ListSessionsForReaper(context.Context, time.Time, time.Time) ([]acp.ReaperSession, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListSessionUsage(context.Context, uuid.UUID, int32) ([]*acp.SessionUsageRecord, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]*acp.SessionUsageRecord, len(f.usageLedger))
|
|
copy(out, f.usageLedger)
|
|
return out, nil
|
|
}
|
|
func (f *fakeEventRepo) SummarizeAcpUsageByUser(context.Context, time.Time, time.Time) ([]acp.AcpUsageUserRow, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) SummarizeAcpUsageByModel(context.Context, time.Time, time.Time) ([]acp.AcpUsageModelRow, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) SummarizeAcpUsageByDay(context.Context, time.Time, time.Time) ([]acp.AcpUsageDayRow, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) SessionRollup(context.Context, acp.DashboardFilter) (acp.SessionRollup, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) SessionsByDay(context.Context, acp.DashboardFilter) ([]acp.SessionDayRow, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) SessionTimeline(context.Context, uuid.UUID) ([]acp.SessionTimelineBucket, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) UpsertConfigFile(context.Context, *acp.ConfigFile) (*acp.ConfigFile, error) {
|
|
panic("n/a")
|
|
}
|
|
func (f *fakeEventRepo) DeleteConfigFile(context.Context, uuid.UUID, string) (bool, error) {
|
|
panic("n/a")
|
|
}
|
|
|
|
func (f *fakeEventRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error {
|
|
panic("n/a")
|
|
}
|
|
|
|
func (f *fakeEventRepo) WithTx(pgx.Tx) acp.Repository {
|
|
return f
|
|
}
|
|
|
|
// ----- Turn 方法(relay turn 测试用,功能性实现)-----
|
|
|
|
func (f *fakeEventRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
cp := *t
|
|
cp.ID = int64(len(f.turns) + 1)
|
|
cp.Status = acp.TurnInProgress
|
|
f.turns = append(f.turns, &cp)
|
|
out := cp
|
|
return &out, nil
|
|
}
|
|
func (f *fakeEventRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, t := range f.turns {
|
|
if t.TurnIndex == turnIndex {
|
|
t.Status = acp.TurnCompleted
|
|
s := stop
|
|
t.StopReason = &s
|
|
t.UpdateCount = updateCount
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeEventRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, t := range f.turns {
|
|
if t.TurnIndex == turnIndex {
|
|
t.Status = acp.TurnAborted
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeEventRepo) IncrementTurnUpdateCount(context.Context, uuid.UUID, int) error { return nil }
|
|
func (f *fakeEventRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.turns) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := *f.turns[len(f.turns)-1]
|
|
return &out, nil
|
|
}
|
|
func (f *fakeEventRepo) ListTurnsBySession(context.Context, uuid.UUID) ([]*acp.Turn, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]*acp.Turn, len(f.turns))
|
|
copy(out, f.turns)
|
|
return out, nil
|
|
}
|
|
func (f *fakeEventRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
r := reason
|
|
f.lastStopReason = &r
|
|
return nil
|
|
}
|
|
func (f *fakeEventRepo) PurgeTurnsBefore(context.Context, time.Time) (int64, error) { return 0, nil }
|
|
|
|
func (f *fakeEventRepo) turnSnapshot() []*acp.Turn {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]*acp.Turn, len(f.turns))
|
|
for i, t := range f.turns {
|
|
cp := *t
|
|
out[i] = &cp
|
|
}
|
|
return out
|
|
}
|
|
func (f *fakeEventRepo) lastStop() *string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.lastStopReason
|
|
}
|
|
|
|
// relayTestRig wires up a Relay with two io.Pipe pairs that simulate the agent
|
|
// subprocess. Returns:
|
|
// - r: the relay under test
|
|
// - agentWriter: an io.WriteCloser the test "agent" writes to (relay reads from)
|
|
// - agentReader: an io.ReadCloser the test "agent" reads from (relay writes to)
|
|
// - repo: the shared event store
|
|
// - sess: a SessionContext suitable for fs/perm dispatch
|
|
type relayTestRig struct {
|
|
r *acp.Relay
|
|
agentWriter io.WriteCloser
|
|
agentReader io.ReadCloser
|
|
repo *fakeEventRepo
|
|
sess handlers.SessionContext
|
|
// keep references so the helper test can close them
|
|
relayStdinR io.ReadCloser
|
|
relayStdinW io.WriteCloser
|
|
}
|
|
|
|
func newRelayTestRig(t *testing.T) *relayTestRig {
|
|
t.Helper()
|
|
|
|
// relay → agent: relay encodes to relayStdinW, agent reads from relayStdinR
|
|
relayStdinR, relayStdinW := io.Pipe()
|
|
// agent → relay: agent writes to agentStdoutW, relay decodes from agentStdoutR
|
|
agentStdoutR, agentStdoutW := io.Pipe()
|
|
|
|
repo := &fakeEventRepo{}
|
|
fs := handlers.NewFsHandler()
|
|
perm := handlers.NewPermissionHandler(nil)
|
|
|
|
cwd := t.TempDir()
|
|
sess := handlers.SessionContext{
|
|
SessionID: uuid.New(),
|
|
WorkspaceID: uuid.New(),
|
|
UserID: uuid.New(),
|
|
CwdPath: cwd,
|
|
}
|
|
|
|
r := acp.NewRelay(
|
|
sess.SessionID,
|
|
acp.NewDecoder(agentStdoutR, 0),
|
|
acp.NewEncoder(relayStdinW),
|
|
repo, fs, perm,
|
|
acp.RelayConfig{EventMaxPayload: 65536, EventTruncateField: 4096, WSSendBuffer: 16},
|
|
nil,
|
|
)
|
|
return &relayTestRig{
|
|
r: r,
|
|
agentWriter: agentStdoutW,
|
|
agentReader: relayStdinR,
|
|
repo: repo,
|
|
sess: sess,
|
|
relayStdinR: relayStdinR,
|
|
relayStdinW: relayStdinW,
|
|
}
|
|
}
|
|
|
|
// runRelay starts r.Run in a goroutine and returns a cancel func + done chan.
|
|
// The caller must invoke cancel() and wait on doneCh in test cleanup.
|
|
func (rig *relayTestRig) runRelay(t *testing.T) (cancel context.CancelFunc, doneCh <-chan struct{}) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
rig.r.Run(ctx, rig.sess)
|
|
close(done)
|
|
}()
|
|
return cancel, done
|
|
}
|
|
|
|
// shutdown closes the agent-side pipes (causing relay reader to EOF) and waits
|
|
// for Run to return. Called via t.Cleanup.
|
|
func (rig *relayTestRig) shutdown(t *testing.T, cancel context.CancelFunc, doneCh <-chan struct{}) {
|
|
t.Helper()
|
|
// Close agent → relay so reader sees EOF.
|
|
_ = rig.agentWriter.Close()
|
|
cancel()
|
|
// Drain the relay → agent pipe so writer never blocks.
|
|
_ = rig.agentReader.Close()
|
|
select {
|
|
case <-doneCh:
|
|
case <-time.After(3 * time.Second):
|
|
t.Log("relay.Run did not return within 3s of shutdown")
|
|
}
|
|
}
|
|
|
|
// ---------- Test 1 ----------
|
|
|
|
func TestRelay_Call_RoundTripIntID(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
// Goroutine "agent": decode the request from relay's writer side, emit a
|
|
// matching response.
|
|
agentDec := acp.NewDecoder(rig.agentReader, 0)
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
agentDone := make(chan error, 1)
|
|
go func() {
|
|
req, err := agentDec.DecodeMessage()
|
|
if err != nil {
|
|
agentDone <- err
|
|
return
|
|
}
|
|
if !req.IsRequest() || req.Method != "initialize" {
|
|
agentDone <- errors.New("expected initialize request")
|
|
return
|
|
}
|
|
resp, err := acp.NewResponseOK(req.ID, map[string]any{"protocolVersion": 1})
|
|
if err != nil {
|
|
agentDone <- err
|
|
return
|
|
}
|
|
agentDone <- agentEnc.Encode(resp)
|
|
}()
|
|
|
|
ctx, cancelCall := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancelCall()
|
|
resp, err := rig.r.Call(ctx, "initialize", map[string]any{"protocolVersion": 1})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.True(t, resp.IsResponse())
|
|
|
|
var result struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(resp.Result, &result))
|
|
assert.Equal(t, 1, result.ProtocolVersion)
|
|
|
|
// Confirm agent goroutine finished cleanly.
|
|
select {
|
|
case err := <-agentDone:
|
|
require.NoError(t, err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("agent goroutine did not finish in time")
|
|
}
|
|
|
|
// Both directions must have been persisted: outgoing request + incoming
|
|
// response. Check repo accumulator.
|
|
require.Eventually(t, func() bool {
|
|
return len(rig.repo.snapshot()) >= 2
|
|
}, 2*time.Second, 10*time.Millisecond, "expected at least 2 events persisted")
|
|
|
|
events := rig.repo.snapshot()
|
|
var sawReqIn, sawRespOut bool
|
|
for _, ev := range events {
|
|
switch {
|
|
case ev.Direction == acp.DirectionIn && ev.RPCKind == acp.RPCRequest:
|
|
sawReqIn = true
|
|
case ev.Direction == acp.DirectionOut && ev.RPCKind == acp.RPCResponse:
|
|
sawRespOut = true
|
|
}
|
|
}
|
|
assert.True(t, sawReqIn, "expected request event with direction=in")
|
|
assert.True(t, sawRespOut, "expected response event with direction=out")
|
|
}
|
|
|
|
// ---------- Test 2 ----------
|
|
|
|
func TestRelay_FanoutToSubscribers(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
sub := rig.r.Subscribe(rig.sess.UserID)
|
|
require.NotNil(t, sub)
|
|
t.Cleanup(func() { rig.r.Unsubscribe(sub.ID) })
|
|
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
notif, err := acp.NewNotification("session/update", map[string]any{
|
|
"sessionId": "agent-sess-1",
|
|
"update": map[string]any{"kind": "agent_message_chunk"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, agentEnc.Encode(notif))
|
|
|
|
select {
|
|
case env, ok := <-sub.Send:
|
|
require.True(t, ok, "subscriber channel closed unexpectedly")
|
|
require.NotNil(t, env)
|
|
assert.Equal(t, "out", env.Direction)
|
|
assert.Equal(t, "session/update", env.Method)
|
|
assert.Equal(t, string(acp.RPCNotification), env.Kind)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("did not receive notification envelope within 3s")
|
|
}
|
|
}
|
|
|
|
// ---------- Test 3 ----------
|
|
|
|
func TestRelay_AgentFsRequest_Echoed(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
// Seed file inside the session cwd.
|
|
fpath := filepath.Join(rig.sess.CwdPath, "x.txt")
|
|
require.NoError(t, os.WriteFile(fpath, []byte("x"), 0o644))
|
|
|
|
// Agent encoder/decoder for the subprocess side.
|
|
agentDec := acp.NewDecoder(rig.agentReader, 0)
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
|
|
// Agent sends fs/read_text_file with int ID.
|
|
req, err := acp.NewRequestInt(101, "fs/read_text_file", map[string]any{
|
|
"path": "x.txt",
|
|
"sessionId": "agent-sess-1",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, agentEnc.Encode(req))
|
|
|
|
// Read the relay's response from the agent-side reader.
|
|
respCh := make(chan *acp.Message, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
m, err := agentDec.DecodeMessage()
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
respCh <- m
|
|
}()
|
|
|
|
select {
|
|
case resp := <-respCh:
|
|
require.True(t, resp.IsResponse(), "expected response from relay")
|
|
require.Nil(t, resp.Error, "expected non-error response, got %+v", resp.Error)
|
|
|
|
var result struct {
|
|
Content string `json:"content"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(resp.Result, &result))
|
|
assert.Equal(t, "x", result.Content)
|
|
case err := <-errCh:
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Fatalf("agent decoder error: %v", err)
|
|
}
|
|
t.Fatal("agent saw EOF before relay responded")
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("relay did not respond to fs/read_text_file within 3s")
|
|
}
|
|
}
|
|
|
|
// ---------- Test 4 ----------
|
|
|
|
func TestRelay_SlowSubscriberDropped(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
sub := rig.r.Subscribe(rig.sess.UserID)
|
|
require.NotNil(t, sub)
|
|
|
|
// Flood ~100 notifications from agent without reading sub.Send. The fanout
|
|
// loop will eventually see sub.Send full (cap = WSSendBuffer = 16), enter
|
|
// the slow-consumer branch, attempt to push a slow_consumer_disconnect
|
|
// envelope (best-effort: select-default may drop it if buffer is still
|
|
// full), and close the channel. Either outcome (final disc envelope or
|
|
// closed channel) signals "subscriber dropped".
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
floodDone := make(chan struct{})
|
|
go func() {
|
|
defer close(floodDone)
|
|
for i := 0; i < 100; i++ {
|
|
notif, err := acp.NewNotification("session/update", map[string]any{
|
|
"seq": i,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := agentEnc.Encode(notif); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Wait for the flood to complete (relay drains the pipe) before draining
|
|
// sub.Send, otherwise the test would race and prevent the buffer from
|
|
// filling up.
|
|
select {
|
|
case <-floodDone:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("flood did not complete within 3s; relay reader stalled?")
|
|
}
|
|
|
|
// Now drain sub.Send. Either we see slow_consumer_disconnect, or the
|
|
// channel was closed. We must observe at least one of these within 3s.
|
|
deadline := time.NewTimer(3 * time.Second)
|
|
defer deadline.Stop()
|
|
|
|
var sawSlowDisc, sawClose bool
|
|
drain:
|
|
for {
|
|
select {
|
|
case env, ok := <-sub.Send:
|
|
if !ok {
|
|
sawClose = true
|
|
break drain
|
|
}
|
|
if env != nil && env.Kind == "slow_consumer_disconnect" {
|
|
sawSlowDisc = true
|
|
// Keep draining until close (or we'll hit the deadline).
|
|
}
|
|
case <-deadline.C:
|
|
t.Fatal("subscriber not dropped within 3s")
|
|
}
|
|
}
|
|
assert.True(t, sawSlowDisc || sawClose,
|
|
"expected slow_consumer_disconnect envelope or closed channel after flood")
|
|
}
|
|
|
|
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
|
func (f *fakeEventRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
|
return &acp.PermissionRequest{}, nil
|
|
}
|
|
func (f *fakeEventRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeEventRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeEventRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeEventRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) {
|
|
return nil, false, nil
|
|
}
|
|
func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// ---------- Turn detection ----------
|
|
|
|
// TestRelay_TurnDetection feeds a session/update notification then a string-id
|
|
// session/prompt response with stopReason, asserting the tracker hooks fire:
|
|
// the turn is created, completed with the stop reason, and update_count tracked.
|
|
func TestRelay_TurnDetection(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
|
|
bus := &fakeBus{}
|
|
tr := acp.NewTurnTracker(rig.repo, bus, rig.sess, nil)
|
|
rig.r.SetTracker(tr)
|
|
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
// Register the turn (as session_service would before sending the prompt).
|
|
_, err := tr.StartTurn(context.Background(), "client-init")
|
|
require.NoError(t, err)
|
|
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
|
|
// Agent emits two session/update notifications then the prompt response.
|
|
notif, err := acp.NewNotification("session/update", map[string]any{
|
|
"sessionId": "agent-sess-1",
|
|
"update": map[string]any{"sessionUpdate": "agent_message_chunk"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, agentEnc.Encode(notif))
|
|
require.NoError(t, agentEnc.Encode(notif))
|
|
|
|
idJSON, _ := json.Marshal("client-init")
|
|
resp := &acp.Message{
|
|
JSONRPC: "2.0", ID: idJSON,
|
|
Result: json.RawMessage(`{"stopReason":"max_tokens"}`),
|
|
}
|
|
require.NoError(t, agentEnc.Encode(resp))
|
|
|
|
// The turn must complete with stop_reason=max_tokens and update_count=2.
|
|
require.Eventually(t, func() bool {
|
|
ts := rig.repo.turnSnapshot()
|
|
if len(ts) != 1 {
|
|
return false
|
|
}
|
|
return ts[0].Status == acp.TurnCompleted &&
|
|
ts[0].StopReason != nil && *ts[0].StopReason == "max_tokens" &&
|
|
ts[0].UpdateCount == 2
|
|
}, 3*time.Second, 20*time.Millisecond, "turn not completed as expected")
|
|
|
|
// last_stop_reason persisted on the session.
|
|
require.Eventually(t, func() bool {
|
|
ls := rig.repo.lastStop()
|
|
return ls != nil && *ls == "max_tokens"
|
|
}, 2*time.Second, 20*time.Millisecond)
|
|
|
|
// Bus received turn_completed then session_idle.
|
|
require.Eventually(t, func() bool {
|
|
return len(bus.snapshot()) >= 2
|
|
}, 2*time.Second, 20*time.Millisecond)
|
|
evs := bus.snapshot()
|
|
assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind)
|
|
assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind)
|
|
assert.Equal(t, acp.StopMaxTokens, evs[0].StopReason)
|
|
}
|
|
|
|
// TestRelay_IntResponseStillRoutesToInflight is a regression guard: with a
|
|
// tracker installed, an int64-id response must still route to inflight (the
|
|
// existing Call mechanism), not the turn path.
|
|
func TestRelay_IntResponseStillRoutesToInflight(t *testing.T) {
|
|
t.Parallel()
|
|
rig := newRelayTestRig(t)
|
|
rig.r.SetTracker(acp.NewTurnTracker(rig.repo, &fakeBus{}, rig.sess, nil))
|
|
cancel, doneCh := rig.runRelay(t)
|
|
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
|
|
|
|
agentDec := acp.NewDecoder(rig.agentReader, 0)
|
|
agentEnc := acp.NewEncoder(rig.agentWriter)
|
|
agentDone := make(chan error, 1)
|
|
go func() {
|
|
req, err := agentDec.DecodeMessage()
|
|
if err != nil {
|
|
agentDone <- err
|
|
return
|
|
}
|
|
respMsg, err := acp.NewResponseOK(req.ID, map[string]any{"protocolVersion": 1})
|
|
if err != nil {
|
|
agentDone <- err
|
|
return
|
|
}
|
|
agentDone <- agentEnc.Encode(respMsg)
|
|
}()
|
|
|
|
ctx, cancelCall := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancelCall()
|
|
resp, err := rig.r.Call(ctx, "initialize", map[string]any{"protocolVersion": 1})
|
|
require.NoError(t, err)
|
|
require.True(t, resp.IsResponse())
|
|
select {
|
|
case err := <-agentDone:
|
|
require.NoError(t, err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("agent goroutine did not finish")
|
|
}
|
|
}
|