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

249 lines
6.9 KiB
Go

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