Files
tutti/services/tuttid/integration/agent_activity_eventhub_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

267 lines
9.2 KiB
Go

package integration_test
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
eventprotocol "github.com/tutti-os/tutti/services/tuttid/api/events/generated"
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
workspacedata "github.com/tutti-os/tutti/services/tuttid/data/workspace"
agentservice "github.com/tutti-os/tutti/services/tuttid/service/agent"
eventstreamservice "github.com/tutti-os/tutti/services/tuttid/service/eventstream"
)
func TestAgentActivityProjectionPublishesEventHubUpdatesAndSupportsReconcile(t *testing.T) {
t.Parallel()
ctx := context.Background()
const (
workspaceID = "ws-agent-activity-eventhub"
agentSessionID = "agent-session-1"
)
store := openIntegrationSQLiteStore(t)
if err := store.Create(ctx, workspacebiz.Summary{
ID: workspaceID,
Name: "Workspace Agent Activity EventHub",
}); err != nil {
t.Fatalf("Create() error = %v", err)
}
events := eventstreamservice.NewService(eventstreamservice.DefaultCatalog(), nil)
session := events.OpenSession()
t.Cleanup(func() {
events.CloseSession(session)
})
if err := events.Subscribe(
session,
[]string{eventstreamservice.TopicAgentActivityUpdated},
eventstreamservice.EventScope{WorkspaceID: workspaceID},
); err != nil {
t.Fatalf("Subscribe() error = %v", err)
}
projection := agentservice.NewActivityProjection(store)
projection.SetPublisher(eventstreamservice.AgentActivityPublisher{Service: events})
stateReply, err := projection.ReportSessionState(ctx, canonical.ReportSessionStateInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
SessionOrigin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
Source: canonical.EventSource{
Provider: "codex",
ProviderSessionID: "provider-session-1",
},
State: canonical.WorkspaceAgentSessionStateUpdate{
Title: "Hello from activity",
LifecycleStatus: "running",
CurrentPhase: "thinking",
OccurredAtUnixMS: 100,
StartedAtUnixMS: 90,
},
})
if err != nil {
t.Fatalf("ReportSessionState() error = %v", err)
}
if !stateReply.Accepted {
t.Fatal("ReportSessionState() accepted = false, want true")
}
stateEvent := receiveAgentActivityEvent(t, events, session)
if stateEvent.EventType != "session_reconcile_required" {
t.Fatalf("state event type = %q, want session_reconcile_required", stateEvent.EventType)
}
stateData := agentActivityDataMap(t, stateEvent)
if stateData["lastEventUnixMs"] != float64(100) {
t.Fatalf("state event data = %#v, want lastEventUnixMs 100", stateData)
}
if _, leaked := stateData["lifecycleStatus"]; leaked {
t.Fatalf("state event data = %#v, session lifecycle must not leak into v2 event", stateData)
}
persisted, ok := projection.GetSession(workspaceID, agentSessionID)
if !ok {
t.Fatal("GetSession() ok = false, want true")
}
if persisted.Title != "Hello from activity" || persisted.ActiveTurnID != "" {
t.Fatalf("persisted session = %#v", persisted)
}
turnID := "turn-1"
turnReply, err := projection.ReportSessionState(ctx, canonical.ReportSessionStateInput{
WorkspaceID: workspaceID, AgentSessionID: agentSessionID,
SessionOrigin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
Source: canonical.EventSource{Provider: "codex", ProviderSessionID: "provider-session-1"},
State: canonical.WorkspaceAgentSessionStateUpdate{
LifecycleStatus: "running", OccurredAtUnixMS: 105,
Turn: &canonical.WorkspaceAgentTurnStateUpdate{
TurnID: turnID, ActiveTurnID: &turnID, Phase: agentactivitybiz.TurnPhaseSubmitted,
Origin: agentactivitybiz.TurnOriginUserPrompt, StartedAtUnixMS: 105,
},
},
})
if err != nil || !turnReply.Accepted {
t.Fatalf("ReportSessionState(turn) reply=%#v error=%v", turnReply, err)
}
receivedTurnUpdate := false
receivedSessionReconcile := false
for range 2 {
switch event := receiveAgentActivityEvent(t, events, session); event.EventType {
case "turn_update":
receivedTurnUpdate = true
case "session_reconcile_required":
receivedSessionReconcile = true
default:
t.Fatalf("turn state report event type = %q", event.EventType)
}
}
if !receivedTurnUpdate || !receivedSessionReconcile {
t.Fatalf("turn state report events: turn=%v reconcile=%v", receivedTurnUpdate, receivedSessionReconcile)
}
messageReply, err := projection.ReportSessionMessages(ctx, canonical.ReportSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
SessionOrigin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
Updates: []canonical.WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Status: "completed",
Payload: map[string]any{"text": "hello"},
OccurredAtUnixMS: 110,
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if messageReply.AcceptedCount != 1 || messageReply.LatestVersion != 1 {
t.Fatalf("ReportSessionMessages() reply = %#v, want 1 accepted at version 1", messageReply)
}
messageEvent := receiveAgentActivityEvent(t, events, session)
if messageEvent.EventType != "message_update" {
t.Fatalf("message event type = %q, want message_update", messageEvent.EventType)
}
messageData := agentActivityDataMap(t, messageEvent)
if messageData["latestVersion"] != float64(1) || messageData["acceptedCount"] != float64(1) {
t.Fatalf("message event data = %#v, want latestVersion/acceptedCount 1", messageData)
}
messages, ok := messageData["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("message event data = %#v, want one inline message", messageData)
}
inlineMessage, ok := messages[0].(map[string]any)
if !ok || inlineMessage["sequence"] != float64(1) {
t.Fatalf("inline message = %#v, want durable sequence 1", messages[0])
}
page, ok := projection.ListSessionMessages(agentactivitybiz.ListSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
Limit: 10,
})
if !ok {
t.Fatal("ListSessionMessages() ok = false, want true")
}
if page.LatestVersion != 1 || len(page.Messages) != 1 {
t.Fatalf("message page = %#v, want one message at version 1", page)
}
message := page.Messages[0]
if message.MessageID != "message-1" || message.Version != 1 || message.Payload["text"] != "hello" {
t.Fatalf("message = %#v, want message-1 version 1 with payload text", message)
}
latest, ok := projection.ListSessionMessages(agentactivitybiz.ListSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
Limit: 1,
Order: agentactivitybiz.MessageOrderDesc,
})
if !ok {
t.Fatal("ListSessionMessages(desc) ok = false, want true")
}
if len(latest.Messages) != 1 || latest.Messages[0].Version != 1 {
t.Fatalf("latest page = %#v, want one message at version 1", latest)
}
afterCurrent, ok := projection.ListSessionMessages(agentactivitybiz.ListSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
AfterVersion: 1,
Limit: 10,
})
if !ok {
t.Fatal("ListSessionMessages(after current) ok = false, want true")
}
if len(afterCurrent.Messages) != 0 || afterCurrent.LatestVersion != 1 {
t.Fatalf("after-current page = %#v, want no messages at latest version 1", afterCurrent)
}
}
func openIntegrationSQLiteStore(t *testing.T) *workspacedata.SQLiteStore {
t.Helper()
store, err := workspacedata.OpenSQLiteStore(filepath.Join(t.TempDir(), "tuttid.db"))
if err != nil {
t.Fatalf("OpenSQLiteStore() error = %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
if err := store.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate() error = %v", err)
}
return store
}
func receiveAgentActivityEvent(
t *testing.T,
events *eventstreamservice.Service,
session *eventstreamservice.Session,
) eventprotocol.AgentActivityUpdatedPayload {
t.Helper()
select {
case event := <-events.Events(session):
if event.Topic != eventstreamservice.TopicAgentActivityUpdated {
t.Fatalf("event topic = %q, want %q", event.Topic, eventstreamservice.TopicAgentActivityUpdated)
}
if event.Scope.WorkspaceID != "ws-agent-activity-eventhub" {
t.Fatalf("event workspace scope = %q, want ws-agent-activity-eventhub", event.Scope.WorkspaceID)
}
var payload eventprotocol.AgentActivityUpdatedPayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal agent activity payload: %v", err)
}
if payload.WorkspaceId != "ws-agent-activity-eventhub" || payload.AgentSessionId != "agent-session-1" {
t.Fatalf("payload = %#v, want scoped workspace/session ids", payload)
}
return payload
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for agent activity event")
}
return eventprotocol.AgentActivityUpdatedPayload{}
}
func agentActivityDataMap(
t *testing.T,
payload eventprotocol.AgentActivityUpdatedPayload,
) map[string]any {
t.Helper()
data, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("payload data type = %T, want map[string]any", payload.Data)
}
return data
}