package acp_test import ( "context" "encoding/json" "sync" "testing" "github.com/google/uuid" "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" ) // ----- NormalizeStopReason ----- func TestNormalizeStopReason(t *testing.T) { t.Parallel() cases := []struct { raw string want acp.StopReason }{ {"end_turn", acp.StopEndTurn}, {"max_tokens", acp.StopMaxTokens}, {"refusal", acp.StopRefusal}, {"cancelled", acp.StopCancelled}, {"vendor_specific_thing", acp.StopOther}, {"", acp.StopOther}, } for _, c := range cases { assert.Equal(t, c.want, acp.NormalizeStopReason(c.raw), "raw=%q", c.raw) } } func TestParseStopReason(t *testing.T) { t.Parallel() assert.Equal(t, "end_turn", acp.ParseStopReason(json.RawMessage(`{"stopReason":"end_turn"}`))) assert.Equal(t, "max_tokens", acp.ParseStopReason(json.RawMessage(`{"stopReason":"max_tokens","other":1}`))) assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`{}`))) assert.Equal(t, "", acp.ParseStopReason(nil)) assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`not json`))) } // ----- fakeTurnRepo: 记录 turn 调用 ----- type fakeTurnRepo struct { mu sync.Mutex inserted []*acp.Turn completed []completedCall aborted []int lastStopUpdates []string } type completedCall struct { turnIndex int stop string updateCount int } func (f *fakeTurnRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { f.mu.Lock() defer f.mu.Unlock() cp := *t cp.ID = int64(len(f.inserted) + 1) cp.Status = acp.TurnInProgress f.inserted = append(f.inserted, &cp) out := cp return &out, nil } func (f *fakeTurnRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error { f.mu.Lock() defer f.mu.Unlock() f.completed = append(f.completed, completedCall{turnIndex, stop, updateCount}) return nil } func (f *fakeTurnRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error { f.mu.Lock() defer f.mu.Unlock() f.aborted = append(f.aborted, turnIndex) return nil } func (f *fakeTurnRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) { return nil, nil } func (f *fakeTurnRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error { f.mu.Lock() defer f.mu.Unlock() f.lastStopUpdates = append(f.lastStopUpdates, reason) return nil } // ----- fakeBus: 记录发布的事件,保留顺序 ----- type fakeBus struct { mu sync.Mutex events []acp.TurnEvent } func (b *fakeBus) Subscribe(string, func(context.Context, acp.TurnEvent)) {} func (b *fakeBus) Publish(_ context.Context, ev acp.TurnEvent) { b.mu.Lock() defer b.mu.Unlock() b.events = append(b.events, ev) } func (b *fakeBus) snapshot() []acp.TurnEvent { b.mu.Lock() defer b.mu.Unlock() out := make([]acp.TurnEvent, len(b.events)) copy(out, b.events) return out } func newTestSessCtx() handlers.SessionContext { return handlers.SessionContext{ SessionID: uuid.New(), WorkspaceID: uuid.New(), UserID: uuid.New(), } } // ----- TurnTracker lifecycle ----- func TestTurnTracker_Lifecycle(t *testing.T) { t.Parallel() repo := &fakeTurnRepo{} bus := &fakeBus{} sess := newTestSessCtx() tr := acp.NewTurnTracker(repo, bus, sess, nil) ctx := context.Background() idx, err := tr.StartTurn(ctx, "client-init") require.NoError(t, err) assert.Equal(t, 0, idx) require.Len(t, repo.inserted, 1) assert.Equal(t, 0, repo.inserted[0].TurnIndex) // 3 个 update const n = 3 for i := 0; i < n; i++ { tr.OnUpdate(ctx) } // id 不匹配 → 不应完成 tr.OnPromptResponse(ctx, "wrong-id", "end_turn") assert.Empty(t, repo.completed, "mismatched id must not complete") // 匹配 → 完成 tr.OnPromptResponse(ctx, "client-init", "end_turn") require.Len(t, repo.completed, 1) assert.Equal(t, 0, repo.completed[0].turnIndex) assert.Equal(t, "end_turn", repo.completed[0].stop) assert.Equal(t, n, repo.completed[0].updateCount, "update_count must equal observed updates") require.Len(t, repo.lastStopUpdates, 1) assert.Equal(t, "end_turn", repo.lastStopUpdates[0]) // 事件顺序:turn_completed 然后 session_idle evs := bus.snapshot() require.Len(t, evs, 2) assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind) assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind) assert.Equal(t, acp.StopEndTurn, evs[0].StopReason) assert.Equal(t, "end_turn", evs[0].RawStopReason) assert.Equal(t, sess.SessionID, evs[0].SessionID) assert.Equal(t, sess.UserID, evs[0].UserID) // 重复响应 → no-op(回合已闭合) tr.OnPromptResponse(ctx, "client-init", "end_turn") assert.Len(t, repo.completed, 1, "duplicate response must be ignored") } func TestTurnTracker_UnknownStopReason(t *testing.T) { t.Parallel() repo := &fakeTurnRepo{} bus := &fakeBus{} tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) ctx := context.Background() _, err := tr.StartTurn(ctx, "p1") require.NoError(t, err) tr.OnPromptResponse(ctx, "p1", "weird_vendor_reason") require.Len(t, repo.completed, 1) // 原始字符串原样落库 assert.Equal(t, "weird_vendor_reason", repo.completed[0].stop) assert.Equal(t, "weird_vendor_reason", repo.lastStopUpdates[0]) // 归一化为 other evs := bus.snapshot() require.Len(t, evs, 2) assert.Equal(t, acp.StopOther, evs[0].StopReason) assert.Equal(t, "weird_vendor_reason", evs[0].RawStopReason) } func TestTurnTracker_Abort(t *testing.T) { t.Parallel() repo := &fakeTurnRepo{} bus := &fakeBus{} tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) ctx := context.Background() _, err := tr.StartTurn(ctx, "p1") require.NoError(t, err) tr.Abort(ctx, "crashed") require.Len(t, repo.aborted, 1) assert.Equal(t, 0, repo.aborted[0]) evs := bus.snapshot() require.Len(t, evs, 1) assert.Equal(t, acp.SessionIdleEvent, evs[0].Kind) // 无开放回合再 Abort → no-op tr.Abort(ctx, "crashed") assert.Len(t, repo.aborted, 1, "abort with no open turn must be no-op") } func TestTurnTracker_AutoAbortPriorTurn(t *testing.T) { t.Parallel() repo := &fakeTurnRepo{} bus := &fakeBus{} tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) ctx := context.Background() idx0, err := tr.StartTurn(ctx, "p1") require.NoError(t, err) assert.Equal(t, 0, idx0) // 第二次 StartTurn 在前一个开放时 → auto-abort 前一个 idx1, err := tr.StartTurn(ctx, "p2") require.NoError(t, err) assert.Equal(t, 1, idx1) require.Len(t, repo.aborted, 1) assert.Equal(t, 0, repo.aborted[0], "prior open turn must be auto-aborted") require.Len(t, repo.inserted, 2) } func TestTurnTracker_NilSafe(t *testing.T) { t.Parallel() var tr *acp.TurnTracker ctx := context.Background() // 所有方法在 nil receiver 上安全 no-op _, err := tr.StartTurn(ctx, "x") assert.NoError(t, err) tr.OnUpdate(ctx) tr.OnPromptResponse(ctx, "x", "end_turn") tr.Abort(ctx, "x") assert.False(t, tr.IsTrackedPrompt("x")) }