Files
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

1348 lines
44 KiB
Go

package agentsessionstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
)
func TestResolveAPIPrefixUsesLocalV1ForLoopbackAndGatewayPrefixForRemote(t *testing.T) {
t.Parallel()
if got := resolveAPIPrefix("http://127.0.0.1:9000"); got != "/v1" {
t.Fatalf("loopback prefix = %q, want /v1", got)
}
if got := resolveAPIPrefix("https://control.example.test"); got != "/api/desktop/v1" {
t.Fatalf("remote prefix = %q, want /api/desktop/v1", got)
}
if got := resolveSessionAPIPrefix("http://127.0.0.1:9000"); got != "/v1" {
t.Fatalf("loopback session prefix = %q, want /v1", got)
}
if got := resolveSessionAPIPrefix("https://control.example.test"); got != "/api/desktop/v1" {
t.Fatalf("remote session prefix = %q, want /api/desktop/v1", got)
}
}
func TestReportActivityPostsSessionStateEndpoint(t *testing.T) {
var got reportSessionStateRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/state" {
t.Fatalf("path = %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"accepted":true}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
reply, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "session-1",
Provider: "codex",
LifecycleStatus: "active",
CurrentPhase: "idle",
}},
})
if err != nil {
t.Fatal(err)
}
if reply.AcceptedTimelineItemCount != 0 || reply.AcceptedStatePatchCount != 1 {
t.Fatalf("reply = %#v", reply)
}
if reply.RequestBodyBytes <= 0 {
t.Fatalf("reply request body bytes = %d, want > 0", reply.RequestBodyBytes)
}
if got.State.CurrentPhase != "idle" || got.State.LifecycleStatus != "active" {
t.Fatalf("state request = %#v", got)
}
}
func TestReportActivityDoesNotPostMessageUpdatesToLegacyActivityEndpoint(t *testing.T) {
var called atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/agents/activity") {
called.Store(true)
t.Fatalf("legacy activity endpoint should not be called for message updates")
}
_, _ = w.Write([]byte(`{"acceptedCount":1}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
reply, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
MessageUpdates: []WorkspaceAgentMessageUpdate{{
AgentSessionID: "session-1",
MessageID: "message-1",
Seq: 42,
TurnID: "turn-1",
Role: "assistant",
Kind: "tool_call",
Status: "completed",
CallID: "call-1",
ParentCallID: "parent-call-1",
RootCallID: "root-call-1",
Title: "Read file",
Payload: map[string]any{"path": "README.md"},
OccurredAtUnixMS: 1710000000001,
StartedAtUnixMS: 1710000000002,
CompletedAtUnixMS: 1710000000003,
}},
})
if err != nil {
t.Fatal(err)
}
if reply.AcceptedMessageUpdateCount != 1 {
t.Fatalf("accepted message updates = %d, want 1", reply.AcceptedMessageUpdateCount)
}
if reply.RequestBodyBytes <= 0 {
t.Fatalf("reply request body bytes = %d, want > 0", reply.RequestBodyBytes)
}
if called.Load() {
t.Fatal("legacy activity endpoint was called")
}
}
func TestReportSessionStatePostsNewEndpoint(t *testing.T) {
var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/state" {
t.Fatalf("request = %s %s", r.Method, r.URL.String())
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"accepted":true,"lastEventAtUnixMs":"1710000000004"}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
reply, err := client.ReportSessionState(context.Background(), ReportSessionStateInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
Connector: &ConnectorInfo{ID: "desktopd", Version: "1.2.3"},
Source: EventSource{
Provider: "codex",
ProviderSessionID: "provider-session-1",
AgentID: "session-1",
CWD: "/workspace",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
},
State: WorkspaceAgentSessionStateUpdate{
Provider: "codex",
ProviderSessionID: "provider-session-1",
Model: "gpt-5",
CWD: "/workspace",
Title: "Implement protocol",
LifecycleStatus: "active",
CurrentPhase: "working",
OccurredAtUnixMS: 1710000000001,
StartedAtUnixMS: 1710000000002,
Turn: &WorkspaceAgentTurnStateUpdate{
TurnID: "turn-1",
Phase: "working",
Outcome: "running",
FileChanges: map[string]any{"modified": []any{"README.md"}},
StartedAtUnixMS: 1710000000002,
CompletedAtUnixMS: 1710000000003,
},
},
})
if err != nil {
t.Fatal(err)
}
if !reply.Accepted || reply.LastEventAtUnixMS != 1710000000004 {
t.Fatalf("reply = %#v", reply)
}
if reply.RequestBodyBytes <= 0 {
t.Fatalf("reply request body bytes = %d, want > 0", reply.RequestBodyBytes)
}
if got["agentSessionId"] != "session-1" || got["sessionOrigin"] != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("request identity = %#v", got)
}
state, ok := got["state"].(map[string]any)
if !ok {
t.Fatalf("state = %#v, want object", got["state"])
}
if state["title"] != "Implement protocol" || state["currentPhase"] != "working" {
t.Fatalf("state = %#v", state)
}
turn, ok := state["turn"].(map[string]any)
if !ok || turn["turnId"] != "turn-1" {
t.Fatalf("turn = %#v", state["turn"])
}
}
func TestReportSessionMessagesPostsNewEndpointWithoutOldMessageIdentity(t *testing.T) {
var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("request = %s %s", r.Method, r.URL.String())
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"acceptedCount":1,"latestVersion":"7"}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
reply, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "tool_call",
Status: "completed",
Payload: map[string]any{"path": "README.md"},
OccurredAtUnixMS: 1710000000001,
StartedAtUnixMS: 1710000000002,
CompletedAtUnixMS: 1710000000003,
}},
})
if err != nil {
t.Fatal(err)
}
if reply.AcceptedCount != 1 || reply.LatestVersion != 7 {
t.Fatalf("reply = %#v", reply)
}
if reply.RequestBodyBytes <= 0 {
t.Fatalf("reply request body bytes = %d, want > 0", reply.RequestBodyBytes)
}
if _, ok := got["agentSessionId"]; ok {
t.Fatalf("request body should not carry top-level agentSessionId field: %#v", got)
}
updates, ok := got["updates"].([]any)
if !ok || len(updates) != 1 {
t.Fatalf("updates = %#v, want one", got["updates"])
}
update, ok := updates[0].(map[string]any)
if !ok {
t.Fatalf("update = %#v, want object", updates[0])
}
if _, ok := update["agentSessionId"]; ok {
t.Fatalf("message update should not carry old agentSessionId field: %#v", update)
}
if _, ok := update["seq"]; ok {
t.Fatalf("message update should not carry old seq field: %#v", update)
}
if update["messageId"] != "message-1" || update["kind"] != "tool_call" {
t.Fatalf("update = %#v", update)
}
}
func TestReportSessionMessagesSanitizesLargeToolPayloadsBeforeUpload(t *testing.T) {
t.Parallel()
rawImageData := strings.Repeat("A", 128*1024)
rawText := strings.Repeat("z", maxUpstreamToolPayloadStringBytes+256)
var rawBody []byte
var got reportSessionMessagesRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("path = %s", r.URL.Path)
}
var err error
rawBody, err = io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(rawBody, &got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"accepted":true}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
_, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "tool_call",
Status: "completed",
Payload: map[string]any{
"input": map[string]any{
"type": "image",
"mimeType": "image/png",
"uri": "/tmp/generated.png",
"data": rawImageData,
},
"output": map[string]any{"stdout": rawText},
},
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if len(rawBody) >= len(rawImageData) {
t.Fatalf("request body size = %d, want sanitization to shrink below raw image data size %d", len(rawBody), len(rawImageData))
}
if strings.Contains(string(rawBody), rawImageData) {
t.Fatal("request body still contains raw image data")
}
if len(got.Updates) != 1 {
t.Fatalf("updates = %#v, want one", got.Updates)
}
inputPayload, _ := got.Updates[0].Payload["input"].(map[string]any)
entityImageData, _ := inputPayload["data"].(string)
if !strings.Contains(entityImageData, "[omitted image/png bytes;") {
t.Fatalf("entity image payload = %q", entityImageData)
}
outputPayload, _ := got.Updates[0].Payload["output"].(map[string]any)
entityStdout, _ := outputPayload["stdout"].(string)
if !strings.Contains(entityStdout, "...[truncated ") {
t.Fatalf("entity stdout payload = %q", entityStdout)
}
}
func TestReportSessionMessagesPreservesLargeTextPayloadsBeforeUpload(t *testing.T) {
t.Parallel()
rawText := strings.Repeat("x", 260*1024)
rawMetadataText := strings.Repeat("z", maxUpstreamToolPayloadStringBytes+256)
var got reportSessionMessagesRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("path = %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"accepted":true}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
_, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "text",
Status: "completed",
Payload: map[string]any{
"source": "runtime",
"content": rawText,
"text": rawText,
"metadata": map[string]any{"stdout": rawMetadataText},
},
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if len(got.Updates) != 1 {
t.Fatalf("updates = %#v, want one", got.Updates)
}
content, _ := got.Updates[0].Payload["content"].(string)
text, _ := got.Updates[0].Payload["text"].(string)
if content != rawText || text != rawText {
t.Fatalf(
"text payload lengths content=%d text=%d, want %d",
len(content),
len(text),
len(rawText),
)
}
if _, ok := got.Updates[0].Payload["truncatedPayloadBytes"]; ok {
t.Fatalf("payload = %#v, did not expect text payload compaction", got.Updates[0].Payload)
}
metadata, _ := got.Updates[0].Payload["metadata"].(map[string]any)
stdout, _ := metadata["stdout"].(string)
if !strings.Contains(stdout, "...[truncated ") {
t.Fatalf("metadata stdout payload = %q, want diagnostic truncation", stdout)
}
}
func TestReportSessionMessagesCapsSinglePayloadToServerLimit(t *testing.T) {
t.Parallel()
const serverMessagePayloadLimitBytes = 256 * 1024
rawText := strings.Repeat("x", 96*1024)
var got reportSessionMessagesRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("path = %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"acceptedCount":1}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
_, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "tool_call",
Status: "completed",
Payload: map[string]any{
"callId": "call-1",
"toolName": "exec_command",
"name": "exec_command",
"input": map[string]any{"stdout": rawText},
"output": map[string]any{"stdout": rawText},
"error": map[string]any{"stderr": rawText},
"metadata": map[string]any{"stdout": rawText},
"content": []any{map[string]any{"text": rawText}},
},
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if len(got.Updates) != 1 {
t.Fatalf("updates = %#v, want one", got.Updates)
}
payloadBody, err := json.Marshal(got.Updates[0].Payload)
if err != nil {
t.Fatal(err)
}
if len(payloadBody) > serverMessagePayloadLimitBytes {
t.Fatalf("payload bytes = %d, want <= %d", len(payloadBody), serverMessagePayloadLimitBytes)
}
if len(payloadBody) <= 64*1024 {
t.Fatalf("payload bytes = %d, want above legacy 64KiB server limit", len(payloadBody))
}
if got.Updates[0].Payload["callId"] != "call-1" || got.Updates[0].Payload["toolName"] != "exec_command" {
t.Fatalf("payload identity fields = %#v", got.Updates[0].Payload)
}
if _, ok := got.Updates[0].Payload["truncatedPayloadBytes"]; ok {
t.Fatalf("payload = %#v, did not expect compaction under raised server limit", got.Updates[0].Payload)
}
}
func TestReportSessionMessagesCompactsPayloadAboveClientLimit(t *testing.T) {
t.Parallel()
const serverMessagePayloadLimitBytes = 256 * 1024
largePayload := map[string]any{
"callId": "call-1",
"toolName": "exec_command",
}
for i := 0; i < 24; i++ {
largePayload[fmt.Sprintf("field%d", i)] = strings.Repeat("x", 96*1024)
}
var got reportSessionMessagesRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"acceptedCount":1}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
_, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "tool_call",
Status: "completed",
Payload: largePayload,
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if len(got.Updates) != 1 {
t.Fatalf("updates = %#v, want one", got.Updates)
}
payloadBody, err := json.Marshal(got.Updates[0].Payload)
if err != nil {
t.Fatal(err)
}
if len(payloadBody) > serverMessagePayloadLimitBytes {
t.Fatalf("payload bytes = %d, want <= %d", len(payloadBody), serverMessagePayloadLimitBytes)
}
if got.Updates[0].Payload["callId"] != "call-1" || got.Updates[0].Payload["toolName"] != "exec_command" {
t.Fatalf("payload identity fields = %#v", got.Updates[0].Payload)
}
if _, ok := got.Updates[0].Payload["truncatedPayloadBytes"]; !ok {
t.Fatalf("payload = %#v, want compaction metadata", got.Updates[0].Payload)
}
}
func TestReportSessionMessagesSplitsOversizedRequestsIntoMultipleUploads(t *testing.T) {
t.Parallel()
const gatewayBodyLimitBytes = 1 << 20
rawText := strings.Repeat("x", maxUpstreamToolPayloadStringBytes+512)
var requestCount int
var acceptedMessageCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("path = %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if len(body) > gatewayBodyLimitBytes {
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
var got reportSessionMessagesRequest
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
requestCount++
acceptedMessageCount += len(got.Updates)
_, _ = w.Write([]byte(`{"acceptedCount":` + strconv.Itoa(len(got.Updates)) + `}`))
}))
defer server.Close()
updates := make([]WorkspaceAgentSessionMessageUpdate, 0, 80)
for i := 0; i < 80; i++ {
updates = append(updates, WorkspaceAgentSessionMessageUpdate{
MessageID: "message-" + strconv.Itoa(i),
Role: "assistant",
Kind: "tool_call",
Status: "completed",
Payload: map[string]any{
"input": map[string]any{"stdout": rawText},
"output": map[string]any{"stdout": rawText},
},
})
}
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1", HTTPClient: server.Client()})
reply, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: updates,
})
if err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if requestCount < 2 {
t.Fatalf("requestCount = %d, want multiple uploads", requestCount)
}
if acceptedMessageCount != len(updates) {
t.Fatalf("accepted messages = %d, want %d", acceptedMessageCount, len(updates))
}
if reply.AcceptedCount != len(updates) {
t.Fatalf("reply = %#v, want full accepted counts", reply)
}
}
func TestListSessionMessagesUsesAfterVersion(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("request = %s %s", r.Method, r.URL.String())
}
if got := r.URL.Query().Get("after_version"); got != "9" {
t.Fatalf("after_version = %q, want 9", got)
}
if got := r.URL.Query().Get("limit"); got != "20" {
t.Fatalf("limit = %q, want 20", got)
}
if got := r.URL.Query().Get("session_origin"); got != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("session_origin = %q, want runtime enum", got)
}
_, _ = w.Write([]byte(`{
"messages":[{
"id":"11",
"agentSessionId":"session-1",
"messageId":"message-1",
"turnId":"turn-1",
"role":"assistant",
"kind":"text",
"status":"completed",
"payload":{"text":"hello"},
"occurredAtUnixMs":"1710000000001",
"startedAtUnixMs":"1710000000002",
"completedAtUnixMs":"1710000000003",
"createdAtUnixMs":"1710000000004",
"updatedAtUnixMs":"1710000000005",
"version":"10"
}],
"latestVersion":"10",
"hasMore":true
}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, HTTPClient: server.Client()})
reply, err := client.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: " ws-1 ",
AgentSessionID: " session-1 ",
AfterVersion: 9,
Limit: 20,
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
})
if err != nil {
t.Fatal(err)
}
if reply.LatestVersion != 10 || !reply.HasMore {
t.Fatalf("reply = %#v", reply)
}
if len(reply.Messages) != 1 {
t.Fatalf("messages = %#v, want one", reply.Messages)
}
message := reply.Messages[0]
if message.ID != 11 || message.Version != 10 || message.MessageID != "message-1" {
t.Fatalf("message identity = %#v", message)
}
if message.OccurredAtUnixMS != 1710000000001 ||
message.StartedAtUnixMS != 1710000000002 ||
message.CompletedAtUnixMS != 1710000000003 ||
message.CreatedAtUnixMS != 1710000000004 ||
message.UpdatedAtUnixMS != 1710000000005 {
t.Fatalf("message timestamps = %#v", message)
}
}
func TestListSessionMessagesDecodesSnakeCaseResponse(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/rooms/ws-1/agents/sessions/session-1/messages" {
t.Fatalf("request = %s %s", r.Method, r.URL.String())
}
_, _ = w.Write([]byte(`{
"messages":[{
"id":"12",
"agent_session_id":"session-1",
"message_id":"message-2",
"turn_id":"turn-2",
"role":"assistant",
"kind":"text",
"status":"completed",
"payload":{"text":"hello"},
"occurred_at_unix_ms":"1710000000001",
"started_at_unix_ms":"1710000000002",
"completed_at_unix_ms":"1710000000003",
"created_at_unix_ms":"1710000000004",
"updated_at_unix_ms":"1710000000005",
"version":"11"
}],
"latest_version":"11",
"has_more":true
}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, HTTPClient: server.Client()})
reply, err := client.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if reply.LatestVersion != 11 || !reply.HasMore {
t.Fatalf("reply = %#v", reply)
}
if len(reply.Messages) != 1 {
t.Fatalf("messages = %#v, want one", reply.Messages)
}
message := reply.Messages[0]
if message.ID != 12 ||
message.AgentSessionID != "session-1" ||
message.MessageID != "message-2" ||
message.TurnID != "turn-2" ||
message.Version != 11 {
t.Fatalf("message identity = %#v", message)
}
if message.OccurredAtUnixMS != 1710000000001 ||
message.StartedAtUnixMS != 1710000000002 ||
message.CompletedAtUnixMS != 1710000000003 ||
message.CreatedAtUnixMS != 1710000000004 ||
message.UpdatedAtUnixMS != 1710000000005 {
t.Fatalf("message timestamps = %#v", message)
}
}
func TestListSessionMessagesValidatesPathFieldsBeforeRequest(t *testing.T) {
t.Parallel()
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, HTTPClient: server.Client()})
if _, err := client.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: " ",
AgentSessionID: "session-1",
}); err == nil {
t.Fatal("ListSessionMessages() error = nil, want workspace id validation error")
}
if _, err := client.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: " ",
}); err == nil {
t.Fatal("ListSessionMessages() error = nil, want agent session id validation error")
}
if requests != 0 {
t.Fatalf("requests = %d, want 0", requests)
}
}
func TestReportActivityUsesResolvedRemoteAPIPrefix(t *testing.T) {
t.Parallel()
paths := make([]string, 0, 2)
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
paths = append(paths, req.URL.Path)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"acceptedTimelineItemCount":1,"acceptedStatePatchCount":1}`)),
Request: req,
}, nil
})},
})
if _, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "session-1",
CurrentPhase: "idle",
}},
}); err != nil {
t.Fatalf("ReportActivity() error = %v", err)
}
if _, err := client.ReportActivityJSON(
context.Background(),
"ws-1",
json.RawMessage(`{"statePatches":[{"agentSessionId":"session-1","currentPhase":"idle"}]}`),
); err != nil {
t.Fatalf("ReportActivityJSON() error = %v", err)
}
want := "/api/desktop/v1/rooms/ws-1/agents/sessions/session-1/state"
if len(paths) != 2 || paths[0] != want || paths[1] != want {
t.Fatalf("paths = %#v, want both %q", paths, want)
}
}
func TestSessionUpdateMethodsUseRemoteDesktopPrefix(t *testing.T) {
t.Parallel()
paths := make([]string, 0, 3)
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
paths = append(paths, req.URL.Path)
body := `{"accepted":true}`
if strings.HasSuffix(req.URL.Path, "/messages") {
body = `{"acceptedCount":1,"latestVersion":"1","messages":[]}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})},
})
if _, err := client.ReportSessionState(context.Background(), ReportSessionStateInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
State: WorkspaceAgentSessionStateUpdate{
CurrentPhase: "idle",
},
}); err != nil {
t.Fatalf("ReportSessionState() error = %v", err)
}
if _, err := client.ReportSessionMessages(context.Background(), ReportSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Updates: []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "text",
}},
}); err != nil {
t.Fatalf("ReportSessionMessages() error = %v", err)
}
if _, err := client.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: "ws-1",
AgentSessionID: "session-1",
Limit: 20,
}); err != nil {
t.Fatalf("ListSessionMessages() error = %v", err)
}
want := "/api/desktop/v1/rooms/ws-1/agents/sessions/session-1"
wantPaths := []string{want + "/state", want + "/messages", want + "/messages"}
if len(paths) != len(wantPaths) {
t.Fatalf("paths = %#v, want %#v", paths, wantPaths)
}
for index := range wantPaths {
if paths[index] != wantPaths[index] {
t.Fatalf("paths = %#v, want %#v", paths, wantPaths)
}
}
}
func TestReportActivityRetriesTransientRemoteEOFUpToThirdAttempt(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.URL.Path; got != "/api/desktop/v1/rooms/ws-1/agents/sessions/session-1/state" {
t.Fatalf("path = %q", got)
}
if attempts.Add(1) < 3 {
return nil, io.EOF
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"accepted":true}`)),
Request: req,
}, nil
})},
})
reply, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "session-1",
CurrentPhase: "idle",
}},
})
if err != nil {
t.Fatalf("ReportActivity() error = %v", err)
}
if attempts.Load() != 3 {
t.Fatalf("attempts = %d, want 3", attempts.Load())
}
if reply.AcceptedTimelineItemCount != 0 || reply.AcceptedStatePatchCount != 1 {
t.Fatalf("reply = %#v", reply)
}
}
func TestReportActivityStopsAfterThirdTransientRemoteEOF(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
attempts.Add(1)
return nil, io.EOF
})},
})
_, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "session-1",
CurrentPhase: "idle",
}},
})
if !errors.Is(err, io.EOF) {
t.Fatalf("ReportActivity() error = %v, want EOF", err)
}
if attempts.Load() != 3 {
t.Fatalf("attempts = %d, want 3", attempts.Load())
}
}
func TestReportActivityRetriesTransientRemoteTLSHandshakeTimeout(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempt := attempts.Add(1)
if attempt == 1 {
return nil, errors.New("net/http: TLS handshake timeout")
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"acceptedCount":1}`)),
Request: req,
}, nil
})},
})
_, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
MessageUpdates: []WorkspaceAgentMessageUpdate{{
AgentSessionID: "session-1",
MessageID: "message-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "hello"},
}},
})
if err != nil {
t.Fatalf("ReportActivity() error = %v", err)
}
if attempts.Load() != 2 {
t.Fatalf("attempts = %d, want 2", attempts.Load())
}
}
func TestReportActivityDoesNotRetryPermanentRemoteHTTPError(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts.Add(1)
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":"bad request"}`)),
Request: req,
}, nil
})},
})
_, err := client.ReportActivity(context.Background(), ReportActivityInput{
WorkspaceID: "ws-1",
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "session-1",
CurrentPhase: "idle",
}},
})
var httpErr HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("ReportActivity() error = %T %v, want HTTPError", err, err)
}
bodyBytes, ok := RequestBodyBytesFromError(err)
if !ok || bodyBytes <= 0 {
t.Fatalf("RequestBodyBytesFromError(%v) = (%d, %v), want (>0, true)", err, bodyBytes, ok)
}
if attempts.Load() != 1 {
t.Fatalf("attempts = %d, want 1", attempts.Load())
}
}
func TestListAgentsWithFilterRetriesTransientRemoteEOFUpToThirdAttempt(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "https://control.example.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.URL.Path; got != "/api/desktop/v1/rooms/ws-1/agents/list" {
t.Fatalf("path = %q", got)
}
if attempts.Add(1) < 3 {
return nil, io.EOF
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"presences":[],"sessions":[]}`)),
Request: req,
}, nil
})},
})
if _, err := client.ListAgentsWithFilter(context.Background(), ListAgentsInput{
WorkspaceID: "ws-1",
}); err != nil {
t.Fatalf("ListAgentsWithFilter() error = %v", err)
}
if attempts.Load() != 3 {
t.Fatalf("attempts = %d, want 3", attempts.Load())
}
}
func TestListAgentsWithFilterDoesNotRetryTransientEOFAgainstLoopback(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
client := NewClient(Config{
BaseURL: "http://127.0.0.1:9999",
HTTPClient: &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
attempts.Add(1)
return nil, io.EOF
})},
})
_, err := client.ListAgentsWithFilter(context.Background(), ListAgentsInput{
WorkspaceID: "ws-1",
})
if !errors.Is(err, io.EOF) {
t.Fatalf("ListAgentsWithFilter() error = %v, want EOF", err)
}
if attempts.Load() != 1 {
t.Fatalf("attempts = %d, want 1", attempts.Load())
}
}
func TestClientProxiesWorkspaceAgentEndpoints(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/rooms/ws-1/agents/list":
_, _ = w.Write([]byte(`{
"presences":[{"id":1,"roomId":"ws-1","userId":"user-1","provider":"codex","status":"working"}],
"sessions":[{"id":2,"agentId":"agent-session-1","presenceId":1,"providerSessionId":"raw-session","cwd":"/workspace/ws-1","effectiveStatus":"working"}]
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1"})
agents, err := client.ListAgents(context.Background(), "ws-1")
if err != nil {
t.Fatalf("ListAgents() error = %v", err)
}
if len(agents.Presences) != 1 || agents.Presences[0].UserID != "user-1" {
t.Fatalf("presences = %#v", agents.Presences)
}
if len(agents.Sessions) != 1 || agents.Sessions[0].AgentSessionID != "agent-session-1" {
t.Fatalf("sessions = %#v", agents.Sessions)
}
if agents.Sessions[0].EffectiveStatus != "working" {
t.Fatalf("session effective status = %q", agents.Sessions[0].EffectiveStatus)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestClientProxiesRuntimeOriginWorkspaceAgentEndpoints(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/rooms/ws-1/agents/list":
if r.URL.Query().Get("session_origin") != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("session_origin = %q", r.URL.Query().Get("session_origin"))
}
_, _ = w.Write([]byte(`{"presences":[],"sessions":[]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1"})
if _, err := client.ListAgentsWithOrigin(context.Background(), "ws-1", WorkspaceAgentSessionOriginRuntime); err != nil {
t.Fatalf("ListAgentsWithOrigin() error = %v", err)
}
}
func TestClientProxiesWorkspaceAgentFilterAndDelete(t *testing.T) {
t.Parallel()
var sawList atomic.Bool
var sawDelete atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/rooms/ws-1/agents/list":
sawList.Store(true)
if got := r.URL.Query().Get("user_id"); got != "user-1" {
t.Fatalf("user_id = %q", got)
}
if got := r.URL.Query().Get("session_origin"); got != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("session_origin = %q", got)
}
_, _ = w.Write([]byte(`{"presences":[],"sessions":[]}`))
case r.Method == http.MethodDelete && r.URL.Path == "/v1/rooms/ws-1/agents/agent-session-1":
sawDelete.Store(true)
if got := r.URL.Query().Get("session_origin"); got != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("session_origin = %q", got)
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1"})
if _, err := client.ListAgentsWithFilter(context.Background(), ListAgentsInput{
WorkspaceID: "ws-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
UserID: "user-1",
}); err != nil {
t.Fatalf("ListAgentsWithFilter() error = %v", err)
}
if err := client.DeleteAgentSession(context.Background(), DeleteAgentSessionInput{
WorkspaceID: "ws-1",
AgentSessionID: "agent-session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}); err != nil {
t.Fatalf("DeleteAgentSession() error = %v", err)
}
if !sawList.Load() || !sawDelete.Load() {
t.Fatalf("saw list/delete = %v/%v", sawList.Load(), sawDelete.Load())
}
}
func TestClientDecodesProtoJSONIntegerStrings(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/rooms/ws-1/agents/list":
_, _ = w.Write([]byte(`{
"presences":[{"id":"1","roomId":"ws-1","userId":"user-1","provider":"codex","status":"working","lastHeartbeatUnixMs":"1710000000100","leaseExpiresUnixMs":"1710000060100","createdAtUnixMs":"1710000000000","updatedAtUnixMs":"1710000000200"}],
"sessions":[{"id":"2","agentSessionId":"agent-session-1","presenceId":"1","providerSessionId":"raw-session","cwd":"/workspace/ws-1","effectiveStatus":"working","createdAtUnixMs":"1710000001000","updatedAtUnixMs":"1710000002000"}]
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, UserID: "user-1"})
agents, err := client.ListAgents(context.Background(), "ws-1")
if err != nil {
t.Fatalf("ListAgents() error = %v", err)
}
if agents.Presences[0].ID != 1 || agents.Sessions[0].ID != 2 || agents.Sessions[0].PresenceID != 1 {
t.Fatalf("decoded agents = %#v", agents)
}
if agents.Presences[0].LastHeartbeatUnixMS != 1710000000100 ||
agents.Presences[0].LeaseExpiresUnixMS != 1710000060100 ||
agents.Presences[0].CreatedAtUnixMS != 1710000000000 ||
agents.Presences[0].UpdatedAtUnixMS != 1710000000200 {
t.Fatalf("decoded presence timestamps = %#v", agents.Presences[0])
}
if agents.Sessions[0].EffectiveStatus != "working" {
t.Fatalf("decoded session effective status = %q", agents.Sessions[0].EffectiveStatus)
}
}
func TestWorkspaceAgentSessionUnmarshalContextBackedSession(t *testing.T) {
t.Parallel()
var snapshot WorkspaceAgentSnapshot
if err := json.Unmarshal([]byte(`{
"sessions": [{
"agentId": "agent-session-1",
"provider": "codex",
"userId": "user-1",
"providerSessionId": "codex-session-1",
"sessionOrigin": "WORKSPACE_AGENT_SESSION_ORIGIN_RUNTIME",
"cwd": "/workspace",
"title": "Implement feature",
"turnPhase": "started",
"effectiveStatus": "working",
"startedAtUnixMs": "1710000000000",
"createdAtUnixMs": "1710000001000",
"updatedAtUnixMs": "1710000002000"
}]
}`), &snapshot); err != nil {
t.Fatalf("unmarshal snapshot: %v", err)
}
if len(snapshot.Sessions) != 1 {
t.Fatalf("sessions = %#v", snapshot.Sessions)
}
session := snapshot.Sessions[0]
if got := session.AgentSessionID; got != "agent-session-1" {
t.Fatalf("AgentSessionID = %q", got)
}
if got := session.Provider; got != "codex" {
t.Fatalf("Provider = %q", got)
}
if got := session.SessionOrigin; got != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("SessionOrigin = %q", got)
}
if got := session.UserID; got != "user-1" {
t.Fatalf("UserID = %q", got)
}
if got := session.StartedAtUnixMS; got != 1710000000000 {
t.Fatalf("StartedAtUnixMS = %d", got)
}
if got := session.CreatedAtUnixMS; got != 1710000001000 {
t.Fatalf("CreatedAtUnixMS = %d", got)
}
if got := session.UpdatedAtUnixMS; got != 1710000002000 {
t.Fatalf("UpdatedAtUnixMS = %d", got)
}
}
func TestWorkspaceAgentSessionUnmarshalProtoSessionStatusFallback(t *testing.T) {
t.Parallel()
var snapshot WorkspaceAgentSnapshot
if err := json.Unmarshal([]byte(`{
"sessions": [{
"agent_id": "agent-session-1",
"provider": "claude-code",
"user_id": "user-1",
"provider_session_id": "provider-session-1",
"session_origin": "WORKSPACE_AGENT_SESSION_ORIGIN_RUNTIME",
"cwd": "/workspace",
"title": "Look around",
"status": "working",
"started_at_unix_ms": "1710000000000",
"created_at_unix_ms": "1710000001000",
"updated_at_unix_ms": "1710000002000"
}]
}`), &snapshot); err != nil {
t.Fatalf("unmarshal snapshot: %v", err)
}
if len(snapshot.Sessions) != 1 {
t.Fatalf("sessions = %#v", snapshot.Sessions)
}
session := snapshot.Sessions[0]
if got := session.AgentSessionID; got != "agent-session-1" {
t.Fatalf("AgentSessionID = %q", got)
}
if got := session.ProviderSessionID; got != "provider-session-1" {
t.Fatalf("ProviderSessionID = %q", got)
}
if got := session.SessionOrigin; got != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("SessionOrigin = %q", got)
}
if got := session.Status; got != "working" {
t.Fatalf("Status = %q", got)
}
if got := session.EffectiveStatus; got != "working" {
t.Fatalf("EffectiveStatus = %q", got)
}
if got := session.StartedAtUnixMS; got != 1710000000000 {
t.Fatalf("StartedAtUnixMS = %d", got)
}
if got := session.CreatedAtUnixMS; got != 1710000001000 {
t.Fatalf("CreatedAtUnixMS = %d", got)
}
if got := session.UpdatedAtUnixMS; got != 1710000002000 {
t.Fatalf("UpdatedAtUnixMS = %d", got)
}
}
func TestClientGetsGlobalAgentActivityFilterOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet || req.URL.Path != "/v2/agent-activity/filter-options" {
t.Fatalf("request = %s %s", req.Method, req.URL.Path)
}
if got := req.Header.Get("Cookie"); got != "session=test" {
t.Fatalf("cookie = %q", got)
}
_, _ = w.Write([]byte(`{"rooms":[{"roomId":"room-1","name":"Room 1"}],"sessionOwners":[],"agents":[],"timeBounds":{"minActivityAtUnixMs":"10","maxActivityAtUnixMs":"20","serverNowUnixMs":"30"}}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, SessionCookie: "session=test"})
options, err := client.GetGlobalAgentActivityFilterOptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(options.Rooms) != 1 || options.Rooms[0].RoomID != "room-1" || options.TimeBounds.ServerNowUnixMS != 30 {
t.Fatalf("options = %#v", options)
}
}
func TestClientListsGlobalAgentActivitySessionsWithFilters(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet || req.URL.Path != "/v2/agent-activity/sessions" {
t.Fatalf("request = %s %s", req.Method, req.URL.Path)
}
query := req.URL.Query()
if got := query["roomIds"]; !reflect.DeepEqual(got, []string{"room-1", "room-2"}) {
t.Fatalf("roomIds = %#v", got)
}
if got := query.Get("sessionOwnerUserIds"); got != "owner-1" {
t.Fatalf("sessionOwnerUserIds = %q", got)
}
if got := query.Get("agentKeys"); got != "target:codex" {
t.Fatalf("agentKeys = %q", got)
}
if got := query.Get("activityFromUnixMs"); got != "100" {
t.Fatalf("activityFromUnixMs = %q", got)
}
if got := query.Get("activityToUnixMs"); got != "200" {
t.Fatalf("activityToUnixMs = %q", got)
}
_, _ = w.Write([]byte(`{"items":[{"room":{"roomId":"room-1","name":"Room 1"},"workspaceId":"workspace-1","agentSessionId":"session-1","sessionOwner":{"userId":"owner-1"},"agent":{"agentKey":"target:codex","agentTargetId":"codex"},"status":"working","title":"Task","activityAtUnixMs":"150"}],"truncated":true}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL})
reply, err := client.ListGlobalAgentActivitySessions(context.Background(), ListGlobalAgentActivitySessionsInput{
RoomIDs: []string{" room-1 ", "room-2", "room-1"},
SessionOwnerUserIDs: []string{"owner-1"},
AgentKeys: []string{"target:codex"},
ActivityFromUnixMS: 100,
ActivityToUnixMS: 200,
})
if err != nil {
t.Fatal(err)
}
if len(reply.Items) != 1 || reply.Items[0].AgentSessionID != "session-1" || !reply.Truncated {
t.Fatalf("reply = %#v", reply)
}
}
func TestClientRejectsUnfilteredGlobalAgentActivityRequest(t *testing.T) {
client := NewClient(Config{})
_, err := client.ListGlobalAgentActivitySessions(context.Background(), ListGlobalAgentActivitySessionsInput{})
if err == nil || !strings.Contains(err.Error(), "at least one global agent activity filter is required") {
t.Fatalf("error = %v, want required filter error", err)
}
}