From 9ba8ffac66917342b18d8dee3c9235d092255789 Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 17:40:24 +0800 Subject: [PATCH 1/7] fix(base): improve field creation and query guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Document batch field creation in shortcut help and the delivered Base skill. 2. Choose field types from stored values instead of business-purpose names. 3. Add generic common filter values to the data-query quick guide with contract tests. 说明: - Combines the accepted fixes for base_table_096, base_table_028, and base_table_087; MR 1275 contributes round 4 only. ```ai-signature 改动范围: Base field-create 帮助与 Skill 指南、data-query 快速指南,以及对应的 shortcuts/base 契约测试 思考过程: 保留三个实验的最终通用规则,合并 public main 上新增的字段读回提示,并排除没有 benchmark 支撑的 MR 1275 round6 改动原因: 让代理发现批量字段接口、按存储值选择字段类型,并用常见通用形状构造 data-query 过滤条件 Break Change: 否 ``` Co-authored-by: BASE Infra Harness AI-SHA256: a53fdb6b6b82d74e4deff5e6d32591ec897cd2dc6ad662885c961a27e67d4666 --- shortcuts/base/base_shortcuts_test.go | 33 +++++++++++++- .../base/data_query_guide_contract_test.go | 45 +++++++++++++++++++ shortcuts/base/field_create.go | 5 ++- skills/lark-base/SKILL.md | 2 +- .../references/lark-base-data-query-guide.md | 26 +++++++++++ .../references/lark-base-field-create.md | 17 ++++--- 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 shortcuts/base/data_query_guide_contract_test.go diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index bc3e9441b1..31a2baba9b 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -246,10 +246,41 @@ func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) { } } -func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) { +func TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) { + tips := strings.Join(BaseFieldCreate.Tips, "\n") + for _, want := range []string{ + "value being stored", + "not from the field name or business purpose", + "use style only to format that type", + "formula, lookup, link, workflow, or automation", + "derived, related, or automatically maintained behavior", + } { + if !strings.Contains(tips, want) { + t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips) + } + } + lowerTips := strings.ToLower(tips) + for _, purposeExample := range []string{"translation field", "phone field", "email field"} { + if strings.Contains(lowerTips, purposeExample) { + t.Fatalf("field-create tips should express a general rule instead of enumerating %q, got:\n%s", purposeExample, tips) + } + } +} + +func TestBaseFieldCreateHelpDocumentsBatchAndHidesReadGuideFlag(t *testing.T) { parent := &cobra.Command{Use: "base"} BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) cmd := parent.Commands()[0] + if !strings.Contains(cmd.Short, "one or more fields") { + t.Fatalf("help should describe creating one or more fields, got %q", cmd.Short) + } + jsonFlag := cmd.Flags().Lookup("json") + if jsonFlag == nil { + t.Fatal("flag json must exist") + } + if !strings.Contains(jsonFlag.Usage, "JSON object or non-empty array") { + t.Fatalf("json flag help should document object and array input, got %q", jsonFlag.Usage) + } if cmd.Flags().Lookup("i-have-read-guide") == nil { t.Fatalf("flag i-have-read-guide must exist for runtime validation") } diff --git a/shortcuts/base/data_query_guide_contract_test.go b/shortcuts/base/data_query_guide_contract_test.go new file mode 100644 index 0000000000..9b622c86bd --- /dev/null +++ b/shortcuts/base/data_query_guide_contract_test.go @@ -0,0 +1,45 @@ +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestDataQueryQuickGuideCoversCommonFiltersWithoutBecomingFullReference(t *testing.T) { + const guidePath = "../../skills/lark-base/references/lark-base-data-query-guide.md" + content, err := vfs.ReadFile(guidePath) + if err != nil { + t.Fatalf("read data-query quick guide: %v", err) + } + guide := string(content) + + for _, want := range []string{ + `"conjunction":"and"`, + "`is`, `isNot`", + `["Today"]`, + `["ExactDate",""]`, + `""`, + `""`, + `""`, + "[lark-base-data-query.md](lark-base-data-query.md)", + } { + if !strings.Contains(guide, want) { + t.Fatalf("quick guide missing %q", want) + } + } + + if len(content) > 6*1024 { + t.Fatalf("quick guide grew to %d bytes; keep full DSL details in lark-base-data-query.md", len(content)) + } + + for _, forbidden := range []string{ + "base_table_", + "bytedance.larkoffice.com/base/", + } { + if strings.Contains(guide, forbidden) { + t.Fatalf("quick guide must use generic placeholders, found %q", forbidden) + } + } +} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index 121117627e..47bbd06146 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -12,19 +12,20 @@ import ( var BaseFieldCreate = common.Shortcut{ Service: "base", Command: "+field-create", - Description: "Create a field", + Description: "Create one or more fields", Risk: "write", Scopes: []string{"base:field:create"}, AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "json", Desc: "field property JSON object or non-empty array of field objects", Required: true}, {Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true}, }, Tips: []string{ `Example text: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"text"}'`, `Example select: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, + `Choose a supported field type from the value being stored, not from the field name or business purpose; use style only to format that type, and explore formula, lookup, link, workflow, or automation only for explicitly requested derived, related, or automatically maintained behavior.`, "Agent hint: use the lark-base skill's field-create guide for usage and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 19dcba9209..f5f31cf5cc 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -52,7 +52,7 @@ metadata: | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | -| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | +| 创建/更新字段 | `+field-create` / `+field-update` | 同一表创建多个字段时,一次向 `+field-create --json` 传字段对象数组,不要逐字段调用;必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 | diff --git a/skills/lark-base/references/lark-base-data-query-guide.md b/skills/lark-base/references/lark-base-data-query-guide.md index d20bf4dbcd..8aa5589da3 100644 --- a/skills/lark-base/references/lark-base-data-query-guide.md +++ b/skills/lark-base/references/lark-base-data-query-guide.md @@ -42,6 +42,32 @@ lark-cli base +data-query \ --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"filters":{"type":1,"conjunction":"and","conditions":[{"field_name":"Status","operator":"is","value":["Done"]}]},"shaper":{"format":"flat"}}' ``` +## Common filter values + +Use a flat filter group with `"type":1`, `"conjunction":"and"` or `"or"`, and a `conditions` array. + +| Field type | Common operators | `value` shape | +|---|---|---| +| select | `is`, `isNot` | exactly one option name, e.g. `[""]` | +| datetime / created_at / updated_at | `is`, `isGreater`, `isLess` | relative date, e.g. `["Today"]`, or exact date, e.g. `["ExactDate",""]` | + +For `isEmpty` / `isNotEmpty`, pass an empty array: `"value":[]`. + +Combine a date boundary and a status exclusion: + +```json +{ + "filters": { + "type": 1, + "conjunction": "and", + "conditions": [ + {"field_name": "", "operator": "isLess", "value": ["Today"]}, + {"field_name": "", "operator": "isNot", "value": [""]} + ] + } +} +``` + Use `tableName` when the table ID is unavailable but the table name is known: ```bash diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 4771458984..589ace04d4 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -创建一个字段。 +创建一个或多个字段;同一表的多个字段用一次 JSON 数组输入,不要逐字段调用。 ## Agent 最小工作流 @@ -29,6 +29,12 @@ lark-cli base +field-create \ --base-token \ --table-id \ --json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}' + +# 多个字段复用相同字段 JSON 形状,一次传非空数组 +lark-cli base +field-create \ + --base-token \ + --table-id \ + --json '[{"name":"备注","type":"text"},{"name":"优先级","type":"select","multiple":false,"options":[{"name":"高"},{"name":"低"}]}]' ``` ## 参数 @@ -37,7 +43,7 @@ lark-cli base +field-create \ |------|------|------| | `--base-token ` | 是 | Base Token | | `--table-id ` | 是 | 表 ID 或表名 | -| `--json ` | 是 | 字段属性 JSON 对象 | +| `--json ` | 是 | 单个字段 JSON 对象,或多个字段对象组成的非空数组 | ## API 入参详情 **HTTP 方法和路径:** @@ -48,8 +54,9 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## JSON 值规范 -- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。 -- 顶层最少包含:`name`、`type`。 +- `--json` 接受单个字段 **JSON 对象**,也接受多个字段对象组成的非空数组;不要再套 `fields` 等外层对象。 +- 数组按顺序创建字段,遇到首个失败即停止且不自动回滚已创建字段;需要原子写入时不要假设数组具备事务语义。 +- 每个字段对象最少包含:`name`、`type`。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。 - 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;`datetime` / `user` 的动态填充用 `$slot`。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。 - `type` 不同,必填子字段不同: @@ -86,7 +93,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## 返回重点 -- 返回 `field` 和 `created: true`。 +- 单字段返回 `field` 和 `created: true`;多字段返回 `fields`、`total` 和 `created: true`。 - 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 - 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 From c88ec5481bb3183e7fd78a5ae3205666f6ac4cdf Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 18:01:10 +0800 Subject: [PATCH 2/7] test(base): strengthen guidance contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Assert every common filter fragment introduced by the data-query quick guide. 2. Keep the field-create argument table compliant with markdown table spacing. ```ai-signature 改动范围: data-query 指南契约测试与 field-create Markdown 表格后的空行 思考过程: 逐条核对 CodeRabbit 建议,只补会防止新增指南片段回退的断言和确定性的 MD058 格式问题,不改生产提示语义 改动原因: 关闭 PR 2114 的两条有效自动审查意见并保持变更可回归 Break Change: 否 ``` Co-authored-by: BASE Infra Harness AI-SHA256: 81096f474001f2c9c59af48ee64150f205101710ebcf3b909ab9d300a019f46f --- shortcuts/base/data_query_guide_contract_test.go | 7 +++++++ skills/lark-base/references/lark-base-field-create.md | 1 + 2 files changed, 8 insertions(+) diff --git a/shortcuts/base/data_query_guide_contract_test.go b/shortcuts/base/data_query_guide_contract_test.go index 9b622c86bd..12aa449c75 100644 --- a/shortcuts/base/data_query_guide_contract_test.go +++ b/shortcuts/base/data_query_guide_contract_test.go @@ -16,10 +16,17 @@ func TestDataQueryQuickGuideCoversCommonFiltersWithoutBecomingFullReference(t *t guide := string(content) for _, want := range []string{ + `"type":1`, `"conjunction":"and"`, + `"conditions"`, "`is`, `isNot`", + "`isLess`", + "`isEmpty`", + "`isNotEmpty`", + `"value":[]`, `["Today"]`, `["ExactDate",""]`, + `[""]`, `""`, `""`, `""`, diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 589ace04d4..894959d862 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -44,6 +44,7 @@ lark-cli base +field-create \ | `--base-token ` | 是 | Base Token | | `--table-id ` | 是 | 表 ID 或表名 | | `--json ` | 是 | 单个字段 JSON 对象,或多个字段对象组成的非空数组 | + ## API 入参详情 **HTTP 方法和路径:** From bfbe90db6efeafdbceaf2420622482c16fdb0d3b Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 19:43:41 +0800 Subject: [PATCH 3/7] fix(base): generalize data-query filter guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replace the date-and-status scenario template with reusable Condition.value shape rules. 2. Update the contract test to require relative-date guidance and reject evaluation-shaped placehold ers. ```ai-signature 改动范围: lark-base data-query quick guide 与对应 shortcuts/base 契约测试 思考过程: 保留 select、datetime、empty 的通用 value shape,删除日期字段和状态字段组合模板,避免将 base_table_087 的解题路径固化到公共指南 改动原因: benchmark 显示当前文案能引导目标题,但组合示例与测试过度贴合单题,需要收敛为跨场景可复用的不变量 Break Change: 否 ``` Co-authored-by: BASE Infra Harness AI-SHA256: 7b6916f05dccf6489dce65610fe757f7b346bcd0f8f5500cfbfb1f30c60471f3 --- .../base/data_query_guide_contract_test.go | 26 +++++++++-------- .../references/lark-base-data-query-guide.md | 28 ++++--------------- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/shortcuts/base/data_query_guide_contract_test.go b/shortcuts/base/data_query_guide_contract_test.go index 12aa449c75..2c02c62601 100644 --- a/shortcuts/base/data_query_guide_contract_test.go +++ b/shortcuts/base/data_query_guide_contract_test.go @@ -7,32 +7,30 @@ import ( "github.com/larksuite/cli/internal/vfs" ) -func TestDataQueryQuickGuideCoversCommonFiltersWithoutBecomingFullReference(t *testing.T) { +func TestDataQueryQuickGuideCoversConditionValueShapesWithoutScenarioTemplate(t *testing.T) { const guidePath = "../../skills/lark-base/references/lark-base-data-query-guide.md" content, err := vfs.ReadFile(guidePath) if err != nil { t.Fatalf("read data-query quick guide: %v", err) } guide := string(content) + normalizedGuide := strings.Join(strings.Fields(guide), " ") for _, want := range []string{ - `"type":1`, - `"conjunction":"and"`, - `"conditions"`, - "`is`, `isNot`", + "Common `Condition.value` shapes", + "`is` / `isNot`", + "exactly one option name", + "`isGreater`", "`isLess`", "`isEmpty`", "`isNotEmpty`", - `"value":[]`, + "uses `[]`", `["Today"]`, `["ExactDate",""]`, - `[""]`, - `""`, - `""`, - `""`, + "Use relative date keywords only for relative requests", "[lark-base-data-query.md](lark-base-data-query.md)", } { - if !strings.Contains(guide, want) { + if !strings.Contains(normalizedGuide, want) { t.Fatalf("quick guide missing %q", want) } } @@ -44,9 +42,13 @@ func TestDataQueryQuickGuideCoversCommonFiltersWithoutBecomingFullReference(t *t for _, forbidden := range []string{ "base_table_", "bytedance.larkoffice.com/base/", + "Combine a date boundary and a status exclusion", + `""`, + `""`, + `""`, } { if strings.Contains(guide, forbidden) { - t.Fatalf("quick guide must use generic placeholders, found %q", forbidden) + t.Fatalf("quick guide must remain generic, found %q", forbidden) } } } diff --git a/skills/lark-base/references/lark-base-data-query-guide.md b/skills/lark-base/references/lark-base-data-query-guide.md index 8aa5589da3..0ea15ff6fc 100644 --- a/skills/lark-base/references/lark-base-data-query-guide.md +++ b/skills/lark-base/references/lark-base-data-query-guide.md @@ -44,29 +44,11 @@ lark-cli base +data-query \ ## Common filter values -Use a flat filter group with `"type":1`, `"conjunction":"and"` or `"or"`, and a `conditions` array. - -| Field type | Common operators | `value` shape | -|---|---|---| -| select | `is`, `isNot` | exactly one option name, e.g. `[""]` | -| datetime / created_at / updated_at | `is`, `isGreater`, `isLess` | relative date, e.g. `["Today"]`, or exact date, e.g. `["ExactDate",""]` | - -For `isEmpty` / `isNotEmpty`, pass an empty array: `"value":[]`. - -Combine a date boundary and a status exclusion: - -```json -{ - "filters": { - "type": 1, - "conjunction": "and", - "conditions": [ - {"field_name": "", "operator": "isLess", "value": ["Today"]}, - {"field_name": "", "operator": "isNot", "value": [""]} - ] - } -} -``` +Common `Condition.value` shapes: select `is` / `isNot` uses exactly one option +name; datetime `is` / `isGreater` / `isLess` uses `["Today"]` or +`["ExactDate",""]`; `isEmpty` / `isNotEmpty` uses `[]`. +Use relative date keywords only for relative requests; see +[lark-base-data-query.md](lark-base-data-query.md) for other field types and operators. Use `tableName` when the table ID is unavailable but the table name is known: From db0506bb4047c179e096cc6da6529b7cd34c32fe Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 21:24:05 +0800 Subject: [PATCH 4/7] fix(base): generalize creation guidance --- shortcuts/base/base_shortcuts_test.go | 19 ++++++--- shortcuts/base/base_skill_contract_test.go | 47 ++++++++++++++++++++++ shortcuts/base/field_create.go | 3 +- skills/lark-base/SKILL.md | 1 + 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 shortcuts/base/base_skill_contract_test.go diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 31a2baba9b..de1ed1acca 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -249,20 +249,29 @@ func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) { func TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) { tips := strings.Join(BaseFieldCreate.Tips, "\n") for _, want := range []string{ + "+field-create defines storage schema only", + "a documented field type", "value being stored", - "not from the field name or business purpose", + "never from the field name or business purpose", "use style only to format that type", + "Do not probe source code, web search, or raw OpenAPI", "formula, lookup, link, workflow, or automation", - "derived, related, or automatically maintained behavior", + "only when the user explicitly requests", + "automatically populated, synchronized, or backfilled values", } { if !strings.Contains(tips, want) { t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips) } } lowerTips := strings.ToLower(tips) - for _, purposeExample := range []string{"translation field", "phone field", "email field"} { - if strings.Contains(lowerTips, purposeExample) { - t.Fatalf("field-create tips should express a general rule instead of enumerating %q, got:\n%s", purposeExample, tips) + for _, caseArtifact := range []string{ + "base_table_", + "larkoffice.com/base/", + "grading_pass_rate", + "benchmark", + } { + if strings.Contains(lowerTips, caseArtifact) { + t.Fatalf("field-create tips should remain generic, found %q:\n%s", caseArtifact, tips) } } } diff --git a/shortcuts/base/base_skill_contract_test.go b/shortcuts/base/base_skill_contract_test.go new file mode 100644 index 0000000000..8c67297315 --- /dev/null +++ b/shortcuts/base/base_skill_contract_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) { + const skillPath = "../../skills/lark-base/SKILL.md" + content, err := vfs.ReadFile(skillPath) + if err != nil { + t.Fatalf("read lark-base skill: %v", err) + } + + skill := string(content) + normalizedSkill := strings.Join(strings.Fields(skill), " ") + for _, want := range []string{ + "用户要求“新增/创建”时", + "本轮 create 返回的对象、ID 或数量", + "同名目标已存在时报告冲突", + "不能把已有资源算作本轮新增", + "不能静默复用或更新", + "对每类资源只做一次必要盘点", + "支持批量时使用批量创建", + "继续配置本轮返回的 ID", + "明确要求确保存在、复用或更新", + } { + if !strings.Contains(normalizedSkill, want) { + t.Fatalf("lark-base skill missing %q", want) + } + } + + for _, forbidden := range []string{ + "base_table_", + "larkoffice.com/base/", + "grading_pass_rate", + } { + if strings.Contains(skill, forbidden) { + t.Fatalf("lark-base skill must remain generic, found %q", forbidden) + } + } +} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index 47bbd06146..f8b2d4cd46 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -25,7 +25,8 @@ var BaseFieldCreate = common.Shortcut{ Tips: []string{ `Example text: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"text"}'`, `Example select: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, - `Choose a supported field type from the value being stored, not from the field name or business purpose; use style only to format that type, and explore formula, lookup, link, workflow, or automation only for explicitly requested derived, related, or automatically maintained behavior.`, + `+field-create defines storage schema only: choose a documented field type from the value being stored, never from the field name or business purpose, and use style only to format that type.`, + `Do not probe source code, web search, or raw OpenAPI for a purpose-named field type. Explore formula, lookup, link, workflow, or automation only when the user explicitly requests derived, related, automatically populated, synchronized, or backfilled values.`, "Agent hint: use the lark-base skill's field-create guide for usage and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index f5f31cf5cc..39fbea920e 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -105,6 +105,7 @@ metadata: ## 写入前置规则 - 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。 +- 严格区分动作语义:用户要求“新增/创建”时,必须用本轮 create 返回的对象、ID 或数量确认完成;同名目标已存在时报告冲突,不能把已有资源算作本轮新增,也不能静默复用或更新。复合创建任务对每类资源只做一次必要盘点,支持批量时使用批量创建,并继续配置本轮返回的 ID;只有用户明确要求确保存在、复用或更新时,才操作同名已有资源。 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 - 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 From a1f350e4d4510da7a7b9cdec7c5e3598f390abe0 Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 22:48:38 +0800 Subject: [PATCH 5/7] fix(base): report partial field-create results --- shortcuts/base/base_execute_test.go | 89 +++++++++++++++++++ shortcuts/base/base_skill_contract_test.go | 48 +++++++++- .../base/data_query_guide_contract_test.go | 5 +- shortcuts/base/field_ops.go | 60 +++++++++++++ skills/lark-base/SKILL.md | 2 +- .../references/lark-base-field-create.md | 2 +- .../references/lark-base-field-json.md | 5 +- 7 files changed, 205 insertions(+), 6 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 5faa3739ef..353476704d 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1383,6 +1383,95 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("create array reports progress when a later field fails", func(t *testing.T) { + oldDelay := fieldCreateBatchDelay + fieldCreateBatchDelay = 0 + t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"A"`) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"B"`) + }, + Body: map[string]interface{}{ + "code": 1254090, + "msg": "field already exists", + "log_id": "202607300001", + }, + }) + + err := runShortcut(t, BaseFieldCreate, []string{ + "+field-create", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, + }, factory, stdout) + var partialErr *output.PartialFailureError + if !errors.As(err, &partialErr) { + t.Fatalf("expected partial failure error, got %T: %v", err, err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String()) + } + if envelope["ok"] != false { + t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope) + } + data, _ := envelope["data"].(map[string]interface{}) + summary, _ := data["summary"].(map[string]interface{}) + for key, want := range map[string]float64{ + "requested": 3, + "attempted": 2, + "created": 1, + "failed": 1, + "not_attempted": 1, + } { + if summary[key] != want { + t.Fatalf("summary[%q]=%#v, want %v; data=%#v", key, summary[key], want, data) + } + } + + items, _ := data["items"].([]interface{}) + if len(items) != 3 { + t.Fatalf("items=%#v, want three outcomes", items) + } + created, _ := items[0].(map[string]interface{}) + createdField, _ := created["field"].(map[string]interface{}) + if created["status"] != "created" || createdField["id"] != "fld_a" { + t.Fatalf("created item=%#v", created) + } + failed, _ := items[1].(map[string]interface{}) + failedField, _ := failed["field"].(map[string]interface{}) + if failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" { + t.Fatalf("failed item=%#v", failed) + } + if !strings.Contains(failed["error"].(string), "field already exists") { + t.Fatalf("failed item must include the API error: %#v", failed) + } + notAttempted, _ := items[2].(map[string]interface{}) + notAttemptedField, _ := notAttempted["field"].(map[string]interface{}) + if notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" { + t.Fatalf("not-attempted item=%#v", notAttempted) + } + if !strings.Contains(data["hint"].(string), "retry only failed or not_attempted") { + t.Fatalf("hint=%#v", data["hint"]) + } + }) + t.Run("create array with generated field recommends readback", func(t *testing.T) { oldDelay := fieldCreateBatchDelay fieldCreateBatchDelay = 0 diff --git a/shortcuts/base/base_skill_contract_test.go b/shortcuts/base/base_skill_contract_test.go index 8c67297315..3431c63386 100644 --- a/shortcuts/base/base_skill_contract_test.go +++ b/shortcuts/base/base_skill_contract_test.go @@ -26,7 +26,7 @@ func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) { "不能把已有资源算作本轮新增", "不能静默复用或更新", "对每类资源只做一次必要盘点", - "支持批量时使用批量创建", + "只有命令明确返回逐项结果时才优先使用批量创建", "继续配置本轮返回的 ID", "明确要求确保存在、复用或更新", } { @@ -45,3 +45,49 @@ func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) { } } } + +func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *testing.T) { + readNormalized := func(path string) string { + t.Helper() + content, err := vfs.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.Join(strings.Fields(string(content)), " ") + } + + skill := readNormalized("../../skills/lark-base/SKILL.md") + fieldJSON := readNormalized("../../skills/lark-base/references/lark-base-field-json.md") + fieldCreate := readNormalized("../../skills/lark-base/references/lark-base-field-create.md") + + for _, want := range []string{ + "同一表创建多个字段时,一次向 `+field-create --json` 传字段对象数组", + "只有命令明确返回逐项结果时才优先使用批量创建", + } { + if !strings.Contains(skill, want) { + t.Fatalf("lark-base skill missing %q", want) + } + } + for _, want := range []string{ + "单个字段定义始终是 JSON 对象", + "`+field-create --json` 接受一个字段对象或非空字段对象数组", + "`+field-update --json` 只接受一个字段对象", + } { + if !strings.Contains(fieldJSON, want) { + t.Fatalf("field JSON SSOT missing %q", want) + } + } + if strings.Contains(fieldJSON, "`--json` 必须是 JSON 对象") { + t.Fatal("field JSON SSOT must not apply the update-only top-level object rule to field-create") + } + for _, want := range []string{ + "遇到首个失败即停止且不自动回滚已创建字段", + "部分失败返回 `ok:false`", + "`summary`", + "`items`", + } { + if !strings.Contains(fieldCreate, want) { + t.Fatalf("field-create reference missing %q", want) + } + } +} diff --git a/shortcuts/base/data_query_guide_contract_test.go b/shortcuts/base/data_query_guide_contract_test.go index 2c02c62601..61c5600e81 100644 --- a/shortcuts/base/data_query_guide_contract_test.go +++ b/shortcuts/base/data_query_guide_contract_test.go @@ -1,3 +1,6 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + package base import ( @@ -47,7 +50,7 @@ func TestDataQueryQuickGuideCoversConditionValueShapesWithoutScenarioTemplate(t `""`, `""`, } { - if strings.Contains(guide, forbidden) { + if strings.Contains(normalizedGuide, forbidden) { t.Fatalf("quick guide must remain generic, found %q", forbidden) } } diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 8285c542ed..c944d44c84 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -168,6 +169,9 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { } data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) if err != nil { + if len(fields) > 0 { + return fieldCreatePartialFailure(runtime, bodies, fields, idx, err) + } return err } fields = append(fields, data) @@ -180,6 +184,62 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { return nil } +func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[string]interface{}, createdFields []interface{}, failedIndex int, err error) error { + items := make([]map[string]interface{}, 0, len(bodies)) + for idx, field := range createdFields { + items = append(items, map[string]interface{}{ + "index": idx, + "status": "created", + "field": field, + }) + } + + failed := map[string]interface{}{ + "index": failedIndex, + "status": "failed", + "field": fieldCreateInputIdentity(bodies[failedIndex]), + "error": err.Error(), + } + if problem, ok := errs.ProblemOf(err); ok { + failed["error_type"] = string(problem.Category) + failed["error_subtype"] = string(problem.Subtype) + if problem.Code != 0 { + failed["code"] = problem.Code + } + if problem.LogID != "" { + failed["log_id"] = problem.LogID + } + } + items = append(items, failed) + + for idx := failedIndex + 1; idx < len(bodies); idx++ { + items = append(items, map[string]interface{}{ + "index": idx, + "status": "not_attempted", + "field": fieldCreateInputIdentity(bodies[idx]), + }) + } + + return runtime.OutPartialFailure(map[string]interface{}{ + "summary": map[string]interface{}{ + "requested": len(bodies), + "attempted": failedIndex + 1, + "created": len(createdFields), + "failed": 1, + "not_attempted": len(bodies) - failedIndex - 1, + }, + "items": items, + "hint": "Some fields were already created and were not rolled back. Inspect items and retry only failed or not_attempted fields.", + }, nil) +} + +func fieldCreateInputIdentity(body map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "name": body["name"], + "type": body["type"], + } +} + func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) { bodies, err := parseObjectList(pc, raw, "json") if err != nil { diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 39fbea920e..b8a4391d23 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -105,7 +105,7 @@ metadata: ## 写入前置规则 - 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。 -- 严格区分动作语义:用户要求“新增/创建”时,必须用本轮 create 返回的对象、ID 或数量确认完成;同名目标已存在时报告冲突,不能把已有资源算作本轮新增,也不能静默复用或更新。复合创建任务对每类资源只做一次必要盘点,支持批量时使用批量创建,并继续配置本轮返回的 ID;只有用户明确要求确保存在、复用或更新时,才操作同名已有资源。 +- 严格区分动作语义:用户要求“新增/创建”时,必须用本轮 create 返回的对象、ID 或数量确认完成;同名目标已存在时报告冲突,不能把已有资源算作本轮新增,也不能静默复用或更新。复合创建任务对每类资源只做一次必要盘点;只有命令明确返回逐项结果时才优先使用批量创建,并继续配置本轮返回的 ID;只有用户明确要求确保存在、复用或更新时,才操作同名已有资源。 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 - 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 894959d862..7698083403 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -95,12 +95,12 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## 返回重点 - 单字段返回 `field` 和 `created: true`;多字段返回 `fields`、`total` 和 `created: true`。 +- 数组创建中若已有字段成功、后续字段失败,部分失败返回 `ok:false`;`summary` 给出已创建、失败和未执行数量,`items` 按输入顺序保留已创建字段及 ID、失败字段和未执行字段。只重试 `failed` / `not_attempted` 项。 - 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 - 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 ## 工作流 - 1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。 2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index e7d3b6c7a8..17ba6c20a4 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -6,8 +6,9 @@ ## 1. 顶层规则(必须遵守) -- `--json` 必须是 JSON 对象。 -- 顶层统一使用:`type` + `name` + 类型特有字段。 +- 单个字段定义始终是 JSON 对象,每个字段对象统一使用:`type` + `name` + 类型特有字段。 +- `+field-create --json` 接受一个字段对象或非空字段对象数组。 +- `+field-update --json` 只接受一个字段对象。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。 - 字段默认值使用 `default_value`,直接传对应 CellValue;支持范围只有 `text`、`number`、静态 `select`、`datetime`、`user`。清空默认值传 `null`;省略表示创建时不设置、更新时不修改。 - 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。 From 03c4beed291f118c0c3aff0df96a76e311b5521e Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 23:13:56 +0800 Subject: [PATCH 6/7] fix(base): preserve partial field-create recovery metadata --- shortcuts/base/base_execute_test.go | 37 ++++++++++++++++--- shortcuts/base/base_skill_contract_test.go | 2 + shortcuts/base/field_ops.go | 18 ++++++--- .../references/lark-base-field-create.md | 2 +- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 353476704d..cac9f271e1 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1397,7 +1397,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { }, Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "auto_number"}, }, }) reg.Register(&httpmock.Stub{ @@ -1407,9 +1407,15 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { return strings.Contains(string(body), `"name":"B"`) }, Body: map[string]interface{}{ - "code": 1254090, - "msg": "field already exists", - "log_id": "202607300001", + "code": 1254090, + "msg": "field already exists", + "error": map[string]interface{}{ + "log_id": "202607300001", + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", + "details": []interface{}{ + map[string]interface{}{"value": "choose a different field name"}, + }, + }, }, }) @@ -1417,7 +1423,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { "+field-create", "--base-token", "app_x", "--table-id", "tbl_x", - "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, + "--json", `[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, }, factory, stdout) var partialErr *output.PartialFailureError if !errors.As(err, &partialErr) { @@ -1462,14 +1468,33 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { if !strings.Contains(failed["error"].(string), "field already exists") { t.Fatalf("failed item must include the API error: %#v", failed) } + for key, want := range map[string]interface{}{ + "type": "api", + "subtype": "unknown", + "code": float64(1254090), + "hint": "choose a different field name", + "retryable": false, + "log_id": "202607300001", + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", + } { + if failed[key] != want { + t.Fatalf("failed[%q]=%#v, want %#v; failed=%#v", key, failed[key], want, failed) + } + } + if _, ok := failed["error_type"]; ok { + t.Fatalf("failed item must use canonical type/subtype fields: %#v", failed) + } notAttempted, _ := items[2].(map[string]interface{}) notAttemptedField, _ := notAttempted["field"].(map[string]interface{}) if notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" { t.Fatalf("not-attempted item=%#v", notAttempted) } - if !strings.Contains(data["hint"].(string), "retry only failed or not_attempted") { + if !strings.Contains(data["hint"].(string), "Do not retry failed items unless retryable is true") { t.Fatalf("hint=%#v", data["hint"]) } + if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil { + t.Fatalf("partial success with auto_number must recommend readback: %#v", data) + } }) t.Run("create array with generated field recommends readback", func(t *testing.T) { diff --git a/shortcuts/base/base_skill_contract_test.go b/shortcuts/base/base_skill_contract_test.go index 3431c63386..3c9e913833 100644 --- a/shortcuts/base/base_skill_contract_test.go +++ b/shortcuts/base/base_skill_contract_test.go @@ -85,6 +85,8 @@ func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *test "部分失败返回 `ok:false`", "`summary`", "`items`", + "只有 `retryable:true` 的 `failed` 项可重试", + "`not_attempted` 项应单独继续", } { if !strings.Contains(fieldCreate, want) { t.Fatalf("field-create reference missing %q", want) diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index c944d44c84..1fc6837507 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -201,14 +201,21 @@ func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[stri "error": err.Error(), } if problem, ok := errs.ProblemOf(err); ok { - failed["error_type"] = string(problem.Category) - failed["error_subtype"] = string(problem.Subtype) + failed["type"] = string(problem.Category) + failed["subtype"] = string(problem.Subtype) + failed["retryable"] = problem.Retryable if problem.Code != 0 { failed["code"] = problem.Code } + if problem.Hint != "" { + failed["hint"] = problem.Hint + } if problem.LogID != "" { failed["log_id"] = problem.LogID } + if problem.Troubleshooter != "" { + failed["troubleshooter"] = problem.Troubleshooter + } } items = append(items, failed) @@ -220,7 +227,7 @@ func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[stri }) } - return runtime.OutPartialFailure(map[string]interface{}{ + result := fieldCreateBatchResult(map[string]interface{}{ "summary": map[string]interface{}{ "requested": len(bodies), "attempted": failedIndex + 1, @@ -229,8 +236,9 @@ func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[stri "not_attempted": len(bodies) - failedIndex - 1, }, "items": items, - "hint": "Some fields were already created and were not rolled back. Inspect items and retry only failed or not_attempted fields.", - }, nil) + "hint": "Some fields were already created and were not rolled back. Do not retry failed items unless retryable is true; submit not_attempted items separately.", + }, bodies[:len(createdFields)]) + return runtime.OutPartialFailure(result, nil) } func fieldCreateInputIdentity(body map[string]interface{}) map[string]interface{} { diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 7698083403..904af84e44 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -95,7 +95,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## 返回重点 - 单字段返回 `field` 和 `created: true`;多字段返回 `fields`、`total` 和 `created: true`。 -- 数组创建中若已有字段成功、后续字段失败,部分失败返回 `ok:false`;`summary` 给出已创建、失败和未执行数量,`items` 按输入顺序保留已创建字段及 ID、失败字段和未执行字段。只重试 `failed` / `not_attempted` 项。 +- 数组创建中若已有字段成功、后续字段失败,部分失败返回 `ok:false`;`summary` 给出已创建、失败和未执行数量,`items` 按输入顺序保留已创建字段及 ID、失败字段和未执行字段。`failed` 项保留 `type`、`subtype`、`code`、`hint`、`retryable`、`log_id`、`troubleshooter`;只有 `retryable:true` 的 `failed` 项可重试,`not_attempted` 项应单独继续。 - 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 - 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 From 4d1fb9a0065b8d7c4c95aa2ab4b8c2efe40d85a8 Mon Sep 17 00:00:00 2001 From: Huarenmin Date: Thu, 30 Jul 2026 23:40:34 +0800 Subject: [PATCH 7/7] fix(base): separate partial field recovery --- shortcuts/base/base_execute_test.go | 141 +++++++++--------- shortcuts/base/base_skill_contract_test.go | 2 + shortcuts/base/field_ops.go | 14 ++ .../references/lark-base-field-create.md | 7 +- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index cac9f271e1..a9a18f583c 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1388,67 +1388,56 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { fieldCreateBatchDelay = 0 t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) - factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", - BodyFilter: func(body []byte) bool { - return strings.Contains(string(body), `"name":"A"`) - }, - Body: map[string]interface{}{ + runPartial := func(input, createdType, failedName string, failedResponse map[string]interface{}) map[string]interface{} { + t.Helper() + factory, stdout, reg := newExecuteFactory(t) + register := func(name string, response map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"`+name+`"`) }, + Body: response, + }) + } + register("A", map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "auto_number"}, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", - BodyFilter: func(body []byte) bool { - return strings.Contains(string(body), `"name":"B"`) - }, - Body: map[string]interface{}{ - "code": 1254090, - "msg": "field already exists", - "error": map[string]interface{}{ - "log_id": "202607300001", - "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", - "details": []interface{}{ - map[string]interface{}{"value": "choose a different field name"}, - }, - }, - }, - }) - - err := runShortcut(t, BaseFieldCreate, []string{ - "+field-create", - "--base-token", "app_x", - "--table-id", "tbl_x", - "--json", `[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, - }, factory, stdout) - var partialErr *output.PartialFailureError - if !errors.As(err, &partialErr) { - t.Fatalf("expected partial failure error, got %T: %v", err, err) + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": createdType}, + }) + register(failedName, failedResponse) + err := runShortcut(t, BaseFieldCreate, []string{ + "+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input, + }, factory, stdout) + var partialErr *output.PartialFailureError + if !errors.As(err, &partialErr) { + t.Fatalf("expected partial failure error, got %T: %v", err, err) + } + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String()) + } + if envelope.OK || envelope.Data == nil { + t.Fatalf("unexpected partial failure envelope: %#v", envelope) + } + return envelope.Data } - var envelope map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { - t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String()) - } - if envelope["ok"] != false { - t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope) + conflictResponse := map[string]interface{}{ + "code": 1254090, + "msg": "field already exists", + "error": map[string]interface{}{ + "log_id": "202607300001", + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", + "details": []interface{}{map[string]interface{}{"value": "choose a different field name"}}, + }, } - data, _ := envelope["data"].(map[string]interface{}) + data := runPartial(`[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "auto_number", "B", conflictResponse) summary, _ := data["summary"].(map[string]interface{}) - for key, want := range map[string]float64{ - "requested": 3, - "attempted": 2, - "created": 1, - "failed": 1, - "not_attempted": 1, - } { - if summary[key] != want { - t.Fatalf("summary[%q]=%#v, want %v; data=%#v", key, summary[key], want, data) - } + if summary["requested"] != float64(3) || summary["attempted"] != float64(2) || + summary["created"] != float64(1) || summary["failed"] != float64(1) || summary["not_attempted"] != float64(1) { + t.Fatalf("unexpected summary: %#v", summary) } items, _ := data["items"].([]interface{}) @@ -1457,15 +1446,16 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } created, _ := items[0].(map[string]interface{}) createdField, _ := created["field"].(map[string]interface{}) - if created["status"] != "created" || createdField["id"] != "fld_a" { - t.Fatalf("created item=%#v", created) - } failed, _ := items[1].(map[string]interface{}) failedField, _ := failed["field"].(map[string]interface{}) - if failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" { - t.Fatalf("failed item=%#v", failed) + notAttempted, _ := items[2].(map[string]interface{}) + notAttemptedField, _ := notAttempted["field"].(map[string]interface{}) + if created["status"] != "created" || createdField["id"] != "fld_a" || + failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" || + notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" { + t.Fatalf("unexpected item outcomes: %#v", items) } - if !strings.Contains(failed["error"].(string), "field already exists") { + if !strings.Contains(common.GetString(failed, "error"), "field already exists") { t.Fatalf("failed item must include the API error: %#v", failed) } for key, want := range map[string]interface{}{ @@ -1484,17 +1474,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { if _, ok := failed["error_type"]; ok { t.Fatalf("failed item must use canonical type/subtype fields: %#v", failed) } - notAttempted, _ := items[2].(map[string]interface{}) - notAttemptedField, _ := notAttempted["field"].(map[string]interface{}) - if notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" { - t.Fatalf("not-attempted item=%#v", notAttempted) - } if !strings.Contains(data["hint"].(string), "Do not retry failed items unless retryable is true") { t.Fatalf("hint=%#v", data["hint"]) } - if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil { + if data["field_get_recommended"] != true || data["next_step"] != "inspect_items" || data["verification_hint"] == nil { t.Fatalf("partial success with auto_number must recommend readback: %#v", data) } + + simpleData := runPartial(`[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "text", "B", conflictResponse) + if simpleData["field_get_recommended"] != false || simpleData["next_step"] != "inspect_items" { + t.Fatalf("simple-field partial failure must inspect items, not report done: %#v", simpleData) + } + + permissionData := runPartial(`[{"name":"A","type":"text"},{"name":"P","type":"text"}]`, "text", "P", map[string]interface{}{ + "code": 99991672, + "msg": "app scope not applied", + "error": map[string]interface{}{ + "permission_violations": []interface{}{map[string]interface{}{"subject": "base:field:create"}}, + }, + }) + items, _ = permissionData["items"].([]interface{}) + permissionFailure, _ := items[1].(map[string]interface{}) + missingScopes, _ := permissionFailure["missing_scopes"].([]interface{}) + if len(missingScopes) != 1 || missingScopes[0] != "base:field:create" || + permissionFailure["identity"] != "bot" || common.GetString(permissionFailure, "console_url") == "" { + t.Fatalf("permission failure must retain typed extensions: %#v", permissionFailure) + } }) t.Run("create array with generated field recommends readback", func(t *testing.T) { diff --git a/shortcuts/base/base_skill_contract_test.go b/shortcuts/base/base_skill_contract_test.go index 3c9e913833..f049411621 100644 --- a/shortcuts/base/base_skill_contract_test.go +++ b/shortcuts/base/base_skill_contract_test.go @@ -85,6 +85,8 @@ func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *test "部分失败返回 `ok:false`", "`summary`", "`items`", + "`next_step:\"inspect_items\"`", + "`missing_scopes`、`identity`、`console_url`", "只有 `retryable:true` 的 `failed` 项可重试", "`not_attempted` 项应单独继续", } { diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 1fc6837507..af95142917 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "errors" "fmt" "strings" "time" @@ -217,6 +218,18 @@ func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[stri failed["troubleshooter"] = problem.Troubleshooter } } + var permissionError *errs.PermissionError + if errors.As(err, &permissionError) { + if len(permissionError.MissingScopes) > 0 { + failed["missing_scopes"] = permissionError.MissingScopes + } + if permissionError.Identity != "" { + failed["identity"] = permissionError.Identity + } + if permissionError.ConsoleURL != "" { + failed["console_url"] = permissionError.ConsoleURL + } + } items = append(items, failed) for idx := failedIndex + 1; idx < len(bodies); idx++ { @@ -238,6 +251,7 @@ func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[stri "items": items, "hint": "Some fields were already created and were not rolled back. Do not retry failed items unless retryable is true; submit not_attempted items separately.", }, bodies[:len(createdFields)]) + result["next_step"] = "inspect_items" return runtime.OutPartialFailure(result, nil) } diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 904af84e44..6cd7693b37 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -95,9 +95,10 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## 返回重点 - 单字段返回 `field` 和 `created: true`;多字段返回 `fields`、`total` 和 `created: true`。 -- 数组创建中若已有字段成功、后续字段失败,部分失败返回 `ok:false`;`summary` 给出已创建、失败和未执行数量,`items` 按输入顺序保留已创建字段及 ID、失败字段和未执行字段。`failed` 项保留 `type`、`subtype`、`code`、`hint`、`retryable`、`log_id`、`troubleshooter`;只有 `retryable:true` 的 `failed` 项可重试,`not_attempted` 项应单独继续。 -- 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 -- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 +- 数组部分失败返回 `ok:false`、`summary` 和有序 `items`,保留已创建字段及 ID、失败项和未执行项。`failed` 项保留 `type`、`subtype`、`code`、`hint`、`retryable`、`log_id`、`troubleshooter`;权限错误还保留原错误已有的 `missing_scopes`、`identity`、`console_url`。 +- 部分失败统一返回 `next_step:"inspect_items"`;`field_get_recommended` 仅表示已创建字段是否建议读回。只有 `retryable:true` 的 `failed` 项可重试,`not_attempted` 项应单独继续。 +- 完整成功且返回 `field_get_recommended:false`、`next_step:"done"` 时,本次是简单字段创建,通常不需要立刻执行 `+field-get`。 +- `field_get_recommended:true` 表示完成当前 `next_step` 后按 `verification_hint` 读回;完整成功时 `next_step:"field_get"` 表示可直接读回。`formula`、`lookup`、`link`、`auto_number` 等字段更适合读回确认服务端最终结构。 ## 工作流