Skip to content

Commit db22813

Browse files
authored
Merge pull request #561 from stanislavHamara/pagination-for-session-messages
Pagination for session messages
2 parents 2a53138 + f5add04 commit db22813

4 files changed

Lines changed: 317 additions & 9 deletions

File tree

pkg/api/pagination.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/docker/cagent/pkg/session"
8+
)
9+
10+
type PaginationParams struct {
11+
Limit int
12+
Before string
13+
}
14+
15+
const DefaultLimit = 50
16+
17+
const MaxLimit = 200
18+
19+
func PaginateMessages(messages []session.Message, params PaginationParams) ([]session.Message, *PaginationMetadata, error) {
20+
totalCount := len(messages)
21+
22+
limit := params.Limit
23+
if limit <= 0 {
24+
limit = DefaultLimit
25+
}
26+
if limit > MaxLimit {
27+
limit = MaxLimit
28+
}
29+
30+
var beforeIndex int
31+
var err error
32+
33+
if params.Before != "" {
34+
beforeIndex, err = strconv.Atoi(params.Before)
35+
if err != nil {
36+
return nil, nil, fmt.Errorf("invalid before cursor: %w", err)
37+
}
38+
}
39+
40+
startIdx := 0
41+
var endIdx int
42+
43+
if params.Before != "" {
44+
endIdx = beforeIndex
45+
if endIdx <= 0 {
46+
return []session.Message{}, &PaginationMetadata{
47+
TotalMessages: totalCount,
48+
Limit: 0,
49+
}, nil
50+
}
51+
actualStart := max(endIdx-limit, startIdx)
52+
startIdx = actualStart
53+
} else {
54+
actualStart := max(totalCount-limit, 0)
55+
startIdx = actualStart
56+
endIdx = totalCount
57+
}
58+
59+
paginatedMessages := messages[startIdx:endIdx]
60+
61+
metadata := &PaginationMetadata{
62+
TotalMessages: totalCount,
63+
Limit: len(paginatedMessages),
64+
}
65+
66+
// Only set cursor if there are more (older) messages available
67+
if len(paginatedMessages) > 0 && startIdx > 0 {
68+
metadata.PrevCursor = strconv.Itoa(startIdx)
69+
}
70+
71+
return paginatedMessages, metadata, nil
72+
}

pkg/api/pagination_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package api
2+
3+
import (
4+
"strconv"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/cagent/pkg/chat"
12+
"github.com/docker/cagent/pkg/session"
13+
)
14+
15+
func createTestMessages(count int) []session.Message {
16+
messages := make([]session.Message, count)
17+
for i := range count {
18+
role := chat.MessageRoleUser
19+
if i%2 == 1 {
20+
role = chat.MessageRoleAssistant
21+
}
22+
messages[i] = session.Message{
23+
AgentFilename: "test.yaml",
24+
AgentName: "test",
25+
Message: chat.Message{
26+
Role: role,
27+
Content: "Message " + strconv.Itoa(i),
28+
CreatedAt: time.Now().Add(time.Duration(i) * time.Second).Format(time.RFC3339),
29+
},
30+
}
31+
}
32+
return messages
33+
}
34+
35+
func TestPaginateMessages_FirstPage(t *testing.T) {
36+
messages := createTestMessages(100)
37+
38+
params := PaginationParams{
39+
Limit: 10,
40+
}
41+
42+
paginated, meta, err := PaginateMessages(messages, params)
43+
require.NoError(t, err)
44+
assert.Len(t, paginated, 10)
45+
assert.Equal(t, 100, meta.TotalMessages)
46+
assert.Equal(t, 10, meta.Limit)
47+
assert.NotEmpty(t, meta.PrevCursor) // More older messages available
48+
49+
// Should get most recent 10 messages (for chat infinite scroll)
50+
// For 100 messages, indices 90-99 should be returned
51+
// Check that we got recent messages by verifying they're different from the old first messages
52+
assert.NotEqual(t, "Message 0", paginated[0].Message.Content) // Not the oldest message
53+
assert.NotEqual(t, "Message 9", paginated[9].Message.Content) // Not the 10th oldest message
54+
assert.Equal(t, "Message 90", paginated[0].Message.Content) // Index 90
55+
assert.Equal(t, "Message 99", paginated[9].Message.Content) // Index 99
56+
}
57+
58+
func TestPaginateMessages_WithBeforeCursorPagination(t *testing.T) {
59+
messages := createTestMessages(20) // Use smaller dataset for easier debugging
60+
61+
// Start with a page at the end (messages 10-19)
62+
endPageParams := PaginationParams{
63+
Limit: 10,
64+
Before: "20", // Get 10 messages before index 20 (which should give us 10-19)
65+
}
66+
endPage, endMeta, err := PaginateMessages(messages, endPageParams)
67+
require.NoError(t, err)
68+
69+
// Verify we got the end page
70+
assert.Len(t, endPage, 10)
71+
assert.Equal(t, "Message 10", endPage[0].Message.Content) // Index 10
72+
assert.Equal(t, "Message 19", endPage[9].Message.Content) // Index 19
73+
74+
// Get previous page using before cursor (should give us messages 0-9)
75+
prevPageParams := PaginationParams{
76+
Limit: 10,
77+
Before: endMeta.PrevCursor, // Before the end page
78+
}
79+
prevPage, prevMeta, err := PaginateMessages(messages, prevPageParams)
80+
require.NoError(t, err)
81+
82+
assert.Len(t, prevPage, 10)
83+
assert.Empty(t, prevMeta.PrevCursor) // No more older messages
84+
85+
// Should get messages 0-9
86+
assert.Equal(t, "Message 0", prevPage[0].Message.Content) // Index 0
87+
assert.Equal(t, "Message 9", prevPage[9].Message.Content) // Index 9
88+
89+
// No overlap between pages
90+
assert.NotEqual(t, endPage[0].Message.Content, prevPage[9].Message.Content)
91+
}
92+
93+
func TestPaginateMessages_WithBeforeCursor(t *testing.T) {
94+
messages := createTestMessages(100)
95+
96+
// Get a page in the middle (starting at index 50)
97+
middleCursor := strconv.Itoa(50)
98+
99+
params := PaginationParams{
100+
Limit: 10,
101+
Before: middleCursor,
102+
}
103+
104+
paginated, meta, err := PaginateMessages(messages, params)
105+
require.NoError(t, err)
106+
107+
assert.Len(t, paginated, 10)
108+
assert.NotEmpty(t, meta.PrevCursor) // There are older messages
109+
110+
// Should get 10 messages before index 50 (indices 40-49)
111+
assert.Equal(t, "Message "+strconv.Itoa(40), paginated[0].Message.Content)
112+
assert.Equal(t, "Message "+strconv.Itoa(49), paginated[9].Message.Content)
113+
}
114+
115+
func TestPaginateMessages_DefaultLimit(t *testing.T) {
116+
messages := createTestMessages(100)
117+
118+
params := PaginationParams{
119+
Limit: 0, // Should use default
120+
}
121+
122+
paginated, meta, err := PaginateMessages(messages, params)
123+
require.NoError(t, err)
124+
125+
assert.Len(t, paginated, DefaultLimit)
126+
assert.Equal(t, DefaultLimit, meta.Limit)
127+
}
128+
129+
func TestPaginateMessages_MaxLimit(t *testing.T) {
130+
messages := createTestMessages(300)
131+
132+
params := PaginationParams{
133+
Limit: 500, // Should be capped at MaxLimit
134+
}
135+
136+
paginated, meta, err := PaginateMessages(messages, params)
137+
require.NoError(t, err)
138+
139+
assert.Len(t, paginated, MaxLimit)
140+
assert.Equal(t, MaxLimit, meta.Limit)
141+
}
142+
143+
func TestPaginateMessages_EmptyMessages(t *testing.T) {
144+
messages := []session.Message{}
145+
146+
params := PaginationParams{
147+
Limit: 10,
148+
}
149+
150+
paginated, meta, err := PaginateMessages(messages, params)
151+
require.NoError(t, err)
152+
153+
assert.Empty(t, paginated)
154+
assert.Equal(t, 0, meta.TotalMessages)
155+
assert.Empty(t, meta.PrevCursor) // No messages at all
156+
}
157+
158+
func TestPaginateMessages_LastPage(t *testing.T) {
159+
messages := createTestMessages(25)
160+
161+
// Get the oldest 5 messages (using before cursor to limit to earliest messages)
162+
lastPageParams := PaginationParams{
163+
Limit: 10,
164+
Before: "5", // Before the 6th message (index 5)
165+
}
166+
lastPage, lastMeta, err := PaginateMessages(messages, lastPageParams)
167+
require.NoError(t, err)
168+
169+
assert.Len(t, lastPage, 5) // Only 5 messages (0-4)
170+
assert.Empty(t, lastMeta.PrevCursor) // No more older messages
171+
assert.Equal(t, 25, lastMeta.TotalMessages)
172+
173+
// Should get the first 5 messages
174+
assert.Equal(t, "Message 0", lastPage[0].Message.Content)
175+
assert.Equal(t, "Message 4", lastPage[4].Message.Content)
176+
}
177+
178+
func TestPaginateMessages_BeforeFirstMessage(t *testing.T) {
179+
messages := createTestMessages(10)
180+
181+
// Create cursor pointing to before first message
182+
firstCursor := strconv.Itoa(0)
183+
184+
params := PaginationParams{
185+
Limit: 10,
186+
Before: firstCursor,
187+
}
188+
189+
paginated, meta, err := PaginateMessages(messages, params)
190+
require.NoError(t, err)
191+
192+
assert.Empty(t, paginated)
193+
assert.Empty(t, meta.PrevCursor) // No messages at all
194+
}
195+
196+
func TestPaginateMessages_InvalidCursor(t *testing.T) {
197+
messages := createTestMessages(10)
198+
199+
params := PaginationParams{
200+
Limit: 10,
201+
Before: "invalid-cursor",
202+
}
203+
204+
_, _, err := PaginateMessages(messages, params)
205+
require.Error(t, err)
206+
assert.Contains(t, err.Error(), "invalid before cursor")
207+
}

pkg/api/types.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,22 @@ type SessionsResponse struct {
127127

128128
// SessionResponse represents a detailed session
129129
type SessionResponse struct {
130-
ID string `json:"id"`
131-
Title string `json:"title"`
132-
Messages []session.Message `json:"messages,omitempty"`
133-
CreatedAt time.Time `json:"created_at"`
134-
ToolsApproved bool `json:"tools_approved"`
135-
InputTokens int `json:"input_tokens"`
136-
OutputTokens int `json:"output_tokens"`
137-
WorkingDir string `json:"working_dir,omitempty"`
130+
ID string `json:"id"`
131+
Title string `json:"title"`
132+
Messages []session.Message `json:"messages,omitempty"`
133+
CreatedAt time.Time `json:"created_at"`
134+
ToolsApproved bool `json:"tools_approved"`
135+
InputTokens int `json:"input_tokens"`
136+
OutputTokens int `json:"output_tokens"`
137+
WorkingDir string `json:"working_dir,omitempty"`
138+
Pagination *PaginationMetadata `json:"pagination,omitempty"`
139+
}
140+
141+
// PaginationMetadata contains pagination information
142+
type PaginationMetadata struct {
143+
TotalMessages int `json:"total_messages"` // Total number of messages in session
144+
Limit int `json:"limit"` // Number of messages in this response
145+
PrevCursor string `json:"prev_cursor,omitempty"` // Cursor for previous page (empty if no more messages)
138146
}
139147

140148
// ResumeSessionRequest represents a request to resume a session

pkg/server/server.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"path/filepath"
1515
"sort"
16+
"strconv"
1617
"strings"
1718
"sync"
1819
"time"
@@ -951,15 +952,35 @@ func (s *Server) getSession(c echo.Context) error {
951952
return echo.NewHTTPError(http.StatusNotFound, "session not found")
952953
}
953954

955+
params := api.PaginationParams{
956+
Limit: api.DefaultLimit,
957+
Before: c.QueryParam("before"),
958+
}
959+
960+
if limitStr := c.QueryParam("limit"); limitStr != "" {
961+
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
962+
params.Limit = limit
963+
}
964+
}
965+
966+
allMessages := sess.GetAllMessages()
967+
968+
paginatedMessages, pagination, err := api.PaginateMessages(allMessages, params)
969+
if err != nil {
970+
slog.Error("Failed to paginate messages", "error", err)
971+
return echo.NewHTTPError(http.StatusBadRequest, "invalid pagination parameters: "+err.Error())
972+
}
973+
954974
sr := api.SessionResponse{
955975
ID: sess.ID,
956976
Title: sess.Title,
957977
CreatedAt: sess.CreatedAt,
958-
Messages: sess.GetAllMessages(),
978+
Messages: paginatedMessages,
959979
ToolsApproved: sess.ToolsApproved,
960980
InputTokens: sess.InputTokens,
961981
OutputTokens: sess.OutputTokens,
962982
WorkingDir: sess.WorkingDir,
983+
Pagination: pagination,
963984
}
964985

965986
return c.JSON(http.StatusOK, sr)

0 commit comments

Comments
 (0)