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