From 8ee9fc3f48e209e79bf1fcab2ebd5df979309a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Thu, 13 Aug 2026 13:50:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=E6=8B=9B=E8=81=98?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8E=E5=88=86=E9=A1=B5=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=20to#85340676?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/schema_agent_examples_dry_run_test.go | 8 ++ internal/helpers/recruit.go | 25 ++++- internal/helpers/recruit_test.go | 99 +++++++++++++++++++ test/fixtures/cli-interface-baseline.txt | 2 +- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index a3b8906f..5497810d 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -274,6 +274,7 @@ type agentExampleFiles struct { markdown string json string batch string + job string binary string image string } @@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown := filepath.Join(root, "content.md") jsonFile := filepath.Join(root, "report.json") batch := filepath.Join(root, "styles.json") + job := filepath.Join(root, "job.json") binary := filepath.Join(root, "report.pdf") image := filepath.Join(root, "chart.png") for path, content := range map[string][]byte{ markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"), jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`), batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), + job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL_TIME","requiredEdu":1,"minSalary":20000,"maxSalary":35000}`), binary: []byte("%PDF-1.4\n%%EOF\n"), image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, } { @@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown: "./" + filepath.Base(markdown), json: "./" + filepath.Base(jsonFile), batch: "./" + filepath.Base(batch), + job: "./" + filepath.Base(job), binary: "./" + filepath.Base(binary), image: "./" + filepath.Base(image), } @@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin replacement = files.markdown case "contents-file": replacement = files.json + case "from": + if strings.HasSuffix(strings.ToLower(value), "job.json") { + replacement = files.job + } case "batch": if strings.HasSuffix(strings.ToLower(value), "styles.json") { replacement = files.batch diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index 315fbc39..c2756305 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -23,10 +23,29 @@ const ( ) var recruitDryRun = &contract.DryRunSpec{ - PreviewKind: contract.DryRunPreviewInvocation, + PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false, } +var ( + recruitListResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}},"hasMore":{"type":"boolean","description":"服务端是否还有下一页"},"nextCursor":{"description":"下一页游标;仅在 hasMore 为 true 时用于续查","oneOf":[{"type":"number"},{"type":"string"}]}},"additionalProperties":true}`), + } + recruitJobDetailResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`), + } + recruitCreateJobResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`), + } +) + +func recruitCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"} +} + func newRecruitCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "recruit", @@ -86,6 +105,8 @@ func newRecruitJobListCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, Description: "按条件分页查询招聘职位", DryRun: recruitDryRun, + Result: recruitListResult, + Pagination: recruitCursorPagination(), Interface: recruitMCPInterface(recruitListJobsTool), Selection: contract.SelectionSpec{ AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", @@ -130,6 +151,7 @@ func newRecruitJobGetCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, Description: "根据职位 ID 查询招聘职位详情", DryRun: recruitDryRun, + Result: recruitJobDetailResult, Interface: recruitMCPInterface(recruitGetJobTool), Selection: contract.SelectionSpec{ AgentSummary: "获取指定招聘职位的完整信息", @@ -159,6 +181,7 @@ func newRecruitJobCreateCommand() *cobra.Command { Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, Description: "从 JSON 文件创建招聘职位", DryRun: recruitDryRun, + Result: recruitCreateJobResult, Interface: recruitMCPInterface(recruitCreateJobTool), Selection: contract.SelectionSpec{ AgentSummary: "使用结构化 JSON 创建招聘职位", diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go index 274ab409..77f319ba 100644 --- a/internal/helpers/recruit_test.go +++ b/internal/helpers/recruit_test.go @@ -14,6 +14,8 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -196,6 +198,52 @@ func TestRecruitCreateValidatesJobFile(t *testing.T) { } } +func TestRecruitValidationBranches(t *testing.T) { + statuses, err := transformRecruitStatuses(" open, ,open,closed ") + if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) { + t.Fatalf("statuses = %#v, err = %v", statuses, err) + } + + negativeCursor := newRecruitJobListCommand() + negativeCursor.SetArgs([]string{"--cursor", "-1"}) + if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { + t.Fatalf("negative cursor error = %v", err) + } + + nullPath := filepath.Join(t.TempDir(), "null.json") + if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { + t.Fatalf("null job error = %v", err) + } + + base := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + } + for _, test := range []struct { + name string + mutate func(map[string]any) + want string + }{ + {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, + {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, + {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, + } { + t.Run(test.name, func(t *testing.T) { + job := make(map[string]any, len(base)) + for key, value := range base { + job[key] = value + } + test.mutate(job) + if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } +} + func TestRecruitCommandTree(t *testing.T) { root := newRecruitCommand() for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { @@ -205,3 +253,54 @@ func TestRecruitCommandTree(t *testing.T) { } } } + +func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) { + root := newRecruitCommand() + tests := []struct { + path []string + canonical string + resultField string + pagination bool + }{ + {path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true}, + {path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"}, + {path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"}, + } + for _, test := range tests { + t.Run(test.canonical, func(t *testing.T) { + leaf, _, err := root.Find(test.path) + if err != nil { + t.Fatal(err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical { + t.Fatalf("final identity = %#v, found = %v", final.Identity, ok) + } + if final.Result == nil { + t.Fatal("final Result is nil") + } + wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure} + if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) { + t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes) + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil { + t.Fatalf("unmarshal result data_schema: %v", err) + } + if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 { + t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField) + } + if test.pagination { + if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor || + final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath { + t.Fatalf("pagination = %#v, want cursor contract", final.Pagination) + } + } else if final.Pagination != nil { + t.Fatalf("pagination = %#v, want nil", final.Pagination) + } + }) + } +} diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index fab13963..31193263 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -1,7 +1,7 @@ [root] runnable: true hidden: false - commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki + commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, recruit, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent [agoal]