package helpers import ( "bytes" "context" "encoding/json" "errors" "io" "strings" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) type pagedCommandCall struct { server string tool string args map[string]any ctx context.Context } type pagedCommandCaller struct { steps []scriptedToolStep calls []pagedCommandCall format string dry bool } func (c *pagedCommandCaller) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { copied := map[string]any{} for k, v := range args { copied[k] = v } c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied, ctx: ctx}) if len(c.steps) == 0 { return textToolResult(`{"result":{"messages":[],"hasMore":false}}`), nil } step := c.steps[len(c.calls)-1] if step.err != nil { return nil, step.err } return textToolResult(step.text), nil } func (c *pagedCommandCaller) Format() string { return c.format } func (c *pagedCommandCaller) DryRun() bool { return c.dry } func (*pagedCommandCaller) Fields() string { return "" } func (*pagedCommandCaller) JQ() string { return "" } func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, args ...string) (map[string]any, string, error) { t.Helper() return runPagedCommandTestWithSleep(t, caller, cfg, func(time.Duration) {}, args...) } func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), args ...string) (map[string]any, string, error) { t.Helper() out, stderr, err := executePagedCommandTest(t, caller, cfg, sleep, &bytes.Buffer{}, args...) if strings.TrimSpace(out) == "" { return nil, stderr, err } var parsed map[string]any if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil { t.Fatalf("stdout JSON = %q, err = %v", out, unmarshalErr) } return parsed, stderr, err } func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) { t.Helper() return executePagedCommandTestWithContext(t, context.Background(), caller, cfg, sleep, stdout, args...) } func executePagedCommandTestWithContext(t *testing.T, ctx context.Context, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) { t.Helper() oldDeps := deps oldSleep := helperSleep oldAfter := helperAfter t.Cleanup(func() { deps = oldDeps helperSleep = oldSleep helperAfter = oldAfter }) InitDeps(caller) out := stdout errOut := &bytes.Buffer{} deps.Out.w = out deps.Out.errW = errOut if sleep != nil { helperSleep = sleep helperAfter = func(d time.Duration) <-chan time.Time { sleep(d) ch := make(chan time.Time, 1) ch <- time.Now() return ch } } cmd := &cobra.Command{ Use: "paged", SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { return RunPagedMCPCommand(cmd, cfg) }, } cmd.SetContext(ctx) cmd.Flags().String("cursor", "0", "") AddPagedMCPFlags(cmd) cmd.SetErr(errOut) cmd.SetArgs(args) err := cmd.Execute() if buf, ok := out.(*bytes.Buffer); ok { return buf.String(), errOut.String(), err } return "", errOut.String(), err } func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig { if fallback == nil { fallback = func(map[string]any) error { return nil } } return PagedMCPCommandConfig{ ServerID: "chat", ToolName: "search_messages_by_time_range", ItemPath: "result.messages", CursorPath: "result.nextCursor", HasMorePath: "result.hasMore", CursorArg: "cursor", CursorKind: PagedCursorString, BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{"cursor": cursor, "limit": 2}, nil }, Fallback: fallback, } } func pagedCommandConversationMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig { cfg := pagedCommandMessagesConfig(fallback) cfg.ItemPath = "result.conversationMessagesList" cfg.AggregationMode = PagedAggregationConversationMessages return cfg } func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) { caller := &pagedCommandCaller{} fallbackCalls := 0 cfg := pagedCommandMessagesConfig(func(args map[string]any) error { fallbackCalls++ if args["cursor"] != "0" { t.Fatalf("fallback args = %#v", args) } return nil }) _, _, err := runPagedCommandTest(t, caller, cfg, "--page-limit", "2", "--max-items", "1", "--page-delay", "0") if err != nil { t.Fatal(err) } if fallbackCalls != 1 || len(caller.calls) != 0 { t.Fatalf("fallback=%d remote=%d, want fallback only", fallbackCalls, len(caller.calls)) } } func TestPagedMCPCommandRejectsInvalidConfigWhenPageAll(t *testing.T) { caller := &pagedCommandCaller{} cfg := pagedCommandMessagesConfig(nil) cfg.ServerID = " " got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all") if err == nil || !strings.Contains(err.Error(), "server is required") { t.Fatalf("result=%#v err=%v, want config error", got, err) } if got != nil { t.Fatalf("result=%#v, want no stdout", got) } if len(caller.calls) != 0 { t.Fatalf("calls=%#v, want no remote call", caller.calls) } } func TestPagedMCPCommandDryRunPrintsRequestAndSkipsRemote(t *testing.T) { caller := &pagedCommandCaller{dry: true} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "3", "--max-items", "7", "--page-delay", "0") if err != nil { t.Fatal(err) } if len(caller.calls) != 0 { t.Fatalf("calls=%#v, want no remote call", caller.calls) } if got["dry_run"] != true { t.Fatalf("dry_run=%#v, want true", got["dry_run"]) } request := got["request"].(map[string]any) if request["server"] != "chat" || request["name"] != "search_messages_by_time_range" { t.Fatalf("request=%#v", request) } args := request["args"].(map[string]any) if args["cursor"] != "0" || args["limit"].(float64) != 2 { t.Fatalf("args=%#v", args) } paging := got["paging"].(map[string]any) if paging["pageAll"] != true || paging["pageLimit"].(float64) != 3 || paging["maxItems"].(float64) != 7 || paging["pageDelay"].(float64) != 0 { t.Fatalf("paging=%#v", paging) } } func TestPagedMCPCommandValidateConfigRejectsMissingRequiredFields(t *testing.T) { tests := []struct { name string edit func(*PagedMCPCommandConfig) want string }{ { name: "server", edit: func(cfg *PagedMCPCommandConfig) { cfg.ServerID = "" }, want: "server is required", }, { name: "tool", edit: func(cfg *PagedMCPCommandConfig) { cfg.ToolName = "" }, want: "tool is required", }, { name: "item path", edit: func(cfg *PagedMCPCommandConfig) { cfg.ItemPath = "" }, want: "item path is required", }, { name: "cursor path", edit: func(cfg *PagedMCPCommandConfig) { cfg.CursorPath = "" }, want: "cursor path is required", }, { name: "hasMore path", edit: func(cfg *PagedMCPCommandConfig) { cfg.HasMorePath = "" }, want: "hasMore path is required", }, { name: "cursor arg", edit: func(cfg *PagedMCPCommandConfig) { cfg.CursorArg = "" }, want: "cursor arg is required", }, { name: "build args callback", edit: func(cfg *PagedMCPCommandConfig) { cfg.BuildArgs = nil }, want: "callbacks are required", }, { name: "fallback callback", edit: func(cfg *PagedMCPCommandConfig) { cfg.Fallback = nil }, want: "callbacks are required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := pagedCommandMessagesConfig(nil) tt.edit(&cfg) err := validatePagedConfig(cfg) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("err=%v, want %q", err, tt.want) } }) } } func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":true,"nextCursor":"c3"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "2", "--page-delay", "0") if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["messages"].([]any) result := got["result"].(map[string]any) paging := got["paging"].(map[string]any) if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 { t.Fatalf("result = %#v", got) } if result["hasMore"] != true || result["nextCursor"] != "c3" { t.Fatalf("result=%#v, want final page-limit cursor state", result) } if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" { t.Fatalf("call args = %#v", caller.calls) } } func TestPagedMCPCommandStringCursorAggregatesAndSyncsCompletionFields(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } result := got["result"].(map[string]any) items := result["messages"].([]any) if len(items) != 2 || result["hasMore"] != false || result["nextCursor"] != "" { t.Fatalf("result=%#v, want complete aggregate with final cursor state", result) } paging := got["paging"].(map[string]any) if paging["truncated"] != false || paging["hasMore"] != false || paging["lastCursor"] != "" { t.Fatalf("paging=%#v, want complete pagination metadata", paging) } } func TestPagedMCPCommandConversationMessagesMergeSameConversation(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"ignored","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) if len(conversations) != 1 { t.Fatalf("conversations=%#v, want one merged conversation", conversations) } conversation := conversations[0].(map[string]any) messages := conversation["messages"].([]any) if conversation["title"] != "群1" || len(messages) != 2 { t.Fatalf("conversation=%#v, want preserved title and two messages", conversation) } paging := got["paging"].(map[string]any) if paging["total"].(float64) != 2 { t.Fatalf("paging=%#v, want total message count 2", paging) } } func TestPagedMCPCommandConversationMessagesPreserveFirstConversationOrder(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid2","messages":[{"id":"m2"}]},{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid3","messages":[{"id":"m3"}]}],"hasMore":false,"nextCursor":""}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) gotIDs := []string{ conversations[0].(map[string]any)["openConversationId"].(string), conversations[1].(map[string]any)["openConversationId"].(string), conversations[2].(map[string]any)["openConversationId"].(string), } if strings.Join(gotIDs, ",") != "cid2,cid1,cid3" { t.Fatalf("conversation order=%v", gotIDs) } } func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesMessages(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "3", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) if len(conversations) != 2 { t.Fatalf("conversations=%#v, want two conversations", conversations) } secondMessages := conversations[1].(map[string]any)["messages"].([]any) paging := got["paging"].(map[string]any) if len(secondMessages) != 1 || paging["total"].(float64) != 3 || paging["truncated"] != true { t.Fatalf("result=%#v", got) } } func TestPagedMCPCommandConversationMessagesMissingListOnFinalPageIsEmpty(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"hasMore":false,"nextCursor":""}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) if len(conversations) != 0 { t.Fatalf("conversations=%#v, want empty", conversations) } } func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`}, {err: errors.New("page failed")}, }} got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") if err == nil || !strings.Contains(stderr, "pagination stopped") { t.Fatalf("err=%v stderr=%q", err, stderr) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) paging := got["paging"].(map[string]any) if len(conversations) != 1 || paging["partial"] != true || paging["itemsFetched"].(float64) != 1 { t.Fatalf("result=%#v", got) } } func TestPagedMCPCommandConversationMessagesAddErrorsOutputPartial(t *testing.T) { tests := []struct { name string response string want string }{ { name: "conversation item not object", response: `{"result":{"conversationMessagesList":["bad"],"hasMore":false,"nextCursor":""}}`, want: "conversation item must be object", }, { name: "conversation missing openConversationId", response: `{"result":{"conversationMessagesList":[{"messages":[]}],"hasMore":false,"nextCursor":""}}`, want: "missing openConversationId", }, { name: "conversation messages not array", response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":"bad"}],"hasMore":false,"nextCursor":""}}`, want: "conversation messages must be array", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}} got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0") if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want) } if !strings.Contains(stderr, "pagination stopped at page 1") { t.Fatalf("stderr=%q", stderr) } paging := got["paging"].(map[string]any) if paging["partial"] != true || paging["itemsFetched"].(float64) != 0 { t.Fatalf("paging=%#v", paging) } }) } } func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesAtConversationBoundary(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) paging := got["paging"].(map[string]any) if len(conversations) != 1 || paging["total"].(float64) != 2 || paging["truncated"] != true { t.Fatalf("result=%#v", got) } } func TestPagedMCPCommandConversationMessagesAcceptsMissingMessages(t *testing.T) { messages, err := conversationMessages(map[string]any{"openConversationId": "cid1"}) if err != nil { t.Fatal(err) } if len(messages) != 0 { t.Fatalf("messages=%#v, want empty", messages) } } func TestPagedMCPCommandConversationMessagesRejectsCorruptExistingMessages(t *testing.T) { collection := newPagedCollection(PagedMCPCommandConfig{AggregationMode: PagedAggregationConversationMessages}) collection.items = []any{map[string]any{"openConversationId": "cid1", "messages": "bad"}} collection.conversationIndex["cid1"] = 0 err := collection.Add([]any{map[string]any{"openConversationId": "cid1", "messages": []any{map[string]any{"id": "m2"}}}}) if err == nil || !strings.Contains(err.Error(), "conversation messages must be array") { t.Fatalf("err=%v, want corrupt existing messages error", err) } } func TestPagedMCPCommandResponseShapeErrors(t *testing.T) { tests := []struct { name string response string want string }{ { name: "items not array", response: `{"result":{"messages":"bad","hasMore":false}}`, want: "result.messages must be array", }, { name: "missing hasMore", response: `{"result":{"messages":[]}}`, want: "missing result.hasMore", }, { name: "hasMore not bool", response: `{"result":{"messages":[],"hasMore":"yes"}}`, want: "result.hasMore must be boolean", }, { name: "missing next cursor", response: `{"result":{"messages":[{"id":"m1"}],"hasMore":true}}`, want: "missing result.nextCursor", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want) } if got != nil { t.Fatalf("result=%#v, want no partial stdout", got) } }) } } func TestPagedMCPCommandPageDelayControlsSleep(t *testing.T) { tests := []struct { name string delay string wantSleeps []time.Duration }{ { name: "non zero delay sleeps between pages", delay: "200", wantSleeps: []time.Duration{200 * time.Millisecond}, }, { name: "zero delay skips sleep", delay: "0", wantSleeps: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, }} var sleeps []time.Duration got, _, err := runPagedCommandTestWithSleep(t, caller, pagedCommandMessagesConfig(nil), func(d time.Duration) { sleeps = append(sleeps, d) }, "--page-all", "--page-delay", tt.delay) if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["messages"].([]any) if len(items) != 2 || len(caller.calls) != 2 { t.Fatalf("items=%#v calls=%#v", items, caller.calls) } if len(sleeps) != len(tt.wantSleeps) { t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps) } for i := range tt.wantSleeps { if sleeps[i] != tt.wantSleeps[i] { t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps) } } }) } } func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["messages"].([]any) paging := got["paging"].(map[string]any) if len(items) != 1 || paging["total"].(float64) != 1 || paging["truncated"] != true { t.Fatalf("result = %#v", got) } } func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, {err: errors.New("second page should not run")}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0") if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["messages"].([]any) result := got["result"].(map[string]any) paging := got["paging"].(map[string]any) if len(caller.calls) != 1 || len(items) != 2 { t.Fatalf("calls=%#v items=%#v, want one full page", caller.calls, items) } if paging["truncated"] != true || paging["hasMore"] != true || paging["lastCursor"] != "c2" { t.Fatalf("paging=%#v, want safe page-boundary cursor", paging) } if result["hasMore"] != true || result["nextCursor"] != "c2" { t.Fatalf("result=%#v, want safe page-boundary cursor fields", result) } if _, ok := paging["truncatedWithinPage"]; ok { t.Fatalf("paging=%#v, want no within-page truncation marker", paging) } } func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["messages"].([]any) result := got["result"].(map[string]any) paging := got["paging"].(map[string]any) if len(items) != 1 || paging["lastCursor"] != "0" { t.Fatalf("result=%#v, want current-page cursor after within-page truncation", got) } if result["hasMore"] != true || result["nextCursor"] != "0" { t.Fatalf("result=%#v, want unreliable current-page cursor fields", result) } if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { t.Fatalf("paging=%#v, want unreliable resume marker", paging) } } func TestPagedCollectionTruncateReturnsFalseWhenLimitDoesNotTrim(t *testing.T) { collection := newPagedCollection(PagedMCPCommandConfig{}) if err := collection.Add([]any{map[string]any{"id": "m1"}}); err != nil { t.Fatal(err) } if collection.Truncate(0) { t.Fatal("Truncate(0) should not trim") } if collection.Truncate(1) { t.Fatal("Truncate(total) should not trim") } if collection.Total() != 1 || len(collection.Values()) != 1 { t.Fatalf("collection=%#v, want unchanged single item", collection.Values()) } } func TestPagedMCPCommandConversationMessagesMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]}],"hasMore":true,"nextCursor":"c2"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0") if err != nil { t.Fatal(err) } conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any) messages := conversations[0].(map[string]any)["messages"].([]any) paging := got["paging"].(map[string]any) if len(messages) != 1 || paging["lastCursor"] != "0" { t.Fatalf("result=%#v, want truncated conversation with current-page cursor", got) } if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false { t.Fatalf("paging=%#v, want unreliable resume marker", paging) } } func TestPagedMCPCommandPassesCommandContextToCaller(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`}, }} out, _, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, &bytes.Buffer{}, "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } if strings.TrimSpace(out) == "" || len(caller.calls) != 1 { t.Fatalf("stdout=%q calls=%#v, want one successful call", out, caller.calls) } if caller.calls[0].ctx != ctx || caller.calls[0].ctx.Err() != context.Canceled { t.Fatalf("call ctx=%#v err=%v, want canceled command context", caller.calls[0].ctx, caller.calls[0].ctx.Err()) } } func TestPagedMCPCommandPageDelayStopsWhenContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`}, }} var out bytes.Buffer stdout, stderr, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), nil, &out, "--page-all", "--page-delay", "10") if strings.TrimSpace(stdout) == "" { t.Fatal("stdout is empty, want partial pagination JSON") } if err == nil || !errors.Is(err, context.Canceled) { t.Fatalf("err=%v, want context canceled", err) } if !strings.Contains(stderr, "pagination stopped at page 2") || len(caller.calls) != 1 { t.Fatalf("stderr=%q calls=%#v, want cancellation before second call", stderr, caller.calls) } var got map[string]any if unmarshalErr := json.Unmarshal([]byte(stdout), &got); unmarshalErr != nil { t.Fatalf("stdout JSON = %q, err = %v", stdout, unmarshalErr) } paging := got["paging"].(map[string]any) if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 { t.Fatalf("paging=%#v, want partial cancellation metadata", paging) } } func TestPagedMCPCommandPropagatesAggregatedOutputErrors(t *testing.T) { tests := []struct { name string steps []scriptedToolStep args []string wantStderr string }{ { name: "normal end", steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`}, }, args: []string{"--page-all", "--page-delay", "0"}, }, { name: "max items truncation", steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`}, }, args: []string{"--page-all", "--max-items", "1", "--page-delay", "0"}, }, { name: "page limit truncation", steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, }, args: []string{"--page-all", "--page-limit", "1", "--page-delay", "0"}, }, { name: "partial result after later failure", steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {err: errors.New("page failed")}, }, args: []string{"--page-all", "--page-delay", "0"}, wantStderr: "pagination stopped at page 2", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // TC-output-error: aggregate stdout write failures must fail the command. caller := &pagedCommandCaller{steps: tt.steps} _, stderr, err := executePagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, failingWriter{}, tt.args...) if err == nil || !strings.Contains(err.Error(), "write failed") { t.Fatalf("err=%v, want propagated write failure", err) } if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) { t.Fatalf("stderr=%q, want %q", stderr, tt.wantStderr) } }) } } func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`}, {text: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`}, }} cfg := PagedMCPCommandConfig{ ServerID: "im", ToolName: "list_message_favorites", ItemPath: "result.items", CursorPath: "result.nextCursor", HasMorePath: "result.hasMore", CursorArg: "cursor", CursorKind: PagedCursorInt64, BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { return map[string]any{"cursor": int64(0), "size": "20"}, nil }, Fallback: func(map[string]any) error { return nil }, } got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0") if err != nil { t.Fatal(err) } items := got["result"].(map[string]any)["items"].([]any) if len(items) != 2 || caller.calls[1].args["cursor"] != int64(20) { t.Fatalf("items=%#v calls=%#v", items, caller.calls) } } func TestPagedMCPCommandInt64CursorRejectsNonNumericNextCursor(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":"not-a-number"}}`}, }} cfg := PagedMCPCommandConfig{ ServerID: "im", ToolName: "list_message_favorites", ItemPath: "result.items", CursorPath: "result.nextCursor", HasMorePath: "result.hasMore", CursorArg: "cursor", CursorKind: PagedCursorInt64, BuildArgs: func(cmd *cobra.Command) (map[string]any, error) { return map[string]any{"cursor": int64(0), "size": "20"}, nil }, Fallback: func(map[string]any) error { return nil }, } got, stderr, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0") if err == nil || !strings.Contains(err.Error(), "base-10 int64 string") { t.Fatalf("err=%v, want invalid int64 cursor error", err) } if !strings.Contains(stderr, "pagination stopped at page 2") { t.Fatalf("stderr=%q", stderr) } paging := got["paging"].(map[string]any) if paging["partial"] != true || paging["failedCursor"] != "not-a-number" || paging["pagesFetched"].(float64) != 1 { t.Fatalf("paging = %#v", paging) } if len(caller.calls) != 1 { t.Fatalf("calls=%#v, want no second call with cursor 0", caller.calls) } } func TestPagedMCPCommandFirstPageFailureReturnsNoPartial(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all") if err == nil || got != nil { t.Fatalf("result=%#v err=%v, want first-page error without stdout", got, err) } } func TestPagedMCPCommandLaterFailureOutputsPartial(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`}, {err: errors.New("page failed")}, }} got, stderr, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") if err == nil || !strings.Contains(stderr, "pagination stopped") { t.Fatalf("err=%v stderr=%q", err, stderr) } paging := got["paging"].(map[string]any) if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 { t.Fatalf("paging = %#v", paging) } } func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) { caller := &pagedCommandCaller{steps: []scriptedToolStep{ {text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"0"}}`}, }} got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0") if err == nil { t.Fatal("cursor cycle should return error") } paging := got["paging"].(map[string]any) if paging["partial"] != true || paging["pagesFetched"].(float64) != 1 { t.Fatalf("paging = %#v", paging) } } func TestPagedMCPCommandSetJSONPathRejectsNonObjectIntermediate(t *testing.T) { root := map[string]any{"result": "not-object"} if setJSONPath(root, "result.messages", []any{}) { t.Fatal("setJSONPath should reject a non-object intermediate") } if root["result"] != "not-object" { t.Fatalf("root=%#v, want original intermediate preserved", root) } } func TestPagedMCPCommandCursorValueKeyCoversBoundaryKinds(t *testing.T) { if got := cursorValueKey(7, PagedCursorInt64); got != "7" { t.Fatalf("int cursor key=%q, want 7", got) } if got := cursorValueKey(nil, PagedCursorString); got != "" { t.Fatalf("nil string cursor key=%q, want empty", got) } } func TestPagedMCPCommandNormalizeCursorArgCoversBoundaryKinds(t *testing.T) { tests := []struct { name string value any kind PagedCursorKind want any wantErr string }{ { name: "nil string cursor", value: nil, kind: PagedCursorString, want: "", }, { name: "int64 cursor", value: int64(9), kind: PagedCursorInt64, want: int64(9), }, { name: "int cursor", value: 10, kind: PagedCursorInt64, want: int64(10), }, { name: "numeric string cursor", value: " 11 ", kind: PagedCursorInt64, want: int64(11), }, { name: "fractional float cursor", value: 1.5, kind: PagedCursorInt64, wantErr: "must be an integer", }, { name: "unsupported cursor type", value: []string{"bad"}, kind: PagedCursorInt64, wantErr: "int64-compatible", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := normalizeCursorArg(tt.value, tt.kind) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("value=%#v err=%v, want %q", tt.value, err, tt.wantErr) } return } if err != nil { t.Fatal(err) } if got != tt.want { t.Fatalf("value=%#v got=%#v, want %#v", tt.value, got, tt.want) } }) } }