Files
tutti/services/tuttid/api/daemon_agent_session_messages_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

481 lines
15 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
agentservice "github.com/tutti-os/tutti/services/tuttid/service/agent"
)
func TestDaemonAPIGeneratedRoutesReadAgentSessionAttachment(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
readAttachmentFn: func(_ context.Context, workspaceID string, agentSessionID string, attachmentID string) (agentservice.PromptAttachment, error) {
if workspaceID != "ws-1" {
t.Fatalf("workspaceID = %q, want ws-1", workspaceID)
}
if agentSessionID != "session-1" {
t.Fatalf("agentSessionID = %q, want session-1", agentSessionID)
}
if attachmentID != "attachment-1" {
t.Fatalf("attachmentID = %q, want attachment-1", attachmentID)
}
return agentservice.PromptAttachment{
AttachmentID: attachmentID,
MimeType: "image/png",
Data: "aW1hZ2U=",
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-sessions/session-1/attachments/attachment-1",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var response tuttigenerated.WorkspaceAgentSessionAttachmentResponse
decodeGeneratedRouteResponse(t, recorder, &response)
if response.AttachmentId != "attachment-1" {
t.Fatalf("attachmentId = %q, want attachment-1", response.AttachmentId)
}
if response.MimeType != tuttigenerated.Imagepng {
t.Fatalf("mimeType = %q, want image/png", response.MimeType)
}
if response.Data != "aW1hZ2U=" {
t.Fatalf("data = %q, want base64 payload", response.Data)
}
}
func TestDaemonAPIGeneratedRoutesListAgentSessionMessages(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listMessagesFn: func(_ context.Context, workspaceID string, agentSessionID string, input agentservice.ListMessagesInput) (agentservice.SessionMessagesPage, error) {
if workspaceID != "ws-1" {
t.Fatalf("workspaceID = %q, want ws-1", workspaceID)
}
if agentSessionID != "agent-session-1" {
t.Fatalf("agentSessionID = %q, want agent-session-1", agentSessionID)
}
if input.BeforeVersion != 9 {
t.Fatalf("beforeVersion = %d, want 9", input.BeforeVersion)
}
if input.Order != agentactivitybiz.MessageOrderDesc {
t.Fatalf("order = %q, want desc", input.Order)
}
if input.Limit != 25 {
t.Fatalf("limit = %d, want 25", input.Limit)
}
return agentservice.SessionMessagesPage{
AgentSessionID: agentSessionID,
Messages: []agentservice.SessionMessage{
{
ID: 8,
AgentSessionID: agentSessionID,
MessageID: "msg-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"content": "Done."},
StartedAtUnixMS: 1717200001000,
Version: 8,
},
},
LatestVersion: 8,
HasMore: false,
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-sessions/agent-session-1/messages?beforeVersion=9&order=desc&limit=25",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var response tuttigenerated.WorkspaceAgentSessionMessagesResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.AgentSessionId != "agent-session-1" {
t.Fatalf("agentSessionId = %q, want agent-session-1", response.AgentSessionId)
}
if response.LatestVersion != 8 {
t.Fatalf("latestVersion = %d, want 8", response.LatestVersion)
}
if response.HasMore {
t.Fatal("hasMore = true, want false")
}
if len(response.Messages) != 1 {
t.Fatalf("len(messages) = %d, want 1", len(response.Messages))
}
if response.Messages[0].MessageId != "msg-1" {
t.Fatalf("messageId = %q, want msg-1", response.Messages[0].MessageId)
}
if response.Messages[0].Sequence != 8 {
t.Fatalf("sequence = %d, want durable message id 8", response.Messages[0].Sequence)
}
if response.Messages[0].TurnId == nil || *response.Messages[0].TurnId != "turn-1" {
t.Fatalf("turnId = %v, want turn-1", response.Messages[0].TurnId)
}
if response.Messages[0].OccurredAtUnixMs != 1717200001000 {
t.Fatalf("occurredAtUnixMs = %d, want startedAt fallback", response.Messages[0].OccurredAtUnixMs)
}
}
// Protocol v2: messages without turn attribution are legitimate
// session-level messages and project turnId null instead of failing.
func TestDaemonAPIGeneratedRoutesProjectTurnlessAgentSessionMessagesAsSessionLevel(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listMessagesFn: func(_ context.Context, _ string, agentSessionID string, _ agentservice.ListMessagesInput) (agentservice.SessionMessagesPage, error) {
return agentservice.SessionMessagesPage{
AgentSessionID: agentSessionID,
Messages: []agentservice.SessionMessage{
{
ID: 8,
AgentSessionID: agentSessionID,
MessageID: "msg-turnless",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"content": "Done."},
StartedAtUnixMS: 1717200001000,
Version: 8,
},
},
LatestVersion: 8,
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-sessions/agent-session-1/messages",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var response tuttigenerated.WorkspaceAgentSessionMessagesResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response error = %v", err)
}
if len(response.Messages) != 1 {
t.Fatalf("len(messages) = %d, want 1", len(response.Messages))
}
if response.Messages[0].TurnId != nil {
t.Fatalf("turnId = %v, want null", *response.Messages[0].TurnId)
}
}
func TestGeneratedWorkspaceAgentSafeIntegerBounds(t *testing.T) {
t.Parallel()
value, err := generatedWorkspaceAgentSafeInteger(
"message version",
maxWorkspaceAgentJSONSafeInteger,
)
if err != nil {
t.Fatalf("maximum safe integer rejected: %v", err)
}
if value != int64(maxWorkspaceAgentJSONSafeInteger) {
t.Fatalf("value = %d, want %d", value, maxWorkspaceAgentJSONSafeInteger)
}
if _, err := generatedWorkspaceAgentSafeInteger(
"message version",
maxWorkspaceAgentJSONSafeInteger+1,
); err == nil {
t.Fatal("maximum safe integer plus one accepted")
}
}
func TestDaemonAPIGeneratedRoutesAcceptSafeAgentMessageCursorQueries(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listMessagesFn: func(_ context.Context, _ string, _ string, input agentservice.ListMessagesInput) (agentservice.SessionMessagesPage, error) {
if input.AfterVersion != maxWorkspaceAgentJSONSafeInteger {
t.Fatalf("afterVersion = %d, want %d", input.AfterVersion, maxWorkspaceAgentJSONSafeInteger)
}
if input.BeforeVersion != maxWorkspaceAgentJSONSafeInteger {
t.Fatalf("beforeVersion = %d, want %d", input.BeforeVersion, maxWorkspaceAgentJSONSafeInteger)
}
return agentservice.SessionMessagesPage{
AgentSessionID: "agent-session-1",
Messages: []agentservice.SessionMessage{},
LatestVersion: maxWorkspaceAgentJSONSafeInteger,
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
fmt.Sprintf(
"/v1/workspaces/ws-1/agent-sessions/agent-session-1/messages?afterVersion=%d&beforeVersion=%d",
maxWorkspaceAgentJSONSafeInteger,
maxWorkspaceAgentJSONSafeInteger,
),
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
}
func TestDaemonAPIGeneratedRoutesRejectUnsafeAgentMessageCursorQueries(t *testing.T) {
for _, parameter := range []string{"afterVersion", "beforeVersion"} {
t.Run(parameter, func(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listMessagesFn: func(context.Context, string, string, agentservice.ListMessagesInput) (agentservice.SessionMessagesPage, error) {
t.Fatal("unsafe cursor reached the service")
return agentservice.SessionMessagesPage{}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
fmt.Sprintf(
"/v1/workspaces/ws-1/agent-sessions/agent-session-1/messages?%s=%d",
parameter,
maxWorkspaceAgentJSONSafeInteger+1,
),
nil,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
})
}
}
func TestDaemonAPIGeneratedRoutesRejectUnsafeAgentMessageResponseIntegers(t *testing.T) {
tests := []struct {
name string
page agentservice.SessionMessagesPage
}{
{
name: "sequence",
page: agentservice.SessionMessagesPage{
AgentSessionID: "agent-session-1",
LatestVersion: 1,
Messages: []agentservice.SessionMessage{{
ID: maxWorkspaceAgentJSONSafeInteger + 1,
AgentSessionID: "agent-session-1",
MessageID: "message-1",
Kind: "text",
Role: "assistant",
Version: 1,
}},
},
},
{
name: "message version",
page: agentservice.SessionMessagesPage{
AgentSessionID: "agent-session-1",
LatestVersion: maxWorkspaceAgentJSONSafeInteger + 1,
Messages: []agentservice.SessionMessage{{
ID: 1,
AgentSessionID: "agent-session-1",
MessageID: "message-1",
Kind: "text",
Role: "assistant",
Version: maxWorkspaceAgentJSONSafeInteger + 1,
}},
},
},
{
name: "latest version",
page: agentservice.SessionMessagesPage{
AgentSessionID: "agent-session-1",
LatestVersion: maxWorkspaceAgentJSONSafeInteger + 1,
Messages: []agentservice.SessionMessage{},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listMessagesFn: func(context.Context, string, string, agentservice.ListMessagesInput) (agentservice.SessionMessagesPage, error) {
return test.page, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-sessions/agent-session-1/messages",
nil,
)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
})
}
}
func TestDaemonAPIGeneratedRoutesRejectUnsafeSessionMessageVersion(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
getDetailFn: func(context.Context, string, string) (agentservice.SessionDetail, error) {
return agentservice.SessionDetail{
Session: agentservice.Session{
ID: "agent-session-1",
Kind: agentactivitybiz.SessionKindRoot,
MessageVersion: maxWorkspaceAgentJSONSafeInteger + 1,
Provider: "codex",
RailSectionKey: "conversations",
CreatedAt: time.UnixMilli(1),
},
ChildSessions: []agentservice.Session{},
Turns: []agentactivitybiz.Turn{},
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-sessions/agent-session-1",
nil,
)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
}
func TestDaemonAPIGeneratedRoutesListAgentGeneratedFiles(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listGeneratedFilesFn: func(_ context.Context, workspaceID string, input agentservice.ListGeneratedFilesInput) (agentservice.GeneratedFileList, error) {
if workspaceID != "ws-1" {
t.Fatalf("workspaceID = %q, want ws-1", workspaceID)
}
if input.Query != "report" {
t.Fatalf("query = %q, want report", input.Query)
}
if input.SectionKey != "project:/workspace" {
t.Fatalf("sectionKey = %q, want project:/workspace", input.SectionKey)
}
if input.Limit != 25 {
t.Fatalf("limit = %d, want 25", input.Limit)
}
if input.Cursor != "v1:25" {
t.Fatalf("cursor = %q, want v1:25", input.Cursor)
}
return agentservice.GeneratedFileList{
WorkspaceID: workspaceID,
HasMore: true,
NextCursor: "v1:50",
Files: []agentservice.GeneratedFile{
{
Label: "report.md",
Path: "/workspace/report.md",
},
},
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-generated-files?query=report&sectionKey=project%3A%2Fworkspace&agentTargetIds=local%3Acodex&agentTargetIds=local%3Aclaude-code&cursor=v1%3A25&limit=25",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var response tuttigenerated.WorkspaceAgentGeneratedFileListResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.WorkspaceId != "ws-1" {
t.Fatalf("workspaceId = %q, want ws-1", response.WorkspaceId)
}
if len(response.Entries) != 1 {
t.Fatalf("len(entries) = %d, want 1", len(response.Entries))
}
if response.Entries[0].Path != "/workspace/report.md" {
t.Fatalf("entry path = %q, want /workspace/report.md", response.Entries[0].Path)
}
if !response.HasMore || response.NextCursor == nil || *response.NextCursor != "v1:50" {
t.Fatalf("pagination = hasMore:%v nextCursor:%v", response.HasMore, response.NextCursor)
}
}
func TestDaemonAPIGeneratedRoutesRejectsTooManyAgentTargetFilters(t *testing.T) {
mux := http.NewServeMux()
serviceCalls := 0
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
listGeneratedFilesFn: func(context.Context, string, agentservice.ListGeneratedFilesInput) (agentservice.GeneratedFileList, error) {
serviceCalls++
return agentservice.GeneratedFileList{}, nil
},
},
}))
query := make(url.Values)
query.Set("sectionKey", "conversations")
for index := 0; index <= agentservice.MaxGeneratedFileAgentTargetFilters; index++ {
query.Add("agentTargetIds", fmt.Sprintf("agent-%d", index))
}
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/ws-1/agent-generated-files?"+query.Encode(),
nil,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if serviceCalls != 0 {
t.Fatalf("service calls = %d, want 0", serviceCalls)
}
}