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