fix: 补充招聘结果与分页契约 to#85340676

This commit is contained in:
恋川
2026-08-13 13:50:18 +08:00
parent 19e19bcd2b
commit 8ee9fc3f48
4 changed files with 132 additions and 2 deletions
@@ -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
+24 -1
View File
@@ -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 创建招聘职位",
+99
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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]