This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+267
View File
@@ -31,6 +31,18 @@ import (
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) {
@@ -81,6 +93,15 @@ func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Sessi
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")
}
@@ -90,6 +111,9 @@ func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Ses
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")
}
@@ -115,6 +139,67 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32
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")
}
@@ -133,6 +218,83 @@ 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
@@ -470,3 +632,108 @@ func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context
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")
}
}