diff --git a/agents/base/adapter_client.go b/agents/base/adapter_client.go index 5a22a4623..ec4cebe48 100644 --- a/agents/base/adapter_client.go +++ b/agents/base/adapter_client.go @@ -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) { diff --git a/agents/base/base_test.go b/agents/base/base_test.go index 567cd3146..bdc8cc7e5 100644 --- a/agents/base/base_test.go +++ b/agents/base/base_test.go @@ -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)}} diff --git a/agents/base/dto.go b/agents/base/dto.go index f72f29280..78c7a3b54 100644 --- a/agents/base/dto.go +++ b/agents/base/dto.go @@ -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"`