You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user