fix(base): handle paginated agent lists

This commit is contained in:
huxiangyang.cn
2026-07-21 13:13:42 +08:00
parent 55f15886c4
commit 97fcbd1cfd
3 changed files with 156 additions and 9 deletions

View File

@@ -91,19 +91,19 @@ func listTasks(ctx context.Context, rt iagents.Runtime, contextID string, page i
query["limit"] = strconv.Itoa(page.Size)
}
putQuery(query, "state", p.State)
got, err := callPayload[[]adapterTask](ctx, rt, "GET", agentRoot(p.BaseToken)+"/tasks", query, nil)
got, err := callPayload[adapterTaskList](ctx, rt, "GET", agentRoot(p.BaseToken)+"/tasks", query, nil)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out := make([]iagents.TaskSummary, 0, len(got))
for _, item := range got {
out := make([]iagents.TaskSummary, 0, len(got.Tasks))
for _, item := range got.Tasks {
summary, err := mapTaskSummary(item)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out = append(out, summary)
}
return out, iagents.PageInfo{}, nil
return out, iagents.PageInfo{HasMore: got.HasMore, NextToken: got.NextCursor}, nil
}
func cancelTask(ctx context.Context, rt iagents.Runtime, taskID string) error {
@@ -132,19 +132,19 @@ func listContexts(ctx context.Context, rt iagents.Runtime, page iagents.PagePara
query["limit"] = strconv.Itoa(page.Size)
}
putQuery(query, "status", p.Status)
got, err := callPayload[[]adapterContext](ctx, rt, "GET", agentRoot(p.BaseToken)+"/contexts", query, nil)
got, err := callPayload[adapterContextList](ctx, rt, "GET", agentRoot(p.BaseToken)+"/contexts", query, nil)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out := make([]iagents.ContextSummary, 0, len(got))
for _, item := range got {
out := make([]iagents.ContextSummary, 0, len(got.Contexts))
for _, item := range got.Contexts {
mapped, err := mapContextSummary(item)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out = append(out, mapped)
}
return out, iagents.PageInfo{}, nil
return out, iagents.PageInfo{HasMore: got.HasMore, NextToken: got.NextCursor}, nil
}
func getContext(ctx context.Context, rt iagents.Runtime, contextID string) (*iagents.ContextDetail, error) {

View File

@@ -289,6 +289,66 @@ func TestTaskHooksAndMapping(t *testing.T) {
}
}
func TestListHooksMapPaginationEnvelope(t *testing.T) {
rt := &fakeRuntime{
params: map[string]string{"base_token": "b1", "state": "done", "status": "active"},
responses: []json.RawMessage{
dataResponse(t, `{"tasks":[{"task_id":"t1","context_id":"c1","state":"done","updated_at":1710000060}],"has_more":true,"next_cursor":"task-next"}`),
dataResponse(t, `{"contexts":[{"context_id":"c1","title":"Quarterly plan","created_at":1710000000,"updated_at":1710000060}],"has_more":true,"next_cursor":"context-next"}`),
},
}
tasks, taskPage, err := assistantSpec.ListTasks.Handler(context.Background(), rt, "c1", iagents.PageParams{Size: 1})
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || taskPage != (iagents.PageInfo{HasMore: true, NextToken: "task-next"}) {
t.Fatalf("tasks=%+v page=%+v", tasks, taskPage)
}
contexts, contextPage, err := assistantSpec.ListContexts.Handler(context.Background(), rt, iagents.PageParams{Size: 1})
if err != nil {
t.Fatal(err)
}
if len(contexts) != 1 || contextPage != (iagents.PageInfo{HasMore: true, NextToken: "context-next"}) {
t.Fatalf("contexts=%+v page=%+v", contexts, contextPage)
}
}
func TestListHooksRejectMalformedPaginationEnvelope(t *testing.T) {
tests := []struct {
name string
payload string
contexts bool
}{
{name: "null task response", payload: `null`},
{name: "missing tasks", payload: `{"has_more":false}`},
{name: "missing task has_more", payload: `{"tasks":[]}`},
{name: "task cursor missing", payload: `{"tasks":[],"has_more":true}`},
{name: "unexpected task cursor", payload: `{"tasks":[],"has_more":false,"next_cursor":"next"}`},
{name: "null contexts", payload: `{"contexts":null,"has_more":false}`, contexts: true},
{name: "missing contexts", payload: `{"has_more":false}`, contexts: true},
{name: "missing context has_more", payload: `{"contexts":[]}`, contexts: true},
{name: "context cursor missing", payload: `{"contexts":[],"has_more":true}`, contexts: true},
{name: "null context cursor", payload: `{"contexts":[],"has_more":false,"next_cursor":null}`, contexts: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rt := &fakeRuntime{
params: map[string]string{"base_token": "b1"},
responses: []json.RawMessage{dataResponse(t, test.payload)},
}
var err error
if test.contexts {
_, _, err = assistantSpec.ListContexts.Handler(context.Background(), rt, iagents.PageParams{})
} else {
_, _, err = assistantSpec.ListTasks.Handler(context.Background(), rt, "c1", iagents.PageParams{})
}
problem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
})
}
}
func TestUnknownStateAndInvalidPayloadAreTyped(t *testing.T) {
for _, payload := range []string{`{"schema_version":1,"task_id":"t1","status":"paused","outputs":[]}`, `{not-json`} {
rt := &fakeRuntime{params: map[string]string{"base_token": "b1"}, responses: []json.RawMessage{dataResponse(t, payload)}}

View File

@@ -3,7 +3,11 @@
package base
import "encoding/json"
import (
"bytes"
"encoding/json"
"fmt"
)
type adapterSendRequest struct {
ContextID string `json:"context_id,omitempty"`
@@ -57,6 +61,21 @@ type adapterTask struct {
Artifacts []adapterArtifact `json:"artifacts,omitempty"`
}
type adapterTaskList struct {
Tasks []adapterTask `json:"tasks"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (l *adapterTaskList) UnmarshalJSON(data []byte) error {
tasks, hasMore, nextCursor, err := decodeAdapterList[adapterTask](data, "tasks")
if err != nil {
return err
}
*l = adapterTaskList{Tasks: tasks, HasMore: hasMore, NextCursor: nextCursor}
return nil
}
type adapterOutput struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -163,6 +182,74 @@ type adapterContext struct {
Tasks []adapterTask `json:"tasks,omitempty"`
}
type adapterContextList struct {
Contexts []adapterContext `json:"contexts"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (l *adapterContextList) UnmarshalJSON(data []byte) error {
contexts, hasMore, nextCursor, err := decodeAdapterList[adapterContext](data, "contexts")
if err != nil {
return err
}
*l = adapterContextList{Contexts: contexts, HasMore: hasMore, NextCursor: nextCursor}
return nil
}
func decodeAdapterList[T any](data []byte, itemsField string) ([]T, bool, string, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, false, "", fmt.Errorf("Base Agent list response is empty")
}
if trimmed[0] == '[' {
var items []T
if err := json.Unmarshal(trimmed, &items); err != nil {
return nil, false, "", err
}
return items, false, "", nil
}
if trimmed[0] != '{' {
return nil, false, "", fmt.Errorf("Base Agent list response must be an object")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &envelope); err != nil {
return nil, false, "", err
}
itemsRaw, ok := envelope[itemsField]
if !ok || bytes.Equal(bytes.TrimSpace(itemsRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response is missing %q", itemsField)
}
var items []T
if err := json.Unmarshal(itemsRaw, &items); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", itemsField, err)
}
hasMoreRaw, ok := envelope["has_more"]
if !ok || bytes.Equal(bytes.TrimSpace(hasMoreRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response is missing %q", "has_more")
}
var hasMore bool
if err := json.Unmarshal(hasMoreRaw, &hasMore); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", "has_more", err)
}
var nextCursor string
if nextCursorRaw, ok := envelope["next_cursor"]; ok {
if bytes.Equal(bytes.TrimSpace(nextCursorRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response %q must be a string", "next_cursor")
}
if err := json.Unmarshal(nextCursorRaw, &nextCursor); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", "next_cursor", err)
}
}
if hasMore != (nextCursor != "") {
return nil, false, "", fmt.Errorf("Base Agent list response has inconsistent pagination fields")
}
return items, hasMore, nextCursor, nil
}
type adapterBusinessError struct {
Category string `json:"category,omitempty"`
Code string `json:"code,omitempty"`