diff --git a/CHANGELOG.md b/CHANGELOG.md index 577ec281..326d9b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Stable Chat command compatibility** — restores the hidden migration + entries for `chat send`, `chat history`, and their `im` aliases that PR #860 + removed, preserving the v1.0.56 command surface while directing callers to + the supported `chat message send/list` commands. + ## [1.0.57-beta.1] - 2026-08-05 This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 29b9680a..b958a936 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) { func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { root := NewRootCommand() + for _, path := range []string{ + "chat send", + "chat history", + "im send", + "im history", + } { + command, remaining, err := root.Find(strings.Fields(path)) + if err != nil { + t.Fatalf("find %s: %v", path, err) + } + if len(remaining) != 0 || !command.Hidden || !command.Runnable() { + t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable()) + } + } + for _, tc := range []struct { + args []string + hint string + }{ + {args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, + {args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, + {args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, + {args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, + } { + command := NewRootCommand() + command.SilenceErrors = true + command.SilenceUsage = true + command.SetArgs(tc.args) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { + t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint) + } + } + listDirect := mustFindCommand(t, root, "chat", "message", "list-direct") for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} { if listDirect.Flags().Lookup(flag) == nil { diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 6ef9b994..e0a473da 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error } } +func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command { + command := hintSubCmd(use, hint) + // Legacy callers may still pass the old command's flags. Let the migration + // command consume them so Cobra reaches RunE and returns the replacement path. + command.DisableFlagParsing = true + return command +} + type nativeChatTargetReader struct{} func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { @@ -8166,5 +8174,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand()) + // Keep the v1.0.56 command surface recognizable while directing callers to + // the supported nested commands. The chat root's "im" alias makes these + // compatibility hints available through both chat and im. + root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send")) + root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group ")) + return root } diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 4d85258a..c9c04d76 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB }) } -func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) { +func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) { + root := newChatCommand() + if len(root.Aliases) != 1 || root.Aliases[0] != "im" { + t.Fatalf("chat aliases = %v, want [im]", root.Aliases) + } for _, tc := range []struct { path string - flag string + args []string + hint string }{ - {path: "send", flag: "--group"}, - {path: "history", flag: "--group"}, + {path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"}, + {path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group "}, } { - caller := &productExampleCaller{} - err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid") - if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) { - t.Fatalf("chat %s error = %v, want unknown command", tc.path, err) + command, remaining, err := root.Find([]string{tc.path}) + if err != nil { + t.Fatalf("find chat %s: %v", tc.path, err) } - if strings.Contains(err.Error(), "unknown flag") { - t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err) + if len(remaining) != 0 || command.Name() != tc.path { + t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining) } - if caller.calls != 0 { - t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls) + if !command.Hidden || !command.Runnable() { + t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable()) + } + root.SetArgs(tc.args) + err = root.ExecuteContext(context.Background()) + if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { + t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint) } } } diff --git a/internal/helpers/drive_transfer.go b/internal/helpers/drive_transfer.go index 47704091..2ce9dc74 100644 --- a/internal/helpers/drive_transfer.go +++ b/internal/helpers/drive_transfer.go @@ -66,6 +66,8 @@ var ( driveFileStat = (*os.File).Stat ) +var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() } + // ────────────────────────────────────────────────────────── // HTTP 状态错误 // ────────────────────────────────────────────────────────── @@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP go func() { defer wg.Done() for part := range jobs { - if runCtx.Err() != nil { + if driveWorkerContextErr(runCtx) != nil { return } if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil { diff --git a/internal/helpers/drive_transfer_test.go b/internal/helpers/drive_transfer_test.go index 217497e0..3a5c4f57 100644 --- a/internal/helpers/drive_transfer_test.go +++ b/internal/helpers/drive_transfer_test.go @@ -13,6 +13,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // ────────────────────────────────────────────────────────── @@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) { func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) { // 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。 - // 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。 - // 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。 - // transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。 - - totalSize := int64(200) - content := makeTestContent(int(totalSize)) - - origClient := driveRangeClient - t.Cleanup(func() { driveRangeClient = origClient }) - - driveRangeClient = &http.Client{ + // 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态; + // 不再依赖微秒级 timeout 与 goroutine 调度概率。 + var checks atomic.Int32 + testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error { + checks.Add(1) + return context.Canceled + }) + var requests atomic.Int32 + testseam.Swap(t, &driveRangeClient, &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - // 每次请求加小延迟,让总处理时间接近 deadline - time.Sleep(50 * time.Microsecond) - var start, end int64 - if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil { - return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil - } - if end >= int64(len(content)) { - end = int64(len(content)) - 1 - } - resp := &http.Response{ - StatusCode: http.StatusPartialContent, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))), - } - resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) - return resp, nil + requests.Add(1) + return nil, errors.New("worker context guard did not stop the request") }), + }) + + creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"} + dest := filepath.Join(t.TempDir(), "worker-context-guard.bin") + opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1} + if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil { + t.Fatalf("downloadRangedParts context guard: %v", err) } - - // 多次尝试以确保覆盖(goroutine 调度非确定性) - for attempt := 0; attempt < 50; attempt++ { - // timeout 设为约为总处理时间的50%,确保在处理过程中过期 - // 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期 - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond) - - creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"} - dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt)) - opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize} - _ = downloadRangedParts(ctx, creds, dest, totalSize, opts) - cancel() + if checks.Load() != 1 { + t.Fatalf("worker context checks = %d, want 1", checks.Load()) + } + if requests.Load() != 0 { + t.Fatalf("worker requests = %d, want 0", requests.Load()) } }