Files
2026-06-22 08:55:57 +08:00

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")
}
}