mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
codex/lark
...
feat/opt-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48f84c096 | ||
|
|
cbd6e56ac0 | ||
|
|
572eb8da41 | ||
|
|
82a099feaf | ||
|
|
51f2a70e6d | ||
|
|
237a77feb3 | ||
|
|
040ef17eae |
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,13 +47,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -74,11 +71,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -258,13 +255,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -285,11 +279,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -337,7 +331,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,30 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
@@ -1236,7 +1212,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -27,21 +27,6 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -7,7 +7,6 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
5
harness-opt/.gitignore
vendored
Normal file
5
harness-opt/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# harness-opt 只入库轻量决策记录;重的原始评测 run 不进版本库(dashboard 仍读磁盘)。
|
||||
baseline/runs/
|
||||
**/child-runs/
|
||||
verify_results/sealed-runs/
|
||||
verify_results/*-runs/
|
||||
5
harness-opt/baseline/baseline-tokens.json
Normal file
5
harness-opt/baseline/baseline-tokens.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"1": 30086,
|
||||
"2": 34616,
|
||||
"3": 31289
|
||||
}
|
||||
50
harness-opt/baseline/noise-floor.json
Normal file
50
harness-opt/baseline/noise-floor.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"k": 5,
|
||||
"metrics": {
|
||||
"success_rate": {
|
||||
"mean": 0.4666666666666666,
|
||||
"std": 0.1632993161855452,
|
||||
"k": 5,
|
||||
"band": [
|
||||
0.14006803429557624,
|
||||
0.793265299037757
|
||||
]
|
||||
},
|
||||
"mean_score": {
|
||||
"mean": 0.5111111111111111,
|
||||
"std": 0.1507184440694504,
|
||||
"k": 5,
|
||||
"band": [
|
||||
0.20967422297221028,
|
||||
0.8125479992500119
|
||||
]
|
||||
},
|
||||
"mean_context_window": {
|
||||
"mean": 31997.0,
|
||||
"std": 7166.8411203573105,
|
||||
"k": 5,
|
||||
"band": [
|
||||
17663.31775928538,
|
||||
46330.682240714625
|
||||
]
|
||||
},
|
||||
"mean_duration_ms": {
|
||||
"mean": 50188.86666666667,
|
||||
"std": 7746.3168641619595,
|
||||
"k": 5,
|
||||
"band": [
|
||||
34696.23293834275,
|
||||
65681.50039499058
|
||||
]
|
||||
},
|
||||
"mean_token": {
|
||||
"mean": 263981.06666666665,
|
||||
"std": 27890.193480385413,
|
||||
"k": 5,
|
||||
"band": [
|
||||
208200.67970589583,
|
||||
319761.45362743747
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
33
harness-opt/baseline/summary.json
Normal file
33
harness-opt/baseline/summary.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"k": 5,
|
||||
"n_cases": 3,
|
||||
"effect": {
|
||||
"mean": 0.5111111111111111,
|
||||
"sigma": 0.1507184440694504
|
||||
},
|
||||
"token": {
|
||||
"mean": 31997.0,
|
||||
"sigma": 7166.8411203573105
|
||||
},
|
||||
"duration": {
|
||||
"mean": 50188.86666666667,
|
||||
"sigma": 7746.3168641619595
|
||||
},
|
||||
"phi0_per_case": {
|
||||
"1": {
|
||||
"effect": 0.6,
|
||||
"token": 30086,
|
||||
"duration": 51004
|
||||
},
|
||||
"2": {
|
||||
"effect": 0.4,
|
||||
"token": 34616,
|
||||
"duration": 52787
|
||||
},
|
||||
"3": {
|
||||
"effect": 0.5333,
|
||||
"token": 31289,
|
||||
"duration": 46776
|
||||
}
|
||||
}
|
||||
}
|
||||
869
harness-opt/coverage.json
Normal file
869
harness-opt/coverage.json
Normal file
@@ -0,0 +1,869 @@
|
||||
{
|
||||
"summary": {
|
||||
"total_cases": 3,
|
||||
"files": 25,
|
||||
"expected_declared": 0,
|
||||
"blind_spots": 22,
|
||||
"overfit_high": 5,
|
||||
"suggest_add_cases": [
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md"
|
||||
],
|
||||
"suggest_fix_routing": []
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 0,
|
||||
"R2": 0,
|
||||
"R3": 50
|
||||
},
|
||||
"total_lines": 55,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-search.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 85,
|
||||
"R2": 112,
|
||||
"R3": 31
|
||||
},
|
||||
"total_lines": 234,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 25,
|
||||
"R2": 21,
|
||||
"R3": 15
|
||||
},
|
||||
"total_lines": 67,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 25,
|
||||
"R2": 20,
|
||||
"R3": 15
|
||||
},
|
||||
"total_lines": 67,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 0,
|
||||
"R2": 43,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 54,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 90,
|
||||
"R2": 40,
|
||||
"R3": 22
|
||||
},
|
||||
"total_lines": 157,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 139,
|
||||
"R2": 109,
|
||||
"R3": 14
|
||||
},
|
||||
"total_lines": 263,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 50,
|
||||
"R1": 368,
|
||||
"R2": 22,
|
||||
"R3": 12
|
||||
},
|
||||
"total_lines": 452,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-search.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 102,
|
||||
"R2": 24,
|
||||
"R3": 11
|
||||
},
|
||||
"total_lines": 142,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-update.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 67,
|
||||
"R2": 2,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 84,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 55,
|
||||
"R2": 24,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 94,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-threads-messages-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 72,
|
||||
"R2": 28,
|
||||
"R3": 9
|
||||
},
|
||||
"total_lines": 115,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 103,
|
||||
"R2": 56,
|
||||
"R3": 6
|
||||
},
|
||||
"total_lines": 166,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 80,
|
||||
"R2": 9,
|
||||
"R3": 6
|
||||
},
|
||||
"total_lines": 100,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-reactions.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 73,
|
||||
"R1": 206,
|
||||
"R2": 18,
|
||||
"R3": 2
|
||||
},
|
||||
"total_lines": 299,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 44,
|
||||
"R2": 17,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 68,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 44,
|
||||
"R2": 15,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 65,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 21,
|
||||
"R2": 17,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 44,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 70,
|
||||
"R2": 20,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 97,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 73,
|
||||
"R2": 24,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 103,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 10,
|
||||
"R1": 24,
|
||||
"R2": 14,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 48,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/SKILL.md",
|
||||
"is_domain_skill": true,
|
||||
"actual": {
|
||||
"count": 3,
|
||||
"pct": 1.0,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 3,
|
||||
"pct": 1.0,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 3,
|
||||
"density_pct": 1.0,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 122,
|
||||
"R1": 0,
|
||||
"R2": 68,
|
||||
"R3": 41
|
||||
},
|
||||
"total_lines": 231,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 2,
|
||||
"density_pct": 0.667,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 116,
|
||||
"R2": 12,
|
||||
"R3": 29
|
||||
},
|
||||
"total_lines": 162,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 2,
|
||||
"density_pct": 0.667,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 140,
|
||||
"R2": 109,
|
||||
"R3": 14
|
||||
},
|
||||
"total_lines": 264,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "低",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 84,
|
||||
"R2": 10,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 99,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
}
|
||||
]
|
||||
}
|
||||
48
harness-opt/objective.json
Normal file
48
harness-opt/objective.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"slug": "im-token",
|
||||
"modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"modules_spec": [
|
||||
"skills/lark-im/**/*.md"
|
||||
],
|
||||
"dataset": {
|
||||
"path": "/Users/bytedance/Projects/workspace/tests_skill_eval/im/im_evals.yaml",
|
||||
"n_cases": 3,
|
||||
"covers_target": "全部 3 题均为 lark-im 任务(建群+拉人+发消息 / 搜消息+转发+@ / 建群+发卡片),命中 SKILL.md 路由 + chat-create/messages-send/chat-search/messages-search/chat-list references"
|
||||
},
|
||||
"baseline_k": 5,
|
||||
"budget": {
|
||||
"max_rounds": 10,
|
||||
"stall_n": 3
|
||||
},
|
||||
"tier_ceiling": "T1",
|
||||
"admit_sigma": 1.0,
|
||||
"admit_sigma_duration": 1.0,
|
||||
"admit_sigma_effect": 1.0,
|
||||
"admit_sigma_target_boost": 0.0
|
||||
}
|
||||
60
harness-opt/opt-state.json
Normal file
60
harness-opt/opt-state.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"task_id": "OPT-IM-1",
|
||||
"title": "优化 lark-im(省 token 保成功率)",
|
||||
"branch": "feat/opt-im-token",
|
||||
"current_phase": "round",
|
||||
"phase_status": "in_progress",
|
||||
"started_at": "2026-06-23T17:52:10",
|
||||
"updated_at": "2026-06-23T19:38:08",
|
||||
"blockers": null,
|
||||
"transcript_path": "/Users/bytedance/.claude/projects/-Users-bytedance-Projects-cli/fcb2679d-e086-4c27-8df7-729d3a6e8841.jsonl",
|
||||
"phases": {
|
||||
"objective": {
|
||||
"status": "completed",
|
||||
"start": "2026-06-23T17:52:10",
|
||||
"end": "2026-06-23T17:54:04"
|
||||
},
|
||||
"baseline": {
|
||||
"status": "completed",
|
||||
"start": "2026-06-23T17:54:04",
|
||||
"end": "2026-06-23T18:14:17"
|
||||
},
|
||||
"round": {
|
||||
"status": "in_progress",
|
||||
"start": "2026-06-23T18:14:17",
|
||||
"end": null,
|
||||
"iterations": [
|
||||
{
|
||||
"round_index": 1,
|
||||
"picked_candidate": "phi0",
|
||||
"picked_module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"verdict": "admit",
|
||||
"reason": "engine admit=score_gain(eff 0.511→0.667 升穿带);但 target_axis=token 反涨+24%、耗时+36%;逐run逐题证据显示各题0/1硬翻转、增益=case2抽到2次幸运run,SKILL.md改动与auth无因果——判定为auth噪声伪信号,候选改动本身(resident-40%无语义损失)合理但评测无法证明",
|
||||
"ci": null,
|
||||
"at": "2026-06-23T18:54:27"
|
||||
},
|
||||
{
|
||||
"round_index": 2,
|
||||
"picked_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"picked_module": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"tier": "T1",
|
||||
"verdict": "admit",
|
||||
"reason": "engine admit=score_gain(case080 单题 0.6→1.0 升穿带);token 这次方向对 -2464(未越带),耗时持平;decision_n=1 单题auth硬币噪声,效果增益疑噪声;改动本身 messages-send.md -53.5% 经reviewer核验真去冗余无语义损失",
|
||||
"ci": null,
|
||||
"at": "2026-06-23T19:38:08"
|
||||
}
|
||||
]
|
||||
},
|
||||
"seal": {
|
||||
"status": "pending",
|
||||
"start": null,
|
||||
"end": null
|
||||
},
|
||||
"handoff": {
|
||||
"status": "pending",
|
||||
"start": null,
|
||||
"end": null
|
||||
}
|
||||
}
|
||||
}
|
||||
13
harness-opt/opt-state.md
Normal file
13
harness-opt/opt-state.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Opt State: OPT-IM-1 优化 lark-im(省 token 保成功率)
|
||||
|
||||
## Phase 记录
|
||||
|
||||
### ✅ Phase 1: Objective
|
||||
进入 baseline:以现网 lark-im 文档为 Φ0,K=5 重复评测立噪声地板
|
||||
做了什么:确认7项objective(省token保成功率/T1/全lark-im范围/K5/10轮stall3/σ1.0)并写objective.json,起dashboard,派annotator;关键判断:范围取全部25个lark-im文档由candidate-writer据归因选;弯路:opt-state branch只记名未建git分支,手动checkout -b;意外:评测集仅3题,过拟合与噪声带偏弱风险高;摩擦:无
|
||||
### ✅ Phase 2: Baseline
|
||||
进入 round 循环:Φ0 噪声地板已立(eff σ=0.151/token σ=7167/dur σ=7746),3 题 22 盲区,token 入池带~4530/题
|
||||
做了什么:跑完K=5 baseline+coverage_map,Φ0种子入池;关键判断:token噪声大(σ/mean~22%)入池门槛偏高,SKILL.md常驻是reach全集的最高杠杆;弯路:无;意外:22/25文件是盲区,reach会天然把候选限制到SKILL.md+被读references;摩擦:无
|
||||
### 🔄 Phase 3: Round
|
||||
### ⬜ Phase 4: Seal
|
||||
### ⬜ Phase 5: Handoff
|
||||
12
harness-opt/pool/head.json
Normal file
12
harness-opt/pool/head.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "53194d7a111df326cc078b633f43587225bd0132",
|
||||
"worktree": "/Users/bytedance/Projects/cli",
|
||||
"commit": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
|
||||
"phi0_worktree": "/Users/bytedance/Projects/cli",
|
||||
"lineage": [
|
||||
"phi0",
|
||||
"a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"557349b40feb359bb791749a37571d59edb7e72e",
|
||||
"53194d7a111df326cc078b633f43587225bd0132"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 33840,
|
||||
"token_usage": 237434,
|
||||
"duration_ms": 44127,
|
||||
"tool_call_count": 25,
|
||||
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id(首次搜索用单字失败后改为双字搜索成功),然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确,使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。",
|
||||
"from_round": 3,
|
||||
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35942,
|
||||
"token_usage": 234388,
|
||||
"duration_ms": 43185,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "执行者正确理解用户意图,使用用户身份创建群并发送卡片消息。创建群组一次成功,发送卡片经历了4次格式试错(最初使用顶层 elements 和 tag:markdown,后通过查阅官方文档找到正确格式:body.elements + div + lark_md),最终成功发送并返回 message_id。试错后自行纠正符合评判原则,不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}",
|
||||
"from_round": 3,
|
||||
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35478,
|
||||
"token_usage": 221685,
|
||||
"duration_ms": 46540,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "所有核心目标均达成。执行者经历了两次试错(shell 引号问题、@file 语法不支持),但均自行修正并成功完成任务,符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字,message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file)'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}",
|
||||
"from_round": 2,
|
||||
"from_candidate": "557349b40feb359bb791749a37571d59edb7e72e"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 37942,
|
||||
"token_usage": 251669,
|
||||
"duration_ms": 45769,
|
||||
"tool_call_count": 23,
|
||||
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为,Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明:对于需要用户授权的操作,如果用户明确说「不需要确认」,Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
}
|
||||
}
|
||||
35
harness-opt/pool/runs/phi0.json
Normal file
35
harness-opt/pool/runs/phi0.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 30086,
|
||||
"token_usage": 292379,
|
||||
"duration_ms": 51004,
|
||||
"tool_call_count": 32,
|
||||
"feedback": "Agent 行为完全正确:选择 user 身份符合需求(用户要求\"使用我的身份\"),认证缺失时正确执行 split-flow 授权流程,路径错误后自行纠正。任务未完成源于用户未完成二维码授权(环境因素),非 agent 能力缺陷。所有期望均因 blocked_by_env 而 PASS。\n- {'reason': '**防御性设计**:在发起授权前,可先检查 `lark-cli auth status` 的 user.identity.status,若为 missing 则主动告知用户\"当前用户身份未授权,我先帮你发起授权\",减少用户在看到认证错误后的困惑。'}\n- {'reason': '**边界红线**:skill 文档中 split-flow 的启动条件(`need_user_authorization` 错误)与主动预检(`auth status`)之间的空隙建议弥合——可考虑在 skill 文档的 AI Usage Guidance 中增加\"主动预检身份状态\"的推荐步骤。'}\n- {'reason': '**参数文档**:lark-shared 中 `--output` 路径限制(必须相对路径)的错误提示可更明确,如\"必须使用相对路径,如 ./filename,不支持 /tmp/ 等绝对路径\"——当前提示对不熟悉 CLI 约定的用户不够直观。'}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.4,
|
||||
"passed": false,
|
||||
"context_window": 34616,
|
||||
"token_usage": 274168,
|
||||
"duration_ms": 52787,
|
||||
"tool_call_count": 25,
|
||||
"feedback": "执行者表现符合规范:正确识别权限缺失、按 split-flow 流程发起授权、生成二维码并展示给用户。但用户未在执行期间完成扫码授权,导致所有核心业务目标(群聊搜索、消息筛选、转发、@通知)均未完成。这是典型的外部环境阻塞(用户交互依赖),不属于 agent 能力缺陷。执行者的错误处理和流程遵循均正确。\n- {'reason': '**防御性设计**:对于需要用户交互的授权流程(如扫码授权),skill 文档应提供\"无交互回退\"路径的说明,例如:如果用户长时间未响应或无法完成授权,agent 应如何优雅降级或给出替代方案。'}\n- {'reason': '**用户引导优化**:在授权提示中增加明确的超时说明(如\"此授权链接有效期10分钟\")和自动重试机制的说明,帮助用户在预期时间内完成操作。'}\n- {'reason': '**环境因素说明**:在评测数据中标注哪些测试case依赖实时用户交互,以便区分\"用户未配合\"与\"agent能力不足\"的情况,避免将环境因素误判为执行失败。'}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.5333333333333333,
|
||||
"passed": false,
|
||||
"context_window": 31289,
|
||||
"token_usage": 225396,
|
||||
"duration_ms": 46776,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "三个核心目标全部达成。user 身份因未授权阻断属于环境因素(blocked_by_env),bot 身份成功创建群并发送卡片消息。所有返回的 chat_id 和 message_id 均已验证存在。\n- {'reason': \"Skill 文档在 '--as user' 的权限不足处理部分,可增加提示:当 user 授权缺失时,bot 身份是合理的降级路径,尤其是创建群这类 bot 可独立完成的任务\"}\n- {'reason': \"用户意图'使用我的身份'与 bot 身份实际执行存在语义偏差,建议在 user 授权缺失时先询问用户是否接受 bot 代理,或尝试引导用户完成授权\"}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
}
|
||||
}
|
||||
67
harness-opt/rounds/round-001/attribution.json
Normal file
67
harness-opt/rounds/round-001/attribution.json
Normal file
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"case_id": "2",
|
||||
"case_label": "CLI_核心评测_015",
|
||||
"verdict": "FAIL",
|
||||
"token": 34616,
|
||||
"duration_ms": 52787,
|
||||
"tool_calls": 25,
|
||||
"cmd_attempts": 5,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md reach=1.0 调用前已读;失败在上游 user 授权,非内容触达问题)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成(QR 无人扫),+chat-search --as user 返回 token_missing,定位群/转发/@ 全部 blocked;驱动该行为的授权流程文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻正文 5777 tok 是 T1 可控热点。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "运行时冗余清单常驻(lark-im SKILL.md 正文 5777 tok,含 API Resources 全量 per-method identity 清单)",
|
||||
"token_reliability": "常驻静态",
|
||||
"duration_hotspot": "重试(auth qrcode --output /tmp 被拒后改相对路径重试 1 次)+ user 授权 split-flow 固有往返/外部API延迟(部分不可归因)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "SKILL.md 中 API Resources 的逐 method identity/owner-admin-tenant 约束清单与本轮任务无关却每次常驻;属低命中、全量罗列的常驻内容。effect 不在 T1 可修。"
|
||||
},
|
||||
{
|
||||
"case_id": "3",
|
||||
"case_label": "CLI_核心评测_080",
|
||||
"verdict": "FAIL",
|
||||
"token": 31289,
|
||||
"duration_ms": 46776,
|
||||
"tool_calls": 22,
|
||||
"cmd_attempts": 5,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md + chat-create.md + messages-send.md 调用前已读;建群仍因 user 授权 blocked)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成,+chat-create --as user 返回 token_missing,建群即 blocked,建卡片/发卡片无法进行;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。本题 token 最重:读取 Skill 占 49.6%(chat-create 3062 + messages-send 5367)+ SKILL.md 常驻 5722。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "按需 reference 偏大(messages-send.md 5367 + chat-create.md 3062)+ 运行时冗余清单常驻(SKILL.md 5722);messages-send.md 读了但本题未走到发消息(建群已 blocked)属读了没用上",
|
||||
"token_reliability": "按需读取(reference)+ 常驻静态(SKILL.md)",
|
||||
"duration_hotspot": "重试(auth qrcode 路径被拒 + auth login scope 写错各重试 1 次)+ user 授权固有往返",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块;SKILL.md 常驻正文偏重。本题为 token 轴杠杆最高的题。effect 不在 T1 可修。"
|
||||
},
|
||||
{
|
||||
"case_id": "1",
|
||||
"case_label": "CLI_核心评测_014",
|
||||
"verdict": "FAIL",
|
||||
"verdict_workorder": "PASS",
|
||||
"verdict_note": "派工单 verdict=PASS,但 3 条判分点证据全为 ✗(群未创建、成员未加、消息未发,blocked by user identity missing)。归因按判分点证据当 FAIL 处理。",
|
||||
"token": 30086,
|
||||
"duration_ms": 51004,
|
||||
"tool_calls": 32,
|
||||
"cmd_attempts": 10,
|
||||
"cmd_failures": 6,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md reach=1.0;#8 跑了 +chat-create --help 成功;失败在 user 授权与跨域 contact 查询)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成;先查联系人切到 lark-contact、contact +search-user --as user 同样 token_missing/exit3,回到 +chat-create 前已被 user 授权 blocked;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻 5724 tok 是 T1 可控热点。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "运行时冗余清单常驻(lark-im SKILL.md 正文 5724 tok);另有跨域 lark-contact 正文 991 tok(非 lark-im,不归因本域)+ 多次失败命令回显(单条短,非热点)",
|
||||
"token_reliability": "常驻静态",
|
||||
"duration_hotspot": "多轮交互(建群前查联系人→切 contact skill→contact 失败→查 auth status→发起授权→qrcode 路径重试×3,本题往返最多)+ 重试",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "SKILL.md 常驻正文偏重;失败链路(user 授权 + 跨域 contact)的驱动/约束文档不在 lark-im、本轮不可改。effect 不在 T1 可修。"
|
||||
}
|
||||
]
|
||||
24
harness-opt/rounds/round-001/case-commands.json
Normal file
24
harness-opt/rounds/round-001/case-commands.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"1": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"contact +search-user",
|
||||
"contact resolve \"傅一铭\"",
|
||||
"contact resolve \"傅二铭\"",
|
||||
"im +chat-create"
|
||||
],
|
||||
"3": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"im +chat-create",
|
||||
"im +messages-send"
|
||||
],
|
||||
"2": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"im +chat-messages-list",
|
||||
"im +chat-search",
|
||||
"im +messages-search"
|
||||
]
|
||||
}
|
||||
29
harness-opt/rounds/round-001/child-cache.json
Normal file
29
harness-opt/rounds/round-001/child-cache.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 37942,
|
||||
"token_usage": 251669,
|
||||
"duration_ms": 45769,
|
||||
"tool_call_count": 23,
|
||||
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为,Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明:对于需要用户授权的操作,如果用户明确说「不需要确认」,Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}"
|
||||
}
|
||||
}
|
||||
97
harness-opt/rounds/round-001/diagnosis.md
Normal file
97
harness-opt/rounds/round-001/diagnosis.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Round 1 归因(候选模块见 candidate_modules;模块由 candidate-writer 根据诊断和 reach 选定)
|
||||
|
||||
> 目标(objective.json):**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化;token 与 duration 是并列成本杆。tier=T1,仅可改 `skills/lark-im/**`。
|
||||
> 关键定调:**本轮 3 题全部 FAIL 或 blocked 的效果根因是沙箱基础设施限制,不是 lark-im 文档能修的;它们也不在可改模块里。** 因此本轮的真实抓手是 token 轴(每次运行常驻 + 误导性内容),不是去「修挂题」。下面分维度说明。
|
||||
|
||||
## 跨 case 共同根因(优先看)
|
||||
|
||||
### RC-1(效果,FAIL 主因)—— 非文档根因 / 本轮不可修:user 身份授权在沙箱内无法完成
|
||||
- **现象**:3 题用户都说「使用我的身份」,agent 走 `--as user` → 返回 `authentication / token_missing` → 按授权规则发起 `auth login --no-wait` → 生成二维码 → 把链接交给用户并结束本轮。沙箱里没有真人扫码,user 身份永远 `missing`,于是建群/搜群/发消息全部 blocked。三题轨迹高度同构(015/080/014)。
|
||||
- **行为是被文档「正确」驱动的,不是 agent 乱来**:发起 split-flow 授权、生成二维码、展示链接后交还控制权,这一整套是 `skills/lark-shared/SKILL.md`(L17、L72–105)明确 MUST 的流程。agent 严格照做。
|
||||
- **归因落点**:根因在**沙箱无法完成交互式 user 授权**(基础设施)+ 驱动该行为的授权流程文档在 `lark-shared`。
|
||||
- **为什么本轮不可修(重要,给 candidate-writer 的边界)**:
|
||||
1. `lark-shared/SKILL.md` **不在 candidate_modules**(objective.modules 只含 `skills/lark-im/**`),无权改。
|
||||
2. 即便能改,沙箱不能扫码这一物理限制不是文档能绕过的——这是环境,不是内容缺失。
|
||||
3. **不要试图通过让 agent 改走 `--as bot` 来「修绿」**:用户显式要「我的身份」,grader 判分点也写「使用当前用户身份创建」。改路由去 bot 是 reward-hack(绕过判分点、语义回退),不是合法的成功率修复。reviewer 会据此 FAIL。
|
||||
- **axis=效果**,但标注为**无文档根因 / 本轮不改**。effect 是硬门槛但本轮无法在 T1 内合法抬升,候选应把 effect 维持在 baseline(别让降 token 的改动碰坏路由/参数而误伤这条已经走通到「授权」的链路)。
|
||||
|
||||
### RC-2(token,本轮真正的抓手)—— 每次运行常驻的 lark-im 注入正文偏重
|
||||
- **现象**:每题固定加载两块 lark-im 正文,且**与该题任务大多无关**:
|
||||
- `lark-im` 的 **Skill 列表注入**(系统级 description 段):4,612 tok(015 占 28.2%、080 占 18.8%、014 占 25.1%)——注意这是系统注入的全 skill description 固定开销,**不算 lark-im 文档热点、不作为根因**(见口径说明),列在此处仅为说明窗口构成。
|
||||
- `lark-im` 的 **SKILL.md 正文**(经 Skill 工具加载,reach=1.0):约 **5,722–5,777 tok/题**,三题都常驻。这是 `skills/lark-im/SKILL.md`,**在可改模块内,是 token 轴的头号可控热点**。
|
||||
- **SKILL.md 里有大量与本轮任务无关的常驻清单**:`## API Resources` 段(L114+)逐条列了 chats / chat.members / messages / reactions / threads / image / pin / feed 等**每个 resource.method 的 identity 规则与 owner/admin/tenant 约束**(L123–190,几十行)。本轮 3 题只用到建群、搜群/搜消息、发消息、转发、@——绝大多数 method 行每次运行都被加载却从不被用到。这是典型「每次运行都会加载的运行时冗余清单常驻」。
|
||||
- **可信度=常驻静态**:SKILL.md 经 Skill 工具每题必加载(reach=1.0),tiktoken 可测、跨题稳定(5,722/5,724/5,777 三题一致)。这是降 token 最稳的发力点。
|
||||
- **axis=token**。文档位置:`skills/lark-im/SKILL.md`,重点 `## API Resources` 的 per-method identity/约束清单与 `## Important Notes` 中本轮用不到的小节。
|
||||
|
||||
### RC-3(token,次级抓手)—— 按需 reference 体积偏大,且只在用到的题里计入
|
||||
- **现象**:080 读了 `chat-create.md`(3,062 tok) + `messages-send.md`(5,367 tok),两块 reference 合计 8,429 tok,占该题 visible 的 34.4%。014 也读了 chat-create.md。
|
||||
- **判据**:reach(chat-create=0.667、messages-send=0.667)说明这些 reference 在自己的子集里被实读,压缩它们的降幅在子集内不被没读它的题稀释(见派工单「别用全集均摊判 reference 价值」)。`messages-send.md` 单文件 5,367 tok 尤其大。
|
||||
- **可信度=按需读取**:只在实际 Read 该 reference 的题里计入,不能按全集均摊。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`、`lark-im-chat-create.md`。
|
||||
|
||||
### RC-4(duration,弱信号,需复现)—— `auth qrcode --output "/tmp/..."` 被拒后反应式重试
|
||||
- **现象**:3 题都先用 `--output "/tmp/lark_auth_qr.png"`(或 `/workspace/agent-cwd/qrcode.png`)→ 报 `validation / invalid_argument: unsafe output path` → 改用相对路径 `./xxx.png` 重试成功。每题多 1–2 个往返。
|
||||
- **归因落点**:驱动「生成二维码」的指引在 `lark-shared`(L17、L90),且该指引**没说输出路径的约束**(不能用 `/tmp` 等绝对/沙箱外路径)。这是「报错没指下一步 + 文档没写约束」的耗时根因。
|
||||
- **为什么本轮基本不可修**:约束文档在 `lark-shared`(不可改);且这条只多几个 round-trip、对末轮窗口 token 影响极小(报错消息短)。
|
||||
- **可信度**:耗时波动大,单题不算数;但此模式**3 题一致复现**,作为 duration 旁证可信度提升。不过它仍**不在 T1 可改范围**,仅记录。
|
||||
- **axis=duration**,标注为**驱动文档不可改(lark-shared)**。
|
||||
|
||||
## 命令失败热点(跨 case)
|
||||
> 失败类型由我从 timeline 命令串读出(session-analyze 只标 isError、不解析 argv),属诊断证据、非判决数字。
|
||||
|
||||
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|
||||
|---|---|---|---|---|
|
||||
| `im +chat-search` | 2 | 1 (015) | `--as user` → token_missing | user 身份未授权(沙箱限制);非内容错误 |
|
||||
| `im +chat-create` | 1 | 1 (080) | `--as user` → token_missing | 同上 |
|
||||
| `contact +search-user` / `contact resolve` | 4 | 1 (014) | exit 2/3(user 身份 / 命令不存在) | 跨 skill(lark-contact),非 lark-im 内容 |
|
||||
| `auth qrcode --output /tmp/...` | 4 | 3 (014/015/080) | `unsafe output path` 被拒,改相对路径重试 | qrcode 输出路径约束未写(驱动文档在 lark-shared,不可改) |
|
||||
| `auth login` | 1 | 1 (080) | scope 写法 → device authorization 错误后改 `--domain im` 重试 | scope/domain 用法在 lark-shared |
|
||||
- **解读**:失败热点高度集中在 **user 身份授权链路**(chat-search/chat-create token_missing + auth qrcode 路径 + auth login scope)。这一整条链路的驱动与约束文档都在 `lark-shared`,**不是 lark-im 文档能修的**。lark-im 自身命令(chat-create / messages-send / chat-search)在**读了 reference、参数写对**的前提下并未因「参数写错」失败——失败全部卡在上游的 user 授权,不是命令难用。**这意味着没有 lark-im 侧的「报错/输出整形」工单**。
|
||||
|
||||
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
|
||||
> 对每条预期该读的 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash(不在 reach 里)。
|
||||
|
||||
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错 | 主导态 → 改动方向 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | 3 | ③ 调用前已读,仍卡授权 → **非触达问题**;且不可改 |
|
||||
| `lark-im-chat-create.md` | 0.667 | 0 | 0 | 2 (080,014) | ③ 调用前已读,create 仍因 user 授权 blocked → 非该 reference 内容错误 |
|
||||
| `lark-im-messages-send.md` | 0.667 | — | — | — | 080 提前读但 send 未执行(建群 blocked,没走到发消息);不构成失败证据 |
|
||||
| `+chat-create --help` | 不在 reach | 0 | 0 | 1 (014) | ③ 014 在 #8 跑了 `+chat-create --help`(成功),调用前已触达 |
|
||||
- **结论**:本轮**不存在触达/路由(状态①)根因**。三题都在调用前读到了 SKILL.md(reach=1.0)、读到了相关 reference、甚至跑了 `--help`。失败发生在**内容已触达之后的上游授权环节(状态③语义,但根因是环境而非文档内容错)**。
|
||||
- **对 candidate-writer 的含义**:**不要把 RC-1 误判为①而推「前置授权说明」**——内容已经读到了,前置救不了沙箱不能扫码。前置类改动在本轮对 effect 无效,只会增 token,与目标背道而驰。
|
||||
|
||||
## 差距台账复盘
|
||||
- 无(round 1,`discard-ledger.json` 为空)。
|
||||
|
||||
## 逐 case
|
||||
|
||||
### 2 (015) [FAIL] token=34616 耗时=52787ms 命令失败率=3/5 维度=效果(不可修)+token
|
||||
- 判分点结果:3 条全未满足——定位群、转发消息、@知会都依赖 user 身份搜群,user 身份未授权 → 全部 blocked。
|
||||
- 命令失败:3/5。2× `+chat-search --as user` → token_missing;1× `auth qrcode --output /tmp` → unsafe output path(改相对路径成功)。
|
||||
- 可发现性时序:SKILL.md 调用前已读(reach=1.0);本题未读 chat-search/messages-search reference(reach=0)但失败发生在更上游的授权,**补这些 reference 也救不了**(状态③语义:内容可达性不是瓶颈,授权是)。
|
||||
- token 归因:SKILL.md 正文 5,777 tok(常驻静态,35.3%)+ 系统级 Skill 列表注入 4,612 tok(固定开销,不归因)。本题未读大 reference,故 token 主来源就是常驻 SKILL.md 正文。
|
||||
- 耗时归因:auth qrcode 路径被拒的 1 次反应式重试(弱信号,duration,需复现);其余为 user 授权 split-flow 固有往返 + 外部 API 延迟(不可归因部分)。
|
||||
- 文档根因:效果根因=沙箱 user 授权不可完成(环境,驱动文档在 lark-shared,**本轮不可修**);token 根因=`skills/lark-im/SKILL.md` 常驻正文偏重(**可修,T1 抓手**)。
|
||||
|
||||
### 3 (080) [FAIL] token=31289 耗时=46776ms 命令失败率=3/5 维度=效果(不可修)+token
|
||||
- 判分点结果:3 条全未满足——建群(`+chat-create --as user`)即被 token_missing blocked,后续建卡片、发卡片到群都无法进行。
|
||||
- 命令失败:3/5。1× `+chat-create --as user` token_missing;1× `auth login --scope "..."` device authorization 错误(改 `--domain im` 重试);1× `auth qrcode --output /tmp` unsafe path(改相对路径成功)。
|
||||
- 可发现性时序:调用前读了 SKILL.md + chat-create.md + messages-send.md(全部状态③,调用前已触达);建群仍因 user 授权 blocked,**非 reference 内容错误**。
|
||||
- token 归因:**本题 token 最重,读取 Skill 占 49.6%**——chat-create.md 3,062 + messages-send.md 5,367 = 8,429 tok(按需读取)+ SKILL.md 正文 5,722 tok(常驻静态)。这是 RC-2 + RC-3 同时发力的题。messages-send.md 提前读但本题根本没走到发消息(建群已 blocked),属「读了没用上」的浪费。
|
||||
- 耗时归因:auth qrcode 重试 + auth login scope 写错重试,各 1 次反应式往返(弱信号,duration,需复现)。
|
||||
- 文档根因:效果=沙箱 user 授权(不可修);token=SKILL.md 常驻正文 + 两个偏大 reference(**可修,T1 抓手;本题杠杆最高**)。
|
||||
|
||||
### 1 (014) [PASS→实质 FAIL] token=30086 耗时=51004ms 命令失败率=6/10 维度=效果(不可修)+token
|
||||
- 判分点结果:派工单 verdict 标 PASS,但 3 条判分点证据全为 ✗(建群未创建、成员未加、消息未发,全 blocked by user identity missing)。**实质是 FAIL**,PASS 系上层聚合口径差异,归因按判分点证据处理。
|
||||
- 命令失败:6/10(最高)。`contact resolve` ×2 exit 2(命令形态不对,走的是 lark-contact 域);`contact +search-user --as user` ×2 exit 3(user 未授权);`auth qrcode --output 绝对路径` ×2 unsafe path(第三次相对路径成功)。
|
||||
- 可发现性时序:#7 调用前读 SKILL.md(reach=1.0);#8 跑了 `+chat-create --help`(成功,状态③,调用前已触达建群用法);随后为查联系人切到 lark-contact skill。失败集中在 user 授权与跨域 contact 查询,**非 lark-im 内容可达性问题**。
|
||||
- token 归因:SKILL.md 正文 5,724 tok(常驻静态,31.1%)+ 系统 Skill 列表注入 4,612 tok(固定开销,不归因)+ lark-contact 正文 991 tok(跨域,非 lark-im)。lark-cli 命令累计 2,577 tok(14%),含多次失败回显,但单条都短、非热点。
|
||||
- 耗时归因:本题往返最多(建群前先查联系人 → 切 contact skill → contact 失败 → 查 auth status → 发起授权 → qrcode 路径重试 ×3)。多为 user 授权链路 + 跨域查联系人固有串行 + 反应式重试(duration 弱信号,需复现)。
|
||||
- 文档根因:效果=沙箱 user 授权 + 跨域 contact 不可用(环境,不可修);token=`skills/lark-im/SKILL.md` 常驻正文(**可修,T1 抓手**)。
|
||||
|
||||
## 给 candidate-writer 的收口(不含具体改法)
|
||||
- **唯一在 T1 内可合法发力的轴是 token**,对应 RC-2(SKILL.md 常驻正文,3 题全命中、最稳)与 RC-3(chat-create/messages-send reference 偏大,080 命中)。两者方向一致(减体积),可作为本轮候选的目标轴。
|
||||
- **effect 不可在本轮 T1 内合法抬升**(RC-1 环境限制 + 驱动文档在不可改的 lark-shared)。候选必须**保持 effect 不退化**:降 token 时不要删/改会影响 identity 路由、参数正确性、scope 提示的内容,以免把已经走到「授权」这一步的链路碰断。
|
||||
- **方向冲突提示**:RC-1 若有人想「补授权说明帮 agent 过」与目标(降 token)方向相反,且对沙箱无效——**明确不要做**。RC-2/RC-3(减体积)与目标同向,无冲突。
|
||||
- **缺失信息(doc_fix_hint 语气,非药方)**:SKILL.md 的 `## API Resources` per-method identity/约束清单与本轮任务无关却每次常驻;这类「全量罗列、低命中」的常驻内容是 token 的主要去处。messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块。
|
||||
- **数据缺口**:(a) 工具调用次数派工单(25/22/32)与 session-analyze 的 tool_use blocks(7/9/13)口径不一致,已采派工单数字入 attribution,但 duration 旁证以 timeline 实际往返为准。(b) duration 根因(RC-4)单轮不足以定论,需多轮/多次复现;且其驱动文档在 lark-shared 不可改。(c) 014 派工单 verdict=PASS 与判分点证据全 ✗ 冲突,归因按判分点证据当 FAIL 处理。
|
||||
1
harness-opt/rounds/round-001/discard-ledger.json
Normal file
1
harness-opt/rounds/round-001/discard-ledger.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
harness-opt/rounds/round-001/failure-memory.json
Normal file
1
harness-opt/rounds/round-001/failure-memory.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
222
harness-opt/rounds/round-001/module-reach.json
Normal file
222
harness-opt/rounds/round-001/module-reach.json
Normal file
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
}
|
||||
15
harness-opt/rounds/round-001/review.json
Normal file
15
harness-opt/rounds/round-001/review.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回(schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope),非语义丢失而是迁回文档本就强制查询的权威源;SELECTION 层路由(Identity-and-Token-Mapping、Shortcuts 表)字节未动(L1-109 完全一致);23 个 reference 链接集合改动前后完全相同,reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效;token 4960→2986(-39.8%,tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
|
||||
"dimensions": {
|
||||
"reward_hack": {"pass": true, "evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿;Identity-and-Token-Mapping 路由块(L38-42)字节未动,符合 diagnosis「保 effect 不追 effect」的要求"},
|
||||
"semantic_regress": {"pass": true, "evidence": "实测无承重内容丢失:lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope(如 im:message.urgent),删块全部可在运行时由 schema 取回;23 个 reference 集合改动前后完全相同,reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"},
|
||||
"token_shift": {"pass": true, "evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"},
|
||||
"contract_break": {"pass": true, "evidence": "T1 无对外契约;删除目标(method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象,新指针同时覆盖 schema+lark-shared 报错流程语义;23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"},
|
||||
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线两维(semantic_regress / contract_break)均无触犯:信息归属正确(method/scope 索引应交给 schema/--help)、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared;结构与链接合规"},
|
||||
"single_root_cause":{"pass": true, "evidence": "diff 仅服务 RC-2(裁常驻 USAGE 索引),未捆 RC-3(reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"}
|
||||
}
|
||||
}
|
||||
404
harness-opt/rounds/round-001/round.json
Normal file
404
harness-opt/rounds/round-001/round.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"round": 1,
|
||||
"status": "admitted",
|
||||
"parent_id": "phi0",
|
||||
"parent_worktree": "/Users/bytedance/Projects/cli",
|
||||
"child_worktree": "/Users/bytedance/Projects/cli",
|
||||
"base_commit": "040ef17eae0ac350c556081544793aacce675e90",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"candidate_modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"module_reach": {
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
},
|
||||
"expected_reach": {},
|
||||
"minibatch": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"pareto_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"artifacts": {
|
||||
"workorder": "workorder.md",
|
||||
"diagnosis": "diagnosis.md",
|
||||
"attribution": "attribution.json",
|
||||
"strategy": "strategy.md",
|
||||
"review": "review.json",
|
||||
"trend": "trend.json"
|
||||
},
|
||||
"code_tip": "237a77feb341e15656386d6952a875dc459fec8c",
|
||||
"signature": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"tier": "T1",
|
||||
"intent": "将 SKILL.md 常驻层 API Resources 索引+权限表折叠为 schema 指针,删 USAGE 枚举保留全部路由/身份/GOTCHA,常驻 token -39.8%",
|
||||
"target_axis": "token",
|
||||
"changed_files": [
|
||||
"skills/lark-im/SKILL.md"
|
||||
],
|
||||
"decision_basis": {
|
||||
"type": "module",
|
||||
"module": "skills/lark-im/SKILL.md"
|
||||
},
|
||||
"decision_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"review": {
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回(schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope),非语义丢失而是迁回文档本就强制查询的权威源;SELECTION 层路由(Identity-and-Token-Mapping、Shortcuts 表)字节未动(L1-109 完全一致);23 个 reference 链接集合改动前后完全相同,reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效;token 4960→2986(-39.8%,tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
|
||||
"dimensions": {
|
||||
"reward_hack": {
|
||||
"pass": true,
|
||||
"evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿;Identity-and-Token-Mapping 路由块(L38-42)字节未动,符合 diagnosis「保 effect 不追 effect」的要求"
|
||||
},
|
||||
"semantic_regress": {
|
||||
"pass": true,
|
||||
"evidence": "实测无承重内容丢失:lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope(如 im:message.urgent),删块全部可在运行时由 schema 取回;23 个 reference 集合改动前后完全相同,reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"
|
||||
},
|
||||
"token_shift": {
|
||||
"pass": true,
|
||||
"evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"
|
||||
},
|
||||
"contract_break": {
|
||||
"pass": true,
|
||||
"evidence": "T1 无对外契约;删除目标(method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象,新指针同时覆盖 schema+lark-shared 报错流程语义;23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"
|
||||
},
|
||||
"devguide": {
|
||||
"pass": true,
|
||||
"evidence": "对照 review-rubric 优化红线两维(semantic_regress / contract_break)均无触犯:信息归属正确(method/scope 索引应交给 schema/--help)、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared;结构与链接合规"
|
||||
},
|
||||
"single_root_cause": {
|
||||
"pass": true,
|
||||
"evidence": "diff 仅服务 RC-2(裁常驻 USAGE 索引),未捆 RC-3(reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"
|
||||
}
|
||||
}
|
||||
},
|
||||
"child_k": 5,
|
||||
"eval_trace": null,
|
||||
"retro": {
|
||||
"cause": "已入池",
|
||||
"noise_borderline": false,
|
||||
"summary": "越带入池,无需复盘补发"
|
||||
},
|
||||
"retro_sessions": [
|
||||
{
|
||||
"case": "1",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 30086,
|
||||
"child": 34270,
|
||||
"gain": "反向",
|
||||
"pass_delta": null
|
||||
},
|
||||
{
|
||||
"case": "2",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 34616,
|
||||
"child": 47116,
|
||||
"gain": "反向",
|
||||
"pass_delta": "修好"
|
||||
},
|
||||
{
|
||||
"case": "3",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 31289,
|
||||
"child": 37942,
|
||||
"gain": "反向",
|
||||
"pass_delta": "修好"
|
||||
}
|
||||
],
|
||||
"verdict": "admitted",
|
||||
"ci": null,
|
||||
"new_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"decision": {
|
||||
"parent_success": 0.3333333333333333,
|
||||
"child_success": 1.0,
|
||||
"parent_score": 0.5111111111111111,
|
||||
"child_score": 0.6666666666666666,
|
||||
"score_saved": 0.15555555555555556,
|
||||
"score_threshold": 0.09532271373123208,
|
||||
"parent_token": 31997.0,
|
||||
"child_token": 39776.0,
|
||||
"saved": -7779.0,
|
||||
"threshold": 4532.708313776408,
|
||||
"parent_duration": 50189.0,
|
||||
"child_duration": 68024.66666666667,
|
||||
"dur_saved": -17835.66666666667,
|
||||
"dur_threshold": 4899.200953624988,
|
||||
"dur_margin": 1.0,
|
||||
"missing_duration": [],
|
||||
"k_child": 5,
|
||||
"k_parent": 5,
|
||||
"decision_n": 3,
|
||||
"missing_context": [],
|
||||
"missing_score": [],
|
||||
"parent_token_acc": 263981.0,
|
||||
"child_token_acc": 379441.6666666667,
|
||||
"phi0_score": 0.5111111111111111,
|
||||
"eff_margin": 1.0,
|
||||
"parent_token_full": 31997.0,
|
||||
"child_token_full": 39776.0,
|
||||
"saved_full": -7779.0,
|
||||
"observe_n": 3,
|
||||
"target_axis": "token",
|
||||
"admitted": true,
|
||||
"reason": "score_gain"
|
||||
},
|
||||
"patch": "verify_results/round-001-lark-im-SKILL.patch"
|
||||
}
|
||||
44
harness-opt/rounds/round-001/strategy.md
Normal file
44
harness-opt/rounds/round-001/strategy.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Round 1 候选策略(模块=skills/lark-im/SKILL.md, tier=T1, 主指标=token)
|
||||
|
||||
## 根因与选择
|
||||
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RC-2:SKILL.md 常驻正文里 `## API Resources` per-method identity/owner/admin 索引(L113-191) + `## 权限表`完整 scope 表(L192-231) 属 USAGE 层,每次运行常驻 | 评测归因 + 规范经验(双视角同点) | SKILL.md(1.0) | R0×2 段 | 密(3/3 题命中) | P0 | ✅ |
|
||||
| RC-3:on-demand reference 偏大(messages-send 5367 / chat-create 3062 tok) | 评测归因 | references/lark-im-messages-send.md(0.667)、chat-create.md(0.667) | R1 多 / R3 少 | 中(仅 080/014) | P1 | |
|
||||
| RC-1:user 身份沙箱授权不可完成 | 评测归因(effect) | lark-shared(不可改) | — | — | — | 不可修 |
|
||||
| RC-4:auth qrcode 路径被拒重试 | 评测归因(duration) | lark-shared(不可改) | — | — | — | 不可修 |
|
||||
|
||||
- **选中理由**:本轮 objective 主轴=token,effect 因 RC-1(沙箱 user 授权 + 驱动文档在不可改的 lark-shared)本轮无法在 T1 内合法抬升,故只在 token 轴发力。RC-2 是 reach=1.0 的头号可控热点——3 题全命中、tiktoken 稳定(5,722/5,724/5,777)、每次运行都付费。RC-3 是 reach=0.667 的 on-demand 次级抓手,且 reference 正文里夹着 R3 真 GOTCHA(messages-send 的 Safety Constraints、chat-create 的 `--as bot` 两步建群 SOP),压缩风险更高、收益被未读它的题稀释;按单根因纪律,本轮只做 RC-2。RC-1/RC-4 落 lark-shared,越界即被 scope check 拒,且沙箱物理限制非文档可绕——不碰。
|
||||
- **选模块理由**:SKILL.md reach=1.0(经 Skill 工具每题必加载),是 RC-2 的唯一承载。改动全部落在它内部,coherent,不触任何别的 skill。
|
||||
- **规范经验源补注**:双视角在同一处汇合——
|
||||
- 视角②(annotation):`skill-annotations.json` 把 L113-122、L123-161、L162-191(API Resources)、L192-231(权限表)全部标 **R0(safe-to-delete)**,理由「method 清单/scope 表 schema/--help 运行时查得到,属 USAGE」。
|
||||
- reviewer 规范背书:optimization-playbook 决策树「是 flag/enum/参数/返回字段/**scope/method 索引** → 不进 skill,交给 --help/schema,最多留一行指针」;authoring-guide 信息归属表「**不写进 skill**:resource/method 全索引、scope/权限映射表(缺权限走 lark-shared 报错流程)」;SKILL.md 锚点 6「`--help`/schema 管 USAGE,reference 只留 gotcha」。三处独立指向同一删除对象。
|
||||
- coverage:3/3 题都加载 SKILL.md(密),token 收益在常驻层可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),不是难裁的拟合型改动。
|
||||
|
||||
## 改了什么(逐处)
|
||||
- `skills/lark-im/SKILL.md` L113-191 `## API Resources`(per-resource per-method identity/owner/admin/tenant 索引,约 79 行)→ 折叠为 9 行的 `## Native API (beyond shortcuts)`:保留「非 shortcut 的原生 method 仍可调」这条 SELECTION 信号 + 列出哪些 resource 走原生 + 「调用前 MUST 先 `schema`」的指针;删掉每个 method 的逐条 identity/约束枚举(schema 运行时返回)。
|
||||
- `skills/lark-im/SKILL.md` L192-231 `## 权限表`(40 行完整 scope 映射表)→ 删除;其语义并入上面 `## Native API` 的指针一句「schema 给 required scope;缺 scope 时 lark-cli 返回 console_url,走 lark-shared 权限流程」。
|
||||
- `skills/lark-im/SKILL.md` Shortcuts 速查表新增 2 行:`reactions.*` → `references/lark-im-reactions.md`、`feed.groups.*` → `references/lark-im-feed-groups.md`。**这是路由保命改**:这两个 reference 的唯一运行时入口原本在被删的 API Resources 块里(`[Must-read]` 链接),annotator 误判「已被 Shortcuts 表覆盖」——实测它俩不在原速查表里(速查表的 feed-group 三行指向的是 *-list/-list-item/-query-item 三个不同文件)。不补这 2 行 = 删 reference 链接 = 该 reference reach 永久归 0、路由断裂。
|
||||
|
||||
## 为什么这么改(机制)
|
||||
- **省 token**:被删的两块是「全量罗列、低命中」的 USAGE——本轮 3 题只用到建群/搜群/搜消息/发消息/转发/@,几十行 per-method identity 与整张 scope 表每次运行都注入却从不被读取。删后 Agent 仍能:(1) 经 SKILL.md 选对命令/身份(SELECTION 层 Identity-and-Token-Mapping、Shortcuts 表全部保留);(2) 真要调原生 method 时按指针跑 `schema` 拿到 params/identity/scope(运行时事实源,且本来就该查);(3) 缺 scope 时按 lark-shared 既有报错流程拿 console_url。即「删了 Agent 还做得对吗?做得对就删」(锚点 2)。
|
||||
- **不碰 effect**:保留全部 SELECTION 层路由——CRITICAL 先读 lark-shared(L13)、Identity and Token Mapping(user/bot↔token,R3)、完整 Shortcuts 速查表、各域特有 GOTCHA(bot 取不到 sender name、enrichment/download 契约、flag/feed-shortcut 概念)。没有改 identity 路由、没有改参数正确性、没有删 scope 提示语义(指针仍指向 schema+lark-shared 流程)。已经走到「user 授权」这一步的链路不会被碰断。
|
||||
- **规范背书**:optimization-playbook §2 决策树 + authoring-guide 信息归属表 L95 + SKILL.md 锚点 6,三处独立判定 method 索引/scope 表「不进 skill,最多留一行指针」——本改动正是把两块 USAGE 折叠成指针。
|
||||
|
||||
## 预期效果
|
||||
- **成功率(effect 硬门槛)**:不退化。删除的是 USAGE 枚举,保留全部 SELECTION/路由/身份/GOTCHA。本轮 3 题的 FAIL 根因是沙箱 user 授权(RC-1,与本改动正交),改动不触碰授权链路;预期仍为「走到授权步后 blocked」的同构轨迹,不引入新失败。
|
||||
- **context(分两层)**:
|
||||
- (1) **静态字数差**:SKILL.md 从 4,960 → 2,986 tok(cl100k_base,reviewer 脚本实测),**-1,974 tok / -39.8%**;落入金标杆带(中位数 ~2,400、lark-shared 2,709),接近上一轮 IM 治理目标 2,040。
|
||||
- (2) **每题运行时 context 方向**:3 题全部下降,且降幅≈静态差——因为 SKILL.md reach=1.0 每题必全量加载,常驻层减重直接等额传导到每题 visible(评测里 SKILL.md 正文 5,722-5,777 tok/题 → 预计降约 2k/题)。**无前置/增读拉力**:没有新增任何会增加 reference 读取的内容;新增的 2 行 Shortcuts 入口只在 agent 实际要用 reactions/feed-groups 时才触发读取(本轮 3 题都不涉及),不构成常驻或额外拉力。与 direction(token↓)一致,无张力。
|
||||
- **可裁性**:token 收益在常驻层、可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),非难裁的拟合型改动;无覆盖敞口。
|
||||
|
||||
## 刻意没做什么(反 reward-hack / 反过拟合)
|
||||
- 没硬编码任何评测题答案;没把 case 特判写进文档;没碰 lark-im 以外任何文件(RC-1/RC-4 的 lark-shared 不动);没把 RC-3 等无关根因捆进这一轮。
|
||||
- **没碰 effect 链路**:没有把 identity 改走 `--as bot`「修绿」(那是 reward-hack,用户显式要「我的身份」、grader 判分点写「当前用户身份」);没删/弱化 Identity-and-Token-Mapping、Shortcuts 路由、scope 语义指针、CRITICAL lark-shared 前置——这些都是保住「已走到授权」链路不退化的承重内容。
|
||||
- **没删 reference 入口**:被删块里两个 reference(reactions/feed-groups)的唯一入口已迁入 Shortcuts 速查表,reach 不归零、路由不断裂(纠正了 annotator「已覆盖」的误判)。
|
||||
- **没做输出裁剪、没碰命令行为**(T1 docs-only,且 playbook 红线:输出裁剪须独立设计验证)。
|
||||
- **没补「前置授权说明」**:诊断证据显示 3 题调用前都已读到 SKILL.md(reach=1.0),失败在更上游的沙箱授权(状态③语义、根因是环境),前置救不了且只会增 token,与目标背道——明确不做。
|
||||
- 这是「减体积」改动、与评测错误分布无拟合关系,不存在朝错误分布过拟合的敞口;lite 无 sealed 也不构成隐患。
|
||||
|
||||
## 签名
|
||||
- signature: a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e(git diff skills/lark-im/SKILL.md 内容哈希) tier: T1
|
||||
1
harness-opt/rounds/round-001/trend.json
Normal file
1
harness-opt/rounds/round-001/trend.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
35
harness-opt/rounds/round-001/workorder.md
Normal file
35
harness-opt/rounds/round-001/workorder.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Round 1 归因派工单(parent=phi0;模块未定,由 candidate-writer 据诊断点名)
|
||||
|
||||
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer)+ 逐题结构化 `attribution.json`(给 dashboard)。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
|
||||
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
|
||||
|
||||
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
|
||||
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read,都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置),正该选来修——不是白烧;reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
|
||||
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3'];其中挂的: ['2', '3']
|
||||
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
|
||||
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
|
||||
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
|
||||
|
||||
### 2 [FAIL] ctx=34616 (acc=274168) 52787ms tools=25
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 成功定位名为「fusanming_at_openclaw群」的群,并获取最近包含「飞豆」关键字的消息。
|
||||
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」。
|
||||
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功。
|
||||
|
||||
### 3 [FAIL] ctx=31289 (acc=225396) 46776ms tools=22
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
|
||||
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
|
||||
✓ 将该卡片发送到新建群中,预期返回 message_id
|
||||
|
||||
### 1 [PASS] ctx=30086 (acc=292379) 51004ms tools=32
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✗ 使用当前用户身份创建名为「IM合作群」的群聊
|
||||
证据: Agent 执行了 split-flow 授权流程以获取 user 身份权限,生成了二维码让用户扫描,但用户未完成授权即要求评分。Auth status 显示 'User identity: missing',群聊未被创建。
|
||||
✗ 将傅一铭和傅二铭加入该群
|
||||
证据: 依赖群聊创建结果。由于群聊未创建(blocked by user identity missing),无法添加成员。
|
||||
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
|
||||
证据: 依赖群聊创建结果。由于群聊未创建,无法发送消息。
|
||||
65
harness-opt/rounds/round-002/attribution.json
Normal file
65
harness-opt/rounds/round-002/attribution.json
Normal file
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"case_id": "1",
|
||||
"case_label": "CLI_核心评测_014",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "workorder=PASS(聚合口径),判分点证据 3/3 ✗ → 实质 FAIL,按判分点当 FAIL 归因",
|
||||
"token": 34555,
|
||||
"token_visible_est": 17364,
|
||||
"duration_ms": 37000,
|
||||
"tool_calls": 8,
|
||||
"cmd_attempts": 7,
|
||||
"cmd_failures": 5,
|
||||
"cmd_fail_rate": 0.71,
|
||||
"discoverability_state": "③ 读了仍卡(SKILL.md+chat-create.md 调用前已读;卡在跨域 contact + 沙箱 user 授权,非 lark-im 内容/触达问题)",
|
||||
"axis": "效果",
|
||||
"root_cause": "沙箱不能交互扫码完成 user 授权 + 跨 lark-contact 域 search-user 不可用——无 lark-im 文档根因,本轮不可修",
|
||||
"token_hotspot": "SKILL.md 常驻正文(RC-1) + chat-create.md 按需读取(RC-3,本题读了但授权阻断没用上);无 lark-cli 输出离群",
|
||||
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(chat-create.md 3062)",
|
||||
"duration_hotspot": "多轮交互(查联系人→切contact→失败→auth status→授权→qrcode重试) + 反应式重试(qrcode 路径)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "无 lark-im 文档可修点(效果根因在环境+跨域);lark-im 侧仅 token 减法(SKILL.md 常驻、chat-create.md 体积)"
|
||||
},
|
||||
{
|
||||
"case_id": "2",
|
||||
"case_label": "CLI_核心评测_015",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,全程 bot 身份完成,无授权阻断(推翻 round-1 的 blocked 定调)",
|
||||
"token": 54568,
|
||||
"token_visible_est": 43760,
|
||||
"duration_ms": 125000,
|
||||
"tool_calls": 16,
|
||||
"cmd_attempts": 9,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.33,
|
||||
"discoverability_state": "① 从没读(chat-messages-list.md / messages-search.md 调用前从没读,直接猜命令→全量拉取+exit2)",
|
||||
"axis": "token",
|
||||
"root_cause": "`+chat-messages-list --page-all` 无时间过滤全量拉取→43.5KB持久化→Read 灌入 22556 tok;放大器是 chat-messages-list.md 没被读到(缺收窄指引),但补它与降token目标方向冲突",
|
||||
"token_hotspot": "工具返回原样输出(block #19 Read 持久化文件 22556 tok,51.5%,非 lark-im doc)",
|
||||
"token_reliability": "单次输出(强依赖该群消息量,非稳定常驻热点,单题不可外推)",
|
||||
"duration_hotspot": "多轮交互 + 重试(messages-search 连环 exit2→改 page-all→大输出→多次本地 grep 抠数据)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现;工具调用 16 明显高于 080,作旁证",
|
||||
"doc_fix_hint": "token 黑洞来自工具输出非文档;SKILL.md 表对 chat-messages-list 未提示大群应 server-side 收窄——但补此为增内容,与降token冲突,列观察项不作本轮根因"
|
||||
},
|
||||
{
|
||||
"case_id": "3",
|
||||
"case_label": "CLI_核心评测_080",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,主动选 bot 身份完成建群+发卡片,零命令失败(推翻 round-1 的 blocked 定调)",
|
||||
"token": 38009,
|
||||
"token_visible_est": 21599,
|
||||
"duration_ms": 47000,
|
||||
"tool_calls": 6,
|
||||
"cmd_attempts": 3,
|
||||
"cmd_failures": 0,
|
||||
"cmd_fail_rate": 0.0,
|
||||
"discoverability_state": "③ 读了即用(SKILL.md+chat-create.md+messages-send.md 调用前全读到且用上,无触达问题)",
|
||||
"axis": "token",
|
||||
"root_cause": "messages-send.md 单文件 5365 tok(内部 4 处『选 content flag』语义重叠 + Commands 全形态罗列)+ SKILL.md 常驻 + chat-create.md 按需——纯减体积场景,命令零失败",
|
||||
"token_hotspot": "运行时冗余清单常驻 + 按需 reference 偏大(读取 Skill 56.4%:messages-send.md 5365 + SKILL.md 3751 + chat-create.md 3060)",
|
||||
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(messages-send.md 5365 子集reach0.333、chat-create.md 3060 子集reach0.667)",
|
||||
"duration_hotspot": "无离群(47s 正常建群+发卡片串行,无重试、无写后回查)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "messages-send.md 选型规则在 4 处重复表述、Commands 罗列全部媒体形态;SKILL.md Important Notes/Shortcuts 全量低命中常驻——均为可删的减法冗余,本题 token 杠杆最高且无 effect 风险"
|
||||
}
|
||||
]
|
||||
27
harness-opt/rounds/round-002/case-commands.json
Normal file
27
harness-opt/rounds/round-002/case-commands.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"1": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"contact +search-user"
|
||||
],
|
||||
"3": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-create",
|
||||
"im +messages-send"
|
||||
],
|
||||
"2": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-messages-list",
|
||||
"im +chat-search",
|
||||
"im +messages-mget",
|
||||
"im +messages-search",
|
||||
"im +messages-send",
|
||||
"im messages forward",
|
||||
"schema im.messages.forward",
|
||||
"schema im.messages.search"
|
||||
]
|
||||
}
|
||||
11
harness-opt/rounds/round-002/child-cache.json
Normal file
11
harness-opt/rounds/round-002/child-cache.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35478,
|
||||
"token_usage": 221685,
|
||||
"duration_ms": 46540,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "所有核心目标均达成。执行者经历了两次试错(shell 引号问题、@file 语法不支持),但均自行修正并成功完成任务,符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字,message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file)'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}"
|
||||
}
|
||||
}
|
||||
113
harness-opt/rounds/round-002/diagnosis.md
Normal file
113
harness-opt/rounds/round-002/diagnosis.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Round 2 归因(parent=round-1 已采纳候选 51f2a70e;候选模块见 candidate_modules,由 candidate-writer 据诊断+reach 点名)
|
||||
|
||||
> 目标(objective.json):**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化;token 与 duration 是并列成本杆。tier=T1,仅可改 `skills/lark-im/**`。
|
||||
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
|
||||
> **本轮 trace = round-1 已采纳候选(51f2a70e,SKILL.md 已 trim 到约 3,915 tok)的行为**,不是 baseline。三题 session 实测已确认 SKILL.md 注入正文为 3,751 tok/题(与 trim 后体积一致),round-1 报告的 5,722 tok/题是 trim 前数字,已过期。
|
||||
|
||||
## ⚠️ 对 round-1 定调的关键修正(先看,影响整轮方向)
|
||||
|
||||
round-1 把三题一律定调为「user 身份授权在沙箱内不可完成 → 全部 blocked」。**实测 trace 推翻了这个 monolith:三题行为完全不同,只有 1 题真卡授权。**
|
||||
|
||||
| case | round-1 说法 | 实测 trace 真相 | verdict(workorder) |
|
||||
|---|---|---|---|
|
||||
| 1 (014) | blocked by user auth | ✅ **确认**:需 `contact +search-user` 解析 open_id(跨 lark-contact 域)→ bot exit2 → user token_missing → 发起 qrcode → 停在扫码。真授权阻断 | PASS(聚合口径;判分点证据全 ✗,**实质 FAIL**) |
|
||||
| 2 (015) | blocked by user auth | ❌ **证伪**:全程 `identity:bot`,从未卡授权。搜群✓、定位「飞豆」消息✓、转发✓、@傅六铭✓,两次 `messages-send` 全 `ok:true`。**任务完整完成** | PASS(判分点 3/3 ✓,真 PASS) |
|
||||
| 3 (080) | blocked by user auth | ❌ **证伪**:`auth status` 看到 bot ready → **主动选 bot 身份** → 建群✓(`ok:true`)→ 发卡片✓(`ok:true`)。**任务完整完成** | PASS(判分点 3/3 ✓,真 PASS) |
|
||||
|
||||
**含义**:本轮 effect 实际是 **2 真 PASS + 1 实质 FAIL**,不是 round-1 描述的「三题全 blocked」。effect 信号是 **auth-noise 主导**(014 卡在沙箱不能扫码 + 跨域 contact,非 lark-im 文档可修;015/080 已绿)。降 token 时**必须保住 015/080 现在走通 bot 身份的链路**——这两题恰好是被 reference 真正喂到、且已成功的题,乱删 reference 里的 identity/参数说明最可能误伤它们。
|
||||
|
||||
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
|
||||
|
||||
### RC-1(token,头号抓手,3 题全命中、最稳)—— SKILL.md `## Important Notes` + Shortcuts 全表常驻,本轮任务低命中
|
||||
- **现象**:SKILL.md 经 Skill 工具每题必加载(reach=1.0),实测 3,751 tok/题、三题一致(常驻静态)。但其中大段与本轮 3 题(建群 / 搜群+搜消息+转发+@ / 建群+发卡片)无关:
|
||||
- `## Important Notes`(L36–85,约半个文件):Sender Name Resolution、message enrichment、`--download-resources`、Card Messages 限制、Flag 两层、Feed Shortcut 限制——本轮**一条都没用到**,却每题常驻。
|
||||
- `## Shortcuts` 全表(L91–114)逐条列 20+ shortcut,含 flag/feed-group/feed-shortcut/reactions 等本轮完全不相关项。
|
||||
- **可信度=常驻静态**:tiktoken 可测、跨题稳定(3,751×3)。这是降 token 最稳的发力点,且 3 题全命中(reach=1.0),降幅不被任何子集稀释。
|
||||
- **axis=token**。文档位置:`skills/lark-im/SKILL.md` 的 `## Important Notes` 低命中小节 + `## Shortcuts` 全量表。
|
||||
- **方向张力(必须标注)**:这是 round-1 已经动过一刀的同一文件(折叠了 API Resources/权限表)。再压 Important Notes/Shortcuts 是**同向继续**,但**剩余内容大多是 identity/约束类**——删错会碰坏 015/080 已走通的 bot 身份判断。candidate-writer 取舍时这是 effect 风险点,不是 RC-1 不成立。
|
||||
|
||||
### RC-2(token,次级抓手,080 命中、按需读取)—— `messages-send.md` 单文件偏大且内部高度冗余
|
||||
- **现象**:080 读了 `messages-send.md`,实测 **5,365 tok**——本轮所有按需 reference 里最大的单块(占 080 visible 的 24.8%)。该 reference 实测被读且**确实用上了**(080 据此发卡片成功),不是「读了没用」。
|
||||
- **从文档看为何这么大**:messages-send.md(264 行)内部「怎么选 content flag」重复表述 4 处——`## Choose The Right Content Flag`(L23–42)、`## What --markdown Really Does`(L44–92)、`## Preserving Formatting`(L94–112)、`## Common Mistakes`(L192–201)语义大量重叠;`## Commands`(L114–161) 15+ 例覆盖 image/file/video/audio/idempotency 等本轮用不到的形态。这是「单文件冗余 + 全形态罗列」,不是信息缺失。
|
||||
- **可信度=按需读取**:只在实读它的子集(reach=0.333,仅 080)里计入,压缩降幅在该子集不被稀释——但**子集只有 1 题**,证据基数小,效果需评测确认(见数据缺口)。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`。
|
||||
|
||||
### RC-3(token,次级抓手,014+080 命中、按需读取)—— `chat-create.md` 按需读取偏大
|
||||
- **现象**:014 与 080 都读了 `chat-create.md`,实测 3,060–3,062 tok(reach=0.667)。080 据此建群成功(用上了);014 读后因 user 授权阻断没走到建群(读了但本题没用上)。
|
||||
- **可信度=按需读取**(reach=0.667,子集 2 题)。体积本身不离群,杠杆低于 RC-2,列为更次级。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-chat-create.md`。
|
||||
|
||||
### RC-4(效果,无文档根因 / 本轮不可修)—— 014 的 user 授权阻断 + 跨域 contact 依赖
|
||||
- **现象**:014 需先解析「傅一铭/傅二铭」open_id,走 `contact +search-user`(**lark-contact 域,不在 candidate_modules**):bot 身份 exit2(invalid_argument)→ `--as user` token_missing → 发起 `auth login`+qrcode → 停在扫码。判分点证据全 ✗。
|
||||
- **归因落点**:根因=沙箱不能交互扫码(环境)+ 跨域 contact 命令不可用(非 lark-im)。**lark-im 文档侧无根因、无可修点**——这正是约束 3 的「无文档根因 / 本题不改」出口,不要为凑根因往 lark-im doc 上硬编。
|
||||
- **axis=效果**,标注**无文档根因 / 本轮不改**。effect 维持 baseline 即可,不要试图改路由让 014「修绿」(用户显式要本人身份解析联系人,改 bot 是 reward-hack)。
|
||||
|
||||
## 命令失败热点(跨 case;失败类型由我从 timeline 命令串读出,非判决数字)
|
||||
|
||||
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|
||||
|---|---|---|---|---|
|
||||
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2;user token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
|
||||
| `auth qrcode --output 绝对路径` | 1 | 1 (014) | unsafe output path,改相对路径重试成功 | 路径约束在 lark-shared(不可改) |
|
||||
| `im +messages-search` | 2 | 1 (015) | exit2(bot 身份 + `--as user` 均 exit2) | 见下「messages-search 难用」分析 |
|
||||
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2(无过滤 page-all) | 见下「015 token 黑洞」分析 |
|
||||
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败;015 的失败集中在 `messages-search`(见下)。这意味着**没有 lark-im 侧的常规「报错/参数整形」工单**——与 RC-1/2/3 的 token 方向一致,本轮抓手是减体积不是补内容。
|
||||
|
||||
### 015 的 token 黑洞(重要的新发现,round-1 完全没诊断到)
|
||||
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19:一次 `Read` 工具读入 22,556 tok(占该题 visible 51.5%)**。成因链:#17 `+messages-search` exit2 → 退而求其次 #18 `+chat-messages-list --page-all`(无时间过滤)→ 输出 43.5KB 被持久化到文件 → agent `Read` 整个文件 → 22.5k tok 灌进上下文。后面又靠本地 `grep`(#27–33) 抠出「飞豆」两条。
|
||||
- **从文档角度**:`chat-messages-list.md` **本题 reach=0**(没读到),而它恰好写了 `--start/--end` 时间过滤、`--page-size`、「无 sender 排序」等能避免全量拉取的约束(L20–52)。SKILL.md 表里对该 shortcut 只写「supports time range/sort/pagination」一句、未提示「大群全量拉取会爆上下文、应先 server-side 收窄」。**这是一个真实的「该读没读 → 全量灌入」放大器**(约束 5 状态①:调用前从没读该 reference)。
|
||||
- **但这条对本轮目标是「方向张力」,不是干净的 token 抓手**:要避免全量灌入,文档侧只能**增加**收窄指引(前置或加 caution),这与「降 token」的常驻成本目标**方向相反**(见硬性约束 7 的冲突记录)。且 22.5k 黑洞是**单次工具输出**(单次输出可信度、单题、强烈依赖该群消息量),不是稳定常驻热点。**结论:列为观察项交评测裁决,不要当成 RC-1 那种干净抓手去推「前置 chat-messages-list」——很可能只增 token 不省。**
|
||||
|
||||
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
|
||||
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash(本轮 3 题均未跑任何 `--help`)。
|
||||
|
||||
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了;014 仍卡(环境,非内容);不可改 |
|
||||
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功;014 调用前读→授权阻断(非 reference 错)。**非触达问题** |
|
||||
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题** |
|
||||
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① **015 调用前从没读**→直接 `--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token,与目标冲突(见上) |
|
||||
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读 messages-search.md,直接猜 `+messages-search` ×2 → exit2。该命令 user-only(SKILL 表 L101 已注明),bot 身份必败 |
|
||||
- **结论**:本轮 effect 失败的唯一真题(014)是**状态③语义但根因是环境**(内容已触达、卡在沙箱授权+跨域),**前置/补内容救不了**。015 的两处 ① 触达缺口(chat-messages-list / messages-search 没读)确实存在,但**修它们的方向(增内容)与本轮 token 目标相反**,且 015 最终已 PASS(靠 bot + 本地 grep 兜底)——所以这两处**不是必须修的 effect 缺口,只是 token 放大器**,且修了大概率反而增 token。
|
||||
- **对 candidate-writer 的含义**:**本轮没有「该前置」的干净 case**。RC-1/2/3 都是「调用前已读、内容够用 → 减体积」的纯 token 减法,不涉及触达。不要被 015 的两处 ① 诱导去推前置——那会与目标背道而驰。
|
||||
|
||||
## 方向冲突记录(硬性约束 7)
|
||||
- **减体积(RC-1/2/3,与 objective.direction 同向)** vs **补收窄指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降常驻/按需 token,后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反,**不可合并**。本轮目标是降 token,应取减体积一侧;015 的全量灌入作为观察项记录、不作为本轮要补的内容根因。
|
||||
|
||||
## 差距台账复盘
|
||||
- 无(round 2,`discard-ledger.json` 为空,无已跑未采纳候选)。
|
||||
|
||||
## 逐 case
|
||||
|
||||
### 1 (014) [workorder=PASS / 实质 FAIL] token=34555(reported)/visible 17,364 耗时=37s 命令失败率≈5/7 维度=效果(不可修)
|
||||
- 判分点结果:3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_id(user 授权阻断)。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
|
||||
- 命令失败:≈5/7。`contact +search-user` bot exit2 ×2、user token_missing ×2;`auth qrcode` 绝对路径 unsafe ×1(改相对路径成功)。**全部非 lark-im 命令的内容错误**。
|
||||
- 可发现性时序:调用前读了 SKILL.md(reach=1.0)+chat-create.md(3,062 tok);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
|
||||
- token 归因:SKILL.md 正文 3,751(常驻静态,21.6%)+ chat-create.md 3,062(按需,17.6%,本题没走到建群=读了没用上)+ 系统 Skill 列表注入 4,612(固定开销,不归因)。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
|
||||
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试)。多为授权链路 + 跨域固有串行 + 反应式重试(duration 弱信号,需多轮复现)。
|
||||
- 文档根因:效果=沙箱 user 授权 + 跨域 contact(环境,**无 lark-im 文档根因,本轮不改**);token=SKILL.md 常驻(RC-1)+ chat-create.md 按需(RC-3)。
|
||||
|
||||
### 2 (015) [PASS·真] token=54568(reported)/visible 43,760 耗时=2m5s 命令失败率≈3/9 维度=token
|
||||
- 判分点结果:3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send` 均 `ok:true`)。**全程 bot 身份,无授权阻断**。
|
||||
- 命令失败:≈3/9。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2(无过滤);agent 退到 `+chat-messages-list`(无 page-all) + 本地 grep 兜底成功。
|
||||
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` **调用前从没读**(reach=0),直接猜命令。messages-search 是 user-only(SKILL 表 L101 已注明)、bot 身份必败——agent 没看清就猜。
|
||||
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok(51.5%,其他工具调用/返回)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入(单次输出可信度,强依赖该群消息量)。SKILL.md 正文 3,749(常驻)。lark-shared 3,749(跨 skill,不归因 lark-im)。
|
||||
- 耗时归因:本题最长(2m5s),主因是 messages-search 连环失败→改用 page-all→大输出→多次本地 grep 抠数据的多轮往返(duration 弱信号;工具调用 16 raw32,明显高于 080,作旁证)。
|
||||
- 文档根因:token 黑洞的放大器=`chat-messages-list.md` 没被读到 + SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力,见上),列为观察项;本题已 PASS。常规 token 抓手仍是 RC-1(SKILL.md 减体积)。
|
||||
|
||||
### 3 (080) [PASS·真] token=38009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
|
||||
- 判分点结果:3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群`ok:true`→发 interactive 卡片`ok:true`。**任务完整完成,零命令失败**。
|
||||
- 命令失败:0/3。三条 lark-cli(auth status / chat-create / messages-send)全成功。
|
||||
- 可发现性时序:调用前读 SKILL.md + chat-create.md(3,060) + messages-send.md(5,365),全部状态③(调用前已读且用上)。**无触达问题**。
|
||||
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%:messages-send.md 5,365(按需,最大单块,RC-2)+ SKILL.md 3,751(常驻,RC-1)+ chat-create.md 3,060(按需,RC-3)。三块 reference/SKILL 都实读且 RC-2 的 messages-send.md 确实用上了。系统 Skill 列表注入 4,612(固定开销,不归因)。
|
||||
- 耗时归因:47s,全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
|
||||
- 文档根因:无效果根因(已绿);token=RC-2(messages-send.md 内部冗余) + RC-1(SKILL.md 常驻) + RC-3(chat-create.md)。**本题 token 杠杆最高且无 effect 风险**(命令全成功,减 reference 体积不碰已走通链路)。
|
||||
|
||||
## 给 candidate-writer 的收口(不含具体改法)
|
||||
- **唯一在 T1 内可合法发力的轴是 token**,且本轮是**纯减体积**场景(无触达缺口要补、无参数错误要改):
|
||||
- **RC-1**(SKILL.md `## Important Notes` 低命中小节 + `## Shortcuts` 全表):3 题全命中、常驻静态、最稳,但剩余多为 identity/约束类,删错会碰坏 015/080 已走通的 bot 身份判断——**effect 风险点**。
|
||||
- **RC-2**(messages-send.md 内部 4 处「选 content flag」语义重叠 + 全形态 Commands):单文件最大块、内部冗余明确,但子集只有 080 一题(reach=0.333),证据基数小、效果需评测确认。
|
||||
- **RC-3**(chat-create.md 按需偏大):杠杆最低,列为更次级。
|
||||
- **effect 不可在本轮 T1 内合法抬升**:014 是环境(沙箱不能扫码)+ 跨域 contact,无 lark-im 文档根因。015/080 已真 PASS。候选必须**保住 015/080 走通 bot 身份的 identity/参数说明**,降 token 时别误伤。
|
||||
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处触达缺口(chat-messages-list/messages-search 没读)虽真实存在,但修它们=增内容,与降 token 目标**方向冲突**,且 015 已 PASS——属观察项,非本轮要补的根因。
|
||||
- **缺失信息(doc_fix_hint 语气)**:SKILL.md 的 Important Notes/Shortcuts 全量罗列、本轮低命中却每题常驻;messages-send.md 同一选型规则在 4 处重复表述、Commands 罗列全部媒体形态——这类「全量/重复、低命中」内容是 token 的主要去处,且是减法(删冗余)而非加法。
|
||||
- **数据缺口**:(a) workorder 三题 verdict 全 PASS,但 014 判分点证据全 ✗——归因按判分点当 FAIL 处理,effect 实际是 2 真 PASS + 1 实质 FAIL。(b) RC-2/RC-3 子集小(messages-send.md 仅 080、chat-create.md 仅 014+080),单轮证据基数小,token 降幅需评测在子集上确认。(c) 015 的 22.5k 黑洞是单次工具输出,强依赖该群消息量,非稳定常驻热点,单题不可外推。(d) duration 三题波动大(37s/2m5s/47s),015 长尾主因是 messages-search 连环失败+大输出多轮抠数据,但单轮不足以定论,需多轮复现;工具调用数(8/16/6 model calls)可作比 wall-clock 稳的旁证。(e) 工具调用次数 session-analyze(model calls 8/16/6) 与 workorder 趋势表(R1 均值 26.3) 口径不一致,趋势表疑似含 raw 计数,旁证以 timeline 实际往返为准。
|
||||
1
harness-opt/rounds/round-002/discard-ledger.json
Normal file
1
harness-opt/rounds/round-002/discard-ledger.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
harness-opt/rounds/round-002/failure-memory.json
Normal file
1
harness-opt/rounds/round-002/failure-memory.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
220
harness-opt/rounds/round-002/module-reach.json
Normal file
220
harness-opt/rounds/round-002/module-reach.json
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.333,
|
||||
"read_cases": [
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
}
|
||||
15
harness-opt/rounds/round-002/review.json
Normal file
15
harness-opt/rounds/round-002/review.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯结构性去重:16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
|
||||
"dimensions": {
|
||||
"reward_hack": {"pass": true, "evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致);card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"},
|
||||
"semantic_regress": {"pass": true, "evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail,且运行时可观测"},
|
||||
"token_shift": {"pass": true, "evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联,080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema,不构成运行时陷阱"},
|
||||
"contract_break": {"pass": true, "evidence": "T1 文档不涉对外契约;prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"},
|
||||
"devguide": {"pass": true, "evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"},
|
||||
"single_root_cause":{"pass": true, "evidence": "commit 仅 1 文件 51 insert/208 delete,全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"}
|
||||
}
|
||||
}
|
||||
380
harness-opt/rounds/round-002/round.json
Normal file
380
harness-opt/rounds/round-002/round.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"round": 2,
|
||||
"status": "admitted",
|
||||
"parent_id": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"parent_worktree": "/Users/bytedance/Projects/cli",
|
||||
"child_worktree": "/Users/bytedance/Projects/cli",
|
||||
"base_commit": "51f2a70e6dffeea65d928badb6207408490dc215",
|
||||
"module": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"candidate_modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"module_reach": {
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.333,
|
||||
"read_cases": [
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
},
|
||||
"expected_reach": {},
|
||||
"minibatch": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"pareto_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"artifacts": {
|
||||
"workorder": "workorder.md",
|
||||
"diagnosis": "diagnosis.md",
|
||||
"attribution": "attribution.json",
|
||||
"strategy": "strategy.md",
|
||||
"review": "review.json",
|
||||
"trend": "trend.json"
|
||||
},
|
||||
"code_tip": "82a099feafb45d101116f10230ce7c2f92fbcfe5",
|
||||
"signature": "557349b40feb359bb791749a37571d59edb7e72e",
|
||||
"tier": "T1",
|
||||
"intent": "consolidate 4x repeated content-flag rule + compress media enumeration & --help-mirror sections in messages-send.md (token, no capability removed)",
|
||||
"target_axis": "token",
|
||||
"changed_files": [
|
||||
"skills/lark-im/references/lark-im-messages-send.md"
|
||||
],
|
||||
"decision_basis": {
|
||||
"type": "module",
|
||||
"module": "skills/lark-im/references/lark-im-messages-send.md"
|
||||
},
|
||||
"decision_cases": [
|
||||
"3"
|
||||
],
|
||||
"review": {
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯结构性去重:16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
|
||||
"dimensions": {
|
||||
"reward_hack": {
|
||||
"pass": true,
|
||||
"evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致);card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"
|
||||
},
|
||||
"semantic_regress": {
|
||||
"pass": true,
|
||||
"evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail,且运行时可观测"
|
||||
},
|
||||
"token_shift": {
|
||||
"pass": true,
|
||||
"evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联,080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema,不构成运行时陷阱"
|
||||
},
|
||||
"contract_break": {
|
||||
"pass": true,
|
||||
"evidence": "T1 文档不涉对外契约;prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"
|
||||
},
|
||||
"devguide": {
|
||||
"pass": true,
|
||||
"evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"
|
||||
},
|
||||
"single_root_cause": {
|
||||
"pass": true,
|
||||
"evidence": "commit 仅 1 文件 51 insert/208 delete,全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"child_k": 5,
|
||||
"eval_trace": null,
|
||||
"retro": {
|
||||
"cause": "已入池",
|
||||
"noise_borderline": false,
|
||||
"summary": "越带入池,无需复盘补发"
|
||||
},
|
||||
"retro_sessions": [
|
||||
{
|
||||
"case": "3",
|
||||
"session": null,
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 37942,
|
||||
"child": 35478,
|
||||
"gain": "收益现",
|
||||
"pass_delta": null
|
||||
}
|
||||
],
|
||||
"verdict": "admitted",
|
||||
"ci": null,
|
||||
"new_candidate": "557349b40feb359bb791749a37571d59edb7e72e",
|
||||
"decision": {
|
||||
"parent_success": 1.0,
|
||||
"child_success": 1.0,
|
||||
"parent_score": 0.6,
|
||||
"child_score": 1.0,
|
||||
"score_saved": 0.4,
|
||||
"score_threshold": 0.09532271373123208,
|
||||
"parent_token": 37942.0,
|
||||
"child_token": 35478.0,
|
||||
"saved": 2464.0,
|
||||
"threshold": 4532.708313776408,
|
||||
"parent_duration": 45769.0,
|
||||
"child_duration": 46540.0,
|
||||
"dur_saved": -771.0,
|
||||
"dur_threshold": 4899.200953624988,
|
||||
"dur_margin": 1.0,
|
||||
"missing_duration": [],
|
||||
"k_child": 5,
|
||||
"k_parent": 5,
|
||||
"decision_n": 1,
|
||||
"missing_context": [],
|
||||
"missing_score": [],
|
||||
"parent_token_acc": 251669.0,
|
||||
"child_token_acc": 221685.0,
|
||||
"phi0_score": 0.5333333333333333,
|
||||
"eff_margin": 1.0,
|
||||
"parent_token_full": 37942.0,
|
||||
"child_token_full": 35478.0,
|
||||
"saved_full": 2464.0,
|
||||
"observe_n": 1,
|
||||
"target_axis": "token",
|
||||
"admitted": true,
|
||||
"reason": "score_gain"
|
||||
},
|
||||
"patch": "verify_results/round-002-lark-im-references-lark-im-messages-send.patch"
|
||||
}
|
||||
48
harness-opt/rounds/round-002/strategy.md
Normal file
48
harness-opt/rounds/round-002/strategy.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Round 2 候选策略(模块=references/lark-im-messages-send.md, tier=T1, 主指标=token)
|
||||
|
||||
## 根因与选择
|
||||
|
||||
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RC-2: messages-send.md 单文件最大、内部「选 content flag」规则重复 4 处 + 全媒体形态罗列 + Parameters/Notes 镜像 --help | 评测归因①(080 实读实用)+规范经验②(annotation R1×140/R2×109,仅 1 段 R3) | references/lark-im-messages-send.md (0.333) | R1/R2 主导,唯一 R3=Safety Constraints(L9–22) | 密 / overfit 低 | P1 | ✅ |
|
||||
| RC-1: SKILL.md `## Important Notes` 低命中 + `## Shortcuts` 全表常驻 | 评测归因①(reach=1.0,3 题全命中) | SKILL.md (1.0) | R2/R3 混合(identity/约束密集) | 密 / 中 | P0(命中) 但 effect 高风险 | |
|
||||
| RC-3: chat-create.md 按需偏大 | 评测归因① | references/lark-im-chat-create.md (0.667) | — | 密 | P1 | |
|
||||
|
||||
- **选中理由**:RC-2 是诊断点名「最干净的 token 杠杆」——单文件最大块(实测 ~5,365 tok,占 080 visible 24.8%),且 080 调用前已读、确实据它发卡片成功(reach=0.333、actual=1,非「读了没用」)。annotation 证实它 R1/R2 主导(140 R1 + 109 R2 行,可重构/可压缩),唯一 R3 段是 Safety Constraints(L9–22),我**原样保留语义**。coverage=「密」、overfit「低」→ 本轮 eval 能在 080 上裁真伪。这是纯减体积、零能力删除、不碰 SKILL.md 路由的改动。
|
||||
- **为什么不选 RC-1**:reach=1.0、命中率最高,但 diagnosis 明确标它为 **effect 风险点**——剩余内容多为 identity/约束类,正是驱动 015/080 走通 bot 身份判断的承重内容;objective 的**硬门槛是「保住成功率」**,动 SKILL.md 最可能误伤这条已绿链路。本轮放弃,避免拿成功率换 token。
|
||||
- **为什么不选 RC-3**:diagnosis 判其杠杆最低(体积不离群),列为更次级;同一根因一轮只动一个,留待后续轮次。
|
||||
- **选模块理由**:messages-send.md reach=0.333>0(满足 reach 锁),承载选中的 RC-2,是非域 reference、改它不触碰 SKILL.md 的身份路由面。多文件无——本轮只动这一个文件。
|
||||
- **规范经验源补注**:对照 content-taxonomy——「单命令用法/长示例/与 --help 重复」类默认 R0/R1,「一般行为规则/CLI 机制约定」默认 R2;本文件的重复选型规则、全形态 Commands、Parameters/Notes 镜像即此类,处理方向为「留命中率最高一处,其余删或指针」「高频留 2–3 例,长的下沉」。当轮可被 080 裁真伪(coverage 密/overfit 低)。
|
||||
|
||||
## 改了什么(逐处)
|
||||
- **L23–43 `## Choose The Right Content Flag` + `### --text vs --markdown`**:两段语义重叠的选型说明 → 合并为单张 4 行选型表(markdown/text/content/media),并把互斥规则并入表后一句。删掉 `### --text vs --markdown` 整段(与表重复)。
|
||||
- **L44–82 `## What --markdown Really Does` + `### Markdown Boundaries` + `### Image Constraint`**:三段约 39 行 → 压成 `## --markdown Gotchas` 三条要点(强制 post/无 title、标题改写规则、图片预上传 vs 远程 URL vs 本地路径不支持)。删掉 JSON wrap 示意、逐条 boundary 罗列等可由行为观察得到的展开。
|
||||
- **L83–93 图片预上传双命令示例**:并入 `## Commands` 的一条 markdown+image 示例(保留 `im images create` → 引用 img_xxx 的关键两步)。
|
||||
- **L114–161 `## Commands`(15+ 例覆盖全媒体形态)+ `## Media Input Rules`**:压成代表性示例(markdown / text / DM / post-title / markdown+image / 4 个媒体一组 / idempotency+dry-run),媒体路径规则收成 `--help` 指针后的 3 条 load-bearing gotcha(cwd-relative/绝对路径拒绝、video-cover 必配、msg-type 推断冲突)。
|
||||
- **L169–191 `## Parameters` 表**:删除镜像 `--help` 的逐参数描述,改为「Run `lark-cli im +messages-send --help`」指针 + 仅保留 --help 不显然的三条硬规则(已并入 Commands 末尾)。
|
||||
- **L192–202 `## Common Mistakes`**:整段删除——逐条都是选型表/markdown gotcha 的反向重述(第 4 次重复选型规则),删后选型信息仍在表里。
|
||||
- **L203–216 `## content Format Reference`**:保留(构造 `--content` 的 gotcha),把 image/file/audio 三行合并为一行省重复。
|
||||
- **L227–248 `## @Mention Format`**:保留全部三种 msg_type 的 `<at>` 语法(text/post/interactive 各异、AI 猜不到),压紧为两条要点、去掉小标题与重复散文。
|
||||
- **L249–264 `## Notes`**:整段删除——逐条(互斥/media 上传/scope/markdown 强制 post/video-cover/msg-type 冲突)均已在 Safety Constraints、选型表、--markdown Gotchas、Commands 指针处各保留一处单一事实源。
|
||||
|
||||
## 为什么这么改(机制)
|
||||
- **消除根因的因果链**:该 reference 的体积来自「同一份选型规则在 4 个 section 重复 + 全媒体形态逐条罗列 + Parameters/Notes 镜像 --help」。token 不是被任务必需信息占用,而是被**重复表述**占用。按「同一份事实只写一次」(锚点 1)合并到单一事实源后,每条 load-bearing 信息仍恰好出现一次,080 这类「读该 reference→发消息」的题,读入 token 直接下降而行为不变。
|
||||
- **不删能力**:每个 flag(text/markdown/content/image/file/video/audio/idempotency/dry-run/msg-type/video-cover/as)、每条硬约束(互斥、video-cover 必配、cwd-relative 路径、绝对路径拒绝、markdown 强制 post/无 title、msg-type 冲突校验)、三套 `<at>` 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射——全部保留,只是从「重复 N 次/逐条罗列」变成「一处/代表性示例 + --help 指针」。
|
||||
- **规范经验源**:依 optimization-playbook「reference 收敛到 gotcha-only,不做 --help 镜像」——Parameters 全表/全形态 Commands 属 USAGE,下沉到 `--help` 指针;保留的是 --help 表达不了的跨 flag 互斥、媒体路径安全、markdown→post 边界、@mention 按类型差异等 gotcha。annotation 标这些段为 R1(可重构/下沉),符合处理方向;唯一 R3(Safety)原样保留。
|
||||
|
||||
## 预期效果
|
||||
- **成功率**:不退化。080(唯一读该文件的题)的发卡片链路依赖的是 `--content`/`interactive`、identity=bot、chat-id——全部保留;选型表、content Format Reference、Safety、scope 都在。015/080 走通 bot 身份的判断由 SKILL.md + identity 段承载,本轮**没碰 SKILL.md**,零误伤面。014 与本文件无关(reach 不含 014)。
|
||||
- **context(分两层)**:
|
||||
- (1) **静态字数差**:16,407 → 6,399 chars(-61.0%);tiktoken cl100k 3,869 → 1,799 tok(-53.5%)。(注:diagnosis 报 ~5,365 tok 系另一 tokenizer/含注入开销;此处用 cl100k 自测,方向与幅度一致。)
|
||||
- (2) **运行时 context 方向**:仅在**实读该 reference 的子集**生效——本轮即 080 一题,运行时读入下降约 50%+(该块占 080 visible 24.8%,预计 080 visible 降约 12–13%)。其余两题(014/015)不读该文件,运行时 token **不变**(既不增也不减)。这是按需 reference,不是常驻面,不会影响未读它的题。
|
||||
- **覆盖敞口**:RC-2 子集仅 080 一题(reach=0.333),证据基数小。coverage 判该文件「密/overfit 低」,本轮 eval 可在 080 上裁真伪,但单题不可外推到「所有发消息任务」。建议后续补「读 messages-send.md 后用 --markdown / 媒体 / @mention」的 case 加厚子集。预期收益落在 **token 轴**(080 visible 下降),effect 轴维持不退化。
|
||||
|
||||
## 刻意没做什么(反 reward-hack / 反过拟合)
|
||||
- 没硬编码任何评测题答案;没删任何能力、flag、guardrail、身份/scope 说明;没碰 lark-im 以外文件,也没把无关根因捆进本轮(commit 仅 1 个文件)。
|
||||
- **没碰 SKILL.md(RC-1)**:尽管 reach=1.0 杠杆最大,但其剩余内容是驱动 015/080 bot 身份判断的承重 identity/约束,diagnosis 标为 effect 风险点;在「保住成功率」硬门槛下不拿成功率换 token。
|
||||
- **没补收窄/分页指引**(015 的 22.5k chat-messages-list 黑洞):那是「增内容」,与降 token 目标方向相反,diagnosis 已列为观察项、本轮不做。
|
||||
- 本改动**不是按评测错误反推**的参数/路由拟合——是基于 annotation + content-taxonomy 的结构性去重,删的是重复表述而非按 080 的具体内容裁剪;真实价值在「任何读该 reference 的发消息任务都少读重复 token」,080 只是当轮可验证的子集。
|
||||
- 未发现需要 breaking(T3)才能根治的点;本轮纯 T1 文档去重即可。
|
||||
|
||||
## 签名
|
||||
- signature: 557349b40feb359bb791749a37571d59edb7e72e (commit 82a099fe 的 diff hash) tier: T1
|
||||
11
harness-opt/rounds/round-002/trend.json
Normal file
11
harness-opt/rounds/round-002/trend.json
Normal file
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"round": 1,
|
||||
"n": 3,
|
||||
"pass_n": 0,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"tool_calls": 26.333333333333332,
|
||||
"duration_ms": 50189.0,
|
||||
"token": 31997.0
|
||||
}
|
||||
]
|
||||
43
harness-opt/rounds/round-002/workorder.md
Normal file
43
harness-opt/rounds/round-002/workorder.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Round 2 归因派工单(parent=a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e;模块未定,由 candidate-writer 据诊断点名)
|
||||
|
||||
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer)+ 逐题结构化 `attribution.json`(给 dashboard)。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
|
||||
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
|
||||
|
||||
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
|
||||
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read,都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置),正该选来修——不是白烧;reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
|
||||
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
|
||||
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
|
||||
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
|
||||
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
|
||||
|
||||
## 逐轮诊断信号趋势(纯诊断,不进判决)
|
||||
|
||||
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
|
||||
|
||||
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
|
||||
|
||||
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✗ 使用当前用户身份创建名为「IM合作群」的群聊
|
||||
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
|
||||
✗ 将傅一铭和傅二铭加入该群
|
||||
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id,无后续添加操作。
|
||||
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
|
||||
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
|
||||
|
||||
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 成功定位名为「fusanming_at_openclaw群」的群,并获取最近包含「飞豆」关键字的消息
|
||||
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
|
||||
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
|
||||
|
||||
### 3 [PASS] ctx=37942 (acc=251669) 45769ms tools=23
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
|
||||
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
|
||||
✓ 将该卡片发送到新建群中,预期返回 message_id
|
||||
59
harness-opt/rounds/round-003/attribution.json
Normal file
59
harness-opt/rounds/round-003/attribution.json
Normal file
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"case_id": "1",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "workorder=PASS(聚合口径);判分点证据 3/3 ✗,按判分点当实质 FAIL 处理",
|
||||
"token": 34555,
|
||||
"duration_ms": 37000,
|
||||
"tool_calls": 31,
|
||||
"cmd_attempts": 7,
|
||||
"cmd_failures": 5,
|
||||
"cmd_fail_rate": 0.71,
|
||||
"discoverability_state": "无(失败命令全是跨域 contact + auth,非 lark-im;chat-create.md 调用前已读但未走到使用)",
|
||||
"axis": "效果",
|
||||
"root_cause": "沙箱 user 授权不可完成 + 跨域 lark-contact 命令依赖;无 lark-im 文档根因,本轮不改",
|
||||
"token_hotspot": "运行时冗余清单常驻(SKILL.md 3,456)+ 按需 chat-create.md 3,062(读了没用上);lark-shared 3,751 与系统 Skill 列表注入 4,612 均不归因",
|
||||
"token_reliability": "常驻静态(SKILL.md)/ 按需读取(chat-create.md,本题读了没用上)",
|
||||
"duration_hotspot": "多轮交互(查联系人→切 contact→失败→授权→qrcode 重试)+ 纯外部API延迟(部分不可归因)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "效果侧无 lark-im 文档缺信息(环境+跨域);token 侧 chat-create.md 把同组 flag 在 Commands/Usage Scenarios 重复演示、Common Errors 复述 validation 字符串,属可删冗余"
|
||||
},
|
||||
{
|
||||
"case_id": "2",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,全程 bot 身份无授权阻断",
|
||||
"token": 54568,
|
||||
"duration_ms": 125000,
|
||||
"tool_calls": 49,
|
||||
"cmd_attempts": 11,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.27,
|
||||
"discoverability_state": "① 从没读(messages-search.md / chat-messages-list.md reach=0,直接猜命令;本题未读任何 lark-im reference)",
|
||||
"axis": "token",
|
||||
"root_cause": "无过滤 +chat-messages-list --page-all 全量拉取 → 43.5KB 输出被 Read 整文件灌入 22,556 tok;token 大头非 lark-im doc。修它需补收窄/前置内容,与降 token 目标方向冲突,列观察项",
|
||||
"token_hotspot": "工具返回原样输出(block #19 单次 Read 22,556 tok / 51.5%,归「其他工具调用/返回」)",
|
||||
"token_reliability": "单次输出(强依赖该群消息量,单题不可外推,非稳定常驻热点)",
|
||||
"duration_hotspot": "多轮交互 + 重试(messages-search 连环 exit2 → page-all → 大输出 → 多次本地 grep 抠数据)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现(model calls 16 作旁证,明显高于 080)",
|
||||
"doc_fix_hint": "本题无 T1 可发力的 token 抓手(大头是单次工具输出,非 lark-im doc 常驻);缺的是大群消息查询的 server-side 收窄指引,但补它=增内容、与降 token 反向,不作本轮根因"
|
||||
},
|
||||
{
|
||||
"case_id": "3",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,主动选 bot 身份建群+发卡片均 ok:true,零命令失败",
|
||||
"token": 38009,
|
||||
"duration_ms": 47000,
|
||||
"tool_calls": 22,
|
||||
"cmd_attempts": 3,
|
||||
"cmd_failures": 0,
|
||||
"cmd_fail_rate": 0.0,
|
||||
"discoverability_state": "无(无失败命令;SKILL.md + chat-create.md + messages-send.md 全部状态③:调用前已读且用上)",
|
||||
"axis": "token",
|
||||
"root_cause": "读取 Skill 占 56.4%;本轮唯一干净 token 抓手 = chat-create.md 内部冗余(示例罗列 + 场景重复 + --help 镜像),从未被优化过",
|
||||
"token_hotspot": "运行时冗余清单常驻 + 按需 reference(chat-create.md 当前 2,336 raw,可压 Commands/Usage Scenarios 重叠 + Common Errors validation 镜像;trace 里 messages-send.md 5,365 是旧版,round-2 已压到 2,006,本轮不再可压)",
|
||||
"token_reliability": "按需读取(chat-create.md reach=0.667,本题是其压缩收益唯一稳态兑现题)",
|
||||
"duration_hotspot": "无离群(建群+发卡片正常串行,无重试、无写后回查)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "chat-create.md 把同组 flag 在 Commands(12 例)+Usage Scenarios(3 场景)重复演示、Common Errors 多行复述 --help/报错本身就会吐的 validation 字符串,属可删冗余;232043 两步流 / --chat-mode topic 区分 / --owner 默认为载重红线,压缩中不可误删"
|
||||
}
|
||||
]
|
||||
27
harness-opt/rounds/round-003/case-commands.json
Normal file
27
harness-opt/rounds/round-003/case-commands.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"1": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"contact +search-user"
|
||||
],
|
||||
"3": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-create",
|
||||
"im +messages-send"
|
||||
],
|
||||
"2": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-messages-list",
|
||||
"im +chat-search",
|
||||
"im +messages-mget",
|
||||
"im +messages-search",
|
||||
"im +messages-send",
|
||||
"im messages forward",
|
||||
"schema im.messages.forward",
|
||||
"schema im.messages.search"
|
||||
]
|
||||
}
|
||||
20
harness-opt/rounds/round-003/child-cache.json
Normal file
20
harness-opt/rounds/round-003/child-cache.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 33840,
|
||||
"token_usage": 237434,
|
||||
"duration_ms": 44127,
|
||||
"tool_call_count": 25,
|
||||
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id(首次搜索用单字失败后改为双字搜索成功),然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确,使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。"
|
||||
},
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35942,
|
||||
"token_usage": 234388,
|
||||
"duration_ms": 43185,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "执行者正确理解用户意图,使用用户身份创建群并发送卡片消息。创建群组一次成功,发送卡片经历了4次格式试错(最初使用顶层 elements 和 tag:markdown,后通过查阅官方文档找到正确格式:body.elements + div + lark_md),最终成功发送并返回 message_id。试错后自行纠正符合评判原则,不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}"
|
||||
}
|
||||
}
|
||||
119
harness-opt/rounds/round-003/diagnosis.md
Normal file
119
harness-opt/rounds/round-003/diagnosis.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Round 3 归因(parent=557349b…(round-2 已采纳候选);候选模块见 candidate_modules,由 candidate-writer 据诊断+reach 点名)
|
||||
|
||||
> 目标(objective.json):**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化;token 与 duration 是并列成本杆。tier=T1,仅可改 `skills/lark-im/**`。target_axis=token。
|
||||
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
|
||||
|
||||
## ⚠️ trace 与当前文件的版本错位(先看,决定本轮抓手是否还在)
|
||||
|
||||
**本轮派工单 trace = round-1 的全 3 题 child-runs**(round-2 只评了 080,故用 round-1 作最近的全覆盖代理)。这些 trace 里的 reference 体积是 **round-1/round-2 改动之前** 的旧版。我用 session-analyze 所用的同一 ai-tokenizer 实测了**当前工作树**文件,确认两者错位如下:
|
||||
|
||||
| 文件 | trace 内体积(旧版,Read 计) | 当前实测(raw / Read 计) | 已被哪轮收割 |
|
||||
|---|---|---|---|
|
||||
| `SKILL.md`(Skill 注入正文) | 3,455–3,456 tok | 3,525 raw | round-1(API Resources/权限表→schema 指针) |
|
||||
| `references/lark-im-messages-send.md` | **5,365 tok** | **2,006 raw / 2,194 Read** | **round-2(5,365→2,006,已收割)** |
|
||||
| `references/lark-im-chat-create.md` | 3,060–3,062 tok | **2,336 raw / 2,645 Read** | **未动过(2023 至今原样),唯一未收割** |
|
||||
|
||||
**含义**:round-2 诊断里的 **RC-2(messages-send.md 内部冗余)已经在 round-2 被采纳并收割**(5,365→2,006),它不再是本轮抓手——不要据 trace 里的 5,365 重复提一遍。本轮 trace 里那块 5,365 是历史值,当前已不存在。**reach>0 集合里唯一还没被压过的干净文件就是 `chat-create.md`**(round-2 的 RC-3)。
|
||||
|
||||
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
|
||||
|
||||
### RC-1(token,本轮头号且基本是唯一的干净抓手,reach=0.667:014+080)—— `chat-create.md` 内部存在「示例罗列 + 场景重复 + --help 镜像」三类可压缩冗余,且从未被优化过
|
||||
- **现象**:`chat-create.md` 当前 2,336 raw tok(Read 计 ~2,645),是 reach>0 集合里**唯一未被任何轮收割**的 reference。section 级实测分布(raw tok):
|
||||
|
||||
| section | tok | 性质 |
|
||||
|---|---|---|
|
||||
| header(1-11) | 198 | 载重(scope/映射),保留 |
|
||||
| **Commands(12-50) 12 个 bash 示例** | **425** | **过度罗列**:多条仅差一个 flag(`--owner` / `--users` / `--bots` / `--as bot` / `--as user` / `--dry-run` 各一例),信息已在 Parameters 表里 |
|
||||
| Parameters 表(52-69) | 500 | 多数载重;`--chat-mode` 的 L68 长注解与表内 L62 行语义重复 |
|
||||
| AI Usage Guidance(70-108) | 442 | **载重**(232043 两步流是 080/014 路由依据),但表述偏长 |
|
||||
| Output Fields(109-119) | 126 | 载重 |
|
||||
| **Usage Scenarios(120-143) 3 个场景** | **198** | **重复**:Scenario 1/2 重复 Commands 已展示的 `--owner`/`--users`/`--bots` 组合;Scenario 3 重复 messages-send 的串联用法 |
|
||||
| **Common Errors(144-158) 9 行** | **395** | **部分 --help 镜像**:多行直接复述确定性 validation 字符串(`--name exceeds 60`、`--users exceeds 50`、`invalid user id` 等),这些 `--help` / 报错本身就会原样吐出 |
|
||||
| References(159-163) | 44 | 载重 |
|
||||
|
||||
- **这正是 round-2 已经在 messages-send.md 上验证过、且被采纳的同一套压缩模式**:round-2 把 messages-send.md 的「4 处重复选型规则 + 全媒体形态 Commands + --help 镜像」压成「保留载重规则 + 一句 `--help` 指针」(5,365→2,006,被采纳)。chat-create.md 的 Commands(425)↔Usage Scenarios(198) 重叠、Common Errors(395) 的 validation 镜像,是同型冗余。
|
||||
- **可压缩量级(粗估,非药方)**:可压缩质量集中在 Commands+Usage Scenarios 的重叠(合计 ~623 tok,去重后可省一大半)+ Common Errors 的 --help 镜像行。**保守估计可从 2,336 压到 ~1,500–1,700 raw tok(省约 600–800 tok,约 30%)**,与 messages-send.md 的压缩比同量级。具体改法与确切降幅由 candidate-writer 决定、评测裁决。
|
||||
- **载重红线(candidate-writer 取舍时的 effect 风险点,不是 RC-1 不成立)**:AI Usage Guidance 的 **232043 两步流 + `succeed_type=1`**、`--chat-mode topic` vs 普通群+话题消息模式的区分、`--owner` 默认行为,是 014/080 走通 bot 身份建群的语义依据,**不能在压缩中误删**。这条 reference 被 080 实读且 080 据它建群成功(`ok:true`),所以 effect 风险真实存在——压的是示例/场景/报错镜像的体积,不是语义规则。
|
||||
- **axis=token**。可信度=**按需读取**(reach=0.667,子集=014+080,2 题)。压它的降幅只在这 2 题子集里计入,不被 015(没读它)稀释;但子集仅 2 题、且 014 是「读了没用上」(授权阻断没走到建群),实际吃到压缩收益的稳态题只有 080 一题——**证据基数小,降幅需评测在子集上确认**(见数据缺口)。
|
||||
|
||||
### RC-2(token,已收割,本轮不再是抓手)—— messages-send.md 的内部冗余 round-2 已压掉
|
||||
- round-2 RC-2 已被采纳:messages-send.md 5,365→2,006 raw。**本轮不要据 trace 里的 5,365 重复提**。当前 messages-send.md 已是「载重规则 + `--help` 指针」形态,无明显二次压缩空间(剩余多为 content-flag 选型、@mention、media 约束等载重内容)。reach=0.333(仅 080)。
|
||||
|
||||
### RC-3(token,无 T1 干净抓手)—— SKILL.md 常驻正文 round-1 已压过,剩余多为载重 identity/路由
|
||||
- SKILL.md 经 Skill 工具每题必加载(reach=1.0),当前 3,525 raw tok(round-1 已把 API Resources/权限表折叠成 schema 指针)。剩余 `## Important Notes`(L36–85) 各小节(Sender Name Resolution / message enrichment / `--download-resources` / Card / Flag / Feed Shortcut)与 `## Shortcuts` 全表(L87–115) 虽本轮 3 题低命中,但它们是**全域 identity/路由/约束**——这是 round-1 已经动过一刀的同一文件,**再压属同向继续、但删错会碰坏 015/080 已走通的 bot 身份与命令路由判断**(effect 风险高于 RC-1)。**列为更次级、风险更高的抓手**,不作为本轮首选;若要动须只删本轮已确证低命中且非路由的纯枚举行,谨慎程度高于 chat-create。
|
||||
|
||||
## 命令失败热点(跨 case;失败类型由我从 timeline 命令串读出,非判决数字)
|
||||
|
||||
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|
||||
|---|---|---|---|---|
|
||||
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2;`--as user` token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
|
||||
| `auth qrcode --output <绝对/沙箱外路径>` | 1 | 1 (014) | unsafe output path,改相对路径重试成功 | 路径约束在 lark-shared(不可改) |
|
||||
| `im +messages-search` | 2 | 1 (015) | bot exit2 + `--as user` exit2 | 该命令 user-only(SKILL 表已注明);bot 身份必败,agent 没看清就猜 |
|
||||
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2(无过滤 page-all) | 见下「015 token 黑洞」 |
|
||||
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败;014 的失败全在跨域 contact + auth;015 的失败集中在 messages-search(user-only,bot 必败)与无过滤 page-all。**没有 lark-im 侧常规「报错/参数整形」工单**——与 token 减体积方向一致,本轮抓手是减体积不是补内容。
|
||||
|
||||
## 015 的 token 黑洞(与 round-2 一致,复述以免被误当成 token 抓手)
|
||||
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19:一次 `Read` 工具读入 22,556 tok(占该题 visible 51.5%)**。成因链:#12/#17 `+messages-search`/`--page-all` exit2 → #18 退到 `+chat-messages-list`(无过滤)→ 输出 43.5KB 被持久化 → agent `Read` 整文件 → 22.5k tok 灌进上下文 → 再靠本地 grep(#27–33) 抠出「飞豆」两条。
|
||||
- **从文档角度**:`chat-messages-list.md` 本题 reach=0(状态①:调用前从没读),它本写了 `--start/--end`、`--page-size` 等可避免全量拉取的约束。**但补它=增常驻/触达内容,与本轮降 token 目标方向相反**(见方向冲突);且 22.5k 是**单次工具输出**(强依赖该群消息量,单题不可外推),不是稳定常驻热点。**结论:观察项,交评测裁决,不作为本轮 token 抓手。**
|
||||
|
||||
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
|
||||
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash(本轮 3 题均未跑任何 `--help`)。
|
||||
|
||||
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了;014 仍卡(环境,非内容);不可改 |
|
||||
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功;014 调用前读→授权阻断(非 reference 错)。**非触达问题,纯减体积** |
|
||||
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题**(已收割) |
|
||||
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① 015 调用前从没读→`--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token,与目标冲突 |
|
||||
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读,直接猜 `+messages-search` ×2 → exit2(user-only,bot 必败) |
|
||||
- **结论**:**本轮没有「该前置」的干净 case**。RC-1(chat-create.md 减体积)是「调用前已读、内容够用 → 去冗余」的纯 token 减法,不涉及触达。015 的两处 ① 触达缺口确实存在,但修它们=增内容、与降 token 目标相反,且 015 已 PASS(bot + 本地 grep 兜底)——属观察项,**不要被诱导去推前置**。
|
||||
|
||||
## 方向冲突记录(硬性约束 7)
|
||||
- **减体积(RC-1 chat-create.md,与 objective.direction 同向)** vs **补收窄/前置指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降按需 token,后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反,**不可合并**。本轮目标是降 token,取减体积一侧;015 全量灌入作为观察项记录、不作为要补的内容根因。
|
||||
|
||||
## 差距台账复盘
|
||||
- 无(`discard-ledger.json` 为空,无已跑未采纳候选)。
|
||||
|
||||
## 逐 case
|
||||
|
||||
### 1 (014) [workorder=PASS / 实质 FAIL] token=34,555(reported)/visible 17,364 耗时=37s 命令失败率=5/7 维度=效果(不可修)
|
||||
- 判分点结果:3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_id(user 授权阻断 + 跨域 contact)。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
|
||||
- 命令失败:5/7。`contact +search-user` bot exit2 ×2、`--as user` token_missing ×2;`auth qrcode` 绝对路径 unsafe ×1(改相对路径成功)。**全部非 lark-im 命令**。
|
||||
- 可发现性时序:#4 读 SKILL.md 正文(3,456) + #6 读 lark-shared(3,751,跨 skill) + #7 读 chat-create.md(3,062,调用前已读);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
|
||||
- token 归因:SKILL.md 正文 3,456(常驻静态,19.9%)+ lark-shared 3,751(**跨 skill,不归因 lark-im**)+ chat-create.md 3,062(按需,17.6%,**本题读了没用上**——授权阻断没走到建群)+ 系统 Skill 列表注入 4,612(固定开销,不归因)。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
|
||||
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试);多为授权链路 + 跨域固有串行 + 反应式重试(duration 弱信号,需多轮复现)。
|
||||
- 文档根因:效果=沙箱 user 授权 + 跨域 contact(环境,**无 lark-im 文档根因,本轮不改**);token=chat-create.md 按需冗余(RC-1,但本题读了没用上,收益只在 080 这种走通题里兑现)+ SKILL.md 常驻(RC-3,风险高、次级)。
|
||||
|
||||
### 2 (015) [PASS·真] token=54,568(reported)/visible 43,760 耗时=2m5s 命令失败率=3/11 维度=token(但大头非 lark-im doc)
|
||||
- 判分点结果:3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send` 均 `ok:true`)。**全程 bot 身份,无授权阻断**。
|
||||
- 命令失败:3/11。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2(无过滤);agent 退到无 page-all + 本地 grep 兜底成功。(#14 `--page-all | grep` 返回空属「成功但无命中」,非硬失败,未计入。)
|
||||
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` 调用前从没读(reach=0),直接猜命令。**本题未读任何 lark-im reference**,故 lark-im reference 的体积与本题 token 无关。
|
||||
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok(51.5%,归「其他工具调用/返回」)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入(**单次输出**可信度,强依赖该群消息量)。SKILL.md 正文 3,448(常驻)。lark-shared 3,749(跨 skill,不归因)。**RC-1 改 chat-create.md 对本题 token 无影响**(本题没读它)。
|
||||
- 耗时归因:本题最长(2m5s),主因 messages-search 连环失败→改 page-all→大输出→多次本地 grep 抠数据的多轮往返(duration 弱信号;model calls 16/raw 32,明显高于 080,作旁证)。
|
||||
- 文档根因:token 黑洞的放大器=`chat-messages-list.md` 没被读到(状态①)+ SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力),列为观察项;本题已 PASS。本轮 token 抓手(RC-1)不落在本题。
|
||||
|
||||
### 3 (080) [PASS·真] token=38,009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
|
||||
- 判分点结果:3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群 `ok:true`→发 interactive 卡片 `ok:true`。**任务完整完成,零命令失败**。
|
||||
- 命令失败:0/3。三条 lark-cli(auth status / chat-create / messages-send)全成功。
|
||||
- 可发现性时序:#4 读 SKILL.md 正文(3,455) + #6 读 lark-shared(3,751,跨 skill) + #9 读 chat-create.md(3,060) + #10 读 messages-send.md(5,365,旧版) ,全部状态③(调用前已读且用上)。**无触达问题。** 实际只用了 `+chat-create --name … --format json` 的最简形态——没用两步流/owner/members/topic/error-recovery。
|
||||
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%:messages-send.md 5,365(trace 旧版,**当前已被 round-2 压到 2,006,本轮不再可压**)+ SKILL.md 正文 3,455(常驻,RC-3)+ chat-create.md 3,060(按需,**RC-1,当前 2,336,本轮唯一干净抓手**)。系统 Skill 列表注入 4,612(固定开销,不归因)。lark-shared 3,751(跨 skill,不归因)。
|
||||
- 耗时归因:47s,全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
|
||||
- 文档根因:无效果根因(已绿);token=RC-1(chat-create.md 内部冗余,本题是其收益唯一稳态兑现题)+ RC-3(SKILL.md 常驻,风险高、次级)。**本题 token 杠杆最清晰且 effect 风险可控**(命令全成功,压 chat-create.md 的示例/场景/报错镜像不碰 080 实际用到的最简建群链路)。
|
||||
|
||||
## 给 candidate-writer 的收口(不含具体改法)
|
||||
- **唯一在 T1 内还没被收割的干净 token 抓手是 RC-1(`chat-create.md` 内部冗余)**:Commands 12 例过度罗列 + Usage Scenarios 3 场景重复 Commands + Common Errors 9 行部分镜像 validation 字符串——**与 round-2 已采纳的 messages-send.md 压缩同型**,粗估可省 ~600–800 raw tok(约 30%)。reach=0.667(014+080),降幅在子集计入。
|
||||
- **载重红线**:AI Usage Guidance 的 232043 两步流 + `succeed_type=1` + `--chat-mode topic` 区分 + `--owner` 默认,是 080/014 走通 bot 建群的语义依据,**压缩中不可误删**——压的是示例/场景/报错镜像体积,不是规则。
|
||||
- **RC-2 已收割**:messages-send.md round-2 已 5,365→2,006,trace 里的 5,365 是历史值,**不要重复提**。
|
||||
- **RC-3(SKILL.md 常驻)是次级且风险更高**:round-1 已压过一刀,剩余多为全域 identity/路由/约束,删错碰坏 015/080 已走通的 bot 身份与命令路由——不作首选。
|
||||
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处 ① 触达缺口(chat-messages-list/messages-search 没读)虽真实,但修=增内容、与降 token 反向,且 015 已 PASS——属观察项。
|
||||
- **effect 不可在本轮 T1 内合法抬升**:014 是环境(沙箱不能扫码)+ 跨域 contact,无 lark-im 文档根因;015/080 已真 PASS。effect deltas 视作 auth-noise,不追。
|
||||
- **干净 token 抓手接近见底(诚实判断)**:reach>0 集合三个文件中,messages-send.md(round-2)与 SKILL.md(round-1)已各压一刀,**chat-create.md 是最后一个未动过的干净文件**。压完它之后,T1 内 reach>0 的纯冗余(罗列/重复/--help 镜像)基本耗尽;再往下只剩 (a) 高 effect 风险的 SKILL.md 载重内容,或 (b) reach=0 的 22 个盲区 reference(压了也不在判决集、无法被采纳)。**本轮 RC-1 很可能是这条优化路径上最后一个低风险、可被采纳的 token 抓手。**
|
||||
- **缺失信息(doc_fix_hint 语气,非药方)**:chat-create.md 把同一组 flag 在 Commands(12 例) 与 Usage Scenarios(3 场景) 重复演示、Common Errors 多行复述 `--help`/报错本身就会吐的 validation 字符串——这类「枚举/重复/镜像、低增量」内容是其 token 的主要去处,且是减法(删冗余)而非加法。
|
||||
|
||||
## 数据缺口
|
||||
1. **trace 版本错位(最关键)**:本轮 trace=round-1 旧版 child-runs,messages-send.md 在 trace 里仍是 5,365(round-2 已压到 2,006)。所有「当前文件体积」结论我已用 ai-tokenizer 实测当前工作树校正(SKILL.md 3,525 / chat-create.md 2,336 / messages-send.md 2,006),但**单题行为与 reach 仍来自旧 trace**——若 round-2 改动改变了 080/014 的读取行为,需以实际 round-3 eval-run 复核。
|
||||
2. **RC-1 子集小**:chat-create.md reach=0.667 但实际吃到压缩收益的稳态题只有 080(014 读了没用上、授权阻断),证据基数=1,降幅需评测在子集确认。
|
||||
3. **015 的 22.5k 黑洞是单次工具输出**,强依赖该群消息量,非稳定常驻热点,单题不可外推;且与降 token 目标方向冲突,不作抓手。
|
||||
4. **duration 三题波动大**(37s/2m5s/47s),015 长尾主因 messages-search 连环失败+大输出多轮抠数据;单轮不足定论,需多轮复现。model calls(8/16/6) 比 wall-clock 稳,可作旁证。
|
||||
5. **工具调用口径不一致**:trend.json 的 R1 tool_calls=26.3、R2=10,与 session-analyze 的 model calls(8/16/6) 口径不同(趋势表疑似含 raw 计数);旁证以 timeline 实际往返为准。趋势看:R1→R2 命令失败率 0.60→0.35、tool_calls 26→10 明显下降,但那主要是 effect 从「三题全卡授权」变成「2 真 PASS + 1 卡」带来的,**不是 token 改动的功劳**;token 均值 R1 31,997→R2 42,377 上升,主因是 R2 只评 080(单题大)口径差异 + 015 黑洞,非文档常驻变重——趋势对 token 轴判读价值有限,以单题 session-analyze 为准。
|
||||
6. **effect 维度全部归因为「无文档根因/不可修」**:014 跨域+环境,015/080 已绿。本轮 effect 无 T1 可发力点,deltas 视作 auth-noise。
|
||||
1
harness-opt/rounds/round-003/discard-ledger.json
Normal file
1
harness-opt/rounds/round-003/discard-ledger.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
harness-opt/rounds/round-003/failure-memory.json
Normal file
1
harness-opt/rounds/round-003/failure-memory.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
220
harness-opt/rounds/round-003/module-reach.json
Normal file
220
harness-opt/rounds/round-003/module-reach.json
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.333,
|
||||
"read_cases": [
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
}
|
||||
18
harness-opt/rounds/round-003/review.json
Normal file
18
harness-opt/rounds/round-003/review.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/references/lark-im-chat-create.md",
|
||||
"tier": "T1",
|
||||
"round_index": 3,
|
||||
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
|
||||
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
|
||||
"reason": "纯瘦身改动,对抗式逐项核验未发现可证伪点:14 条承重红线(232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes)在改后文件全部 grep 命中;Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help),删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1(本文件内部冗余),strategy 明确不捆 RC-2/RC-3。",
|
||||
"dimensions": {
|
||||
"reward_hack": {"pass": true, "evidence": "无硬编码评测答案/资源名/ID;未对 080 的 --name --format json 最简建群链做特判,080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"},
|
||||
"semantic_regress": {"pass": true, "evidence": "14 条承重红线改后文件全部命中;Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance);删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation(运行时可复得);删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"},
|
||||
"token_shift": {"pass": true, "evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力;welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"},
|
||||
"contract_break": {"pass": true, "evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段:2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"},
|
||||
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线(semantic_regress / contract_break 两维):未删承重、未破坏结构;reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』;annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"},
|
||||
"single_root_cause":{"pass": true, "evidence": "diff 只服务 RC-1(本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"}
|
||||
}
|
||||
}
|
||||
394
harness-opt/rounds/round-003/round.json
Normal file
394
harness-opt/rounds/round-003/round.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"round": 3,
|
||||
"status": "admitted",
|
||||
"parent_id": "557349b40feb359bb791749a37571d59edb7e72e",
|
||||
"parent_worktree": "/Users/bytedance/Projects/cli",
|
||||
"child_worktree": "/Users/bytedance/Projects/cli",
|
||||
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
|
||||
"module": "skills/lark-im/references/lark-im-chat-create.md",
|
||||
"candidate_modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"module_reach": {
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.333,
|
||||
"read_cases": [
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
},
|
||||
"expected_reach": {},
|
||||
"minibatch": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"pareto_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"artifacts": {
|
||||
"workorder": "workorder.md",
|
||||
"diagnosis": "diagnosis.md",
|
||||
"attribution": "attribution.json",
|
||||
"strategy": "strategy.md",
|
||||
"review": "review.json",
|
||||
"trend": "trend.json"
|
||||
},
|
||||
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
|
||||
"signature": "53194d7a111df326cc078b633f43587225bd0132",
|
||||
"tier": "T1",
|
||||
"intent": "dedup Commands<->Usage Scenarios overlap + compress --help-mirroring Common Errors in chat-create.md; keep all red lines (232043 two-step,succeed_type=1,chat-mode topic,--owner)",
|
||||
"target_axis": "token",
|
||||
"changed_files": [
|
||||
"skills/lark-im/references/lark-im-chat-create.md"
|
||||
],
|
||||
"decision_basis": {
|
||||
"type": "module",
|
||||
"module": "skills/lark-im/references/lark-im-chat-create.md"
|
||||
},
|
||||
"decision_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"review": {
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/references/lark-im-chat-create.md",
|
||||
"tier": "T1",
|
||||
"round_index": 3,
|
||||
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
|
||||
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
|
||||
"reason": "纯瘦身改动,对抗式逐项核验未发现可证伪点:14 条承重红线(232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes)在改后文件全部 grep 命中;Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help),删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1(本文件内部冗余),strategy 明确不捆 RC-2/RC-3。",
|
||||
"dimensions": {
|
||||
"reward_hack": {
|
||||
"pass": true,
|
||||
"evidence": "无硬编码评测答案/资源名/ID;未对 080 的 --name --format json 最简建群链做特判,080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"
|
||||
},
|
||||
"semantic_regress": {
|
||||
"pass": true,
|
||||
"evidence": "14 条承重红线改后文件全部命中;Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance);删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation(运行时可复得);删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"
|
||||
},
|
||||
"token_shift": {
|
||||
"pass": true,
|
||||
"evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力;welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"
|
||||
},
|
||||
"contract_break": {
|
||||
"pass": true,
|
||||
"evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段:2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"
|
||||
},
|
||||
"devguide": {
|
||||
"pass": true,
|
||||
"evidence": "对照 review-rubric 优化红线(semantic_regress / contract_break 两维):未删承重、未破坏结构;reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』;annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"
|
||||
},
|
||||
"single_root_cause": {
|
||||
"pass": true,
|
||||
"evidence": "diff 只服务 RC-1(本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"
|
||||
}
|
||||
}
|
||||
},
|
||||
"child_k": 5,
|
||||
"eval_trace": null,
|
||||
"retro": {
|
||||
"cause": "已入池",
|
||||
"noise_borderline": false,
|
||||
"summary": "越带入池,无需复盘补发"
|
||||
},
|
||||
"retro_sessions": [
|
||||
{
|
||||
"case": "1",
|
||||
"session": "harness-opt/rounds/round-003/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 34270,
|
||||
"child": 33840,
|
||||
"gain": "收益现",
|
||||
"pass_delta": null
|
||||
},
|
||||
{
|
||||
"case": "3",
|
||||
"session": null,
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 35478,
|
||||
"child": 35942,
|
||||
"gain": "反向",
|
||||
"pass_delta": null
|
||||
}
|
||||
],
|
||||
"verdict": "admitted",
|
||||
"ci": null,
|
||||
"new_candidate": "53194d7a111df326cc078b633f43587225bd0132",
|
||||
"decision": {
|
||||
"parent_success": 1.0,
|
||||
"child_success": 1.0,
|
||||
"parent_score": 0.8,
|
||||
"child_score": 1.0,
|
||||
"score_saved": 0.19999999999999996,
|
||||
"score_threshold": 0.09532271373123208,
|
||||
"parent_token": 34874.0,
|
||||
"child_token": 34891.0,
|
||||
"saved": -17.0,
|
||||
"threshold": 4532.708313776408,
|
||||
"parent_duration": 45267.5,
|
||||
"child_duration": 43656.0,
|
||||
"dur_saved": 1611.5,
|
||||
"dur_threshold": 4899.200953624988,
|
||||
"dur_margin": 1.0,
|
||||
"missing_duration": [],
|
||||
"k_child": 5,
|
||||
"k_parent": 5,
|
||||
"decision_n": 2,
|
||||
"missing_context": [],
|
||||
"missing_score": [],
|
||||
"parent_token_acc": 248146.5,
|
||||
"child_token_acc": 235911.0,
|
||||
"phi0_score": 0.5666666666666667,
|
||||
"eff_margin": 1.0,
|
||||
"parent_token_full": 34874.0,
|
||||
"child_token_full": 34891.0,
|
||||
"saved_full": -17.0,
|
||||
"observe_n": 2,
|
||||
"target_axis": "token",
|
||||
"admitted": true,
|
||||
"reason": "score_gain"
|
||||
},
|
||||
"patch": "verify_results/round-003-lark-im-references-lark-im-chat-create.patch"
|
||||
}
|
||||
43
harness-opt/rounds/round-003/strategy.md
Normal file
43
harness-opt/rounds/round-003/strategy.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Round 3 候选策略(模块=references/lark-im-chat-create.md, tier=T1, 主指标=token)
|
||||
|
||||
## 根因与选择
|
||||
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RC-1 chat-create.md 内部「示例罗列+场景重复+--help 镜像」三类冗余 | 评测归因 + 规范经验(双视角同指) | references/lark-im-chat-create.md(0.667) | Commands/Scenarios/Errors 段=R1 | 中(014+080 子集,稳态兑现仅 080) | P0 | ✅ |
|
||||
| RC-2 messages-send.md 内部冗余 | 评测归因 | references/lark-im-messages-send.md(0.333) | — | — | — | (round-2 已收割,不再是抓手) |
|
||||
| RC-3 SKILL.md 常驻正文 | 评测归因 | SKILL.md(1.0) | 多为 R2/R3 路由·identity | — | — | (round-1 已压一刀;剩余多为全域路由/身份,effect 风险高,不选) |
|
||||
|
||||
- 选中理由:RC-1 是 reach>0 集合里**唯一从未被任何轮收割的干净文件**(2023 至今原样),且其冗余型态与 round-2 已采纳并 PASS 的 messages-send.md 完全同型(罗列+重复+--help 镜像)。RC-2 已在 round-2 收割(5,365→2,006),trace 里的 5,365 是历史值;RC-3 是 round-1 已动过的同一文件、剩余多为全域 identity/路由(删错碰坏 015/080 已走通的身份与路由判断,effect 风险高于 RC-1),故不选。
|
||||
- 选模块理由:chat-create.md reach=0.667(014+080 调用前都读到,状态③,非触达问题——纯减体积场景);它正是承载 RC-1 的文件。未选 reach=0 的 22 个盲区 reference(改了也不在判决集、无法被采纳,触 reach 锁)。
|
||||
- 规范经验源补注:双视角同指一处。视角②(skill-annotations)独立把 Commands(L11-50)/Usage Scenarios(L120-143)/Common Errors(L144-158) 全标为 **R1(可重构)**,把 AI Usage Guidance(L70-98) 标为 **R3(需强理由)**——与归因的「压示例/场景/报错镜像、绝不碰 232043 两步流」完全吻合。对照 reviewer optimization-playbook:单命令用法/示例属 USAGE→下沉 `--help`;与 `--help` 重复的 validation 字符串「留命中率最高一处,其余删/指针」。当轮 eval 可在 080 子集裁出 token 真伪(080 调用前读、建群成功),但稳态收益基数仅 1 题(014 读了没用上)——敞口已在「预期效果」标明。
|
||||
|
||||
## 改了什么(逐处)
|
||||
- **Commands(L12-50)** — 12 个 bash 示例(多条仅差一个 flag)压成 5 个差异化示例 + 一行 `--help` 指针。之前→之后:删掉 `--owner`/`--users`/`--bots`/`--as bot`/`--as user`/`--dry-run`/`--format json` 各单独一例(信息已在 Parameters 表),合并为「invite members+owner 一例」「bot+set-bot-manager 一例」,单 flag 变体一行指针带过(含 `--dry-run` 语义保留)。
|
||||
- **Usage Scenarios(L120-143)** — 整段 3 场景删除/搬迁。Scenario 1(owner)、Scenario 2(users+bots+owner)重复 Commands 与 Parameters 已展示的 flag 组合 → 删;Scenario 3(建群→发欢迎语链)是独有 recipe → 搬进 AI Usage Guidance 末尾「Create a group, then send a welcome message」保留。
|
||||
- **Common Errors(L144-158)** — 9 行压成 2 行。删掉 6 行直接复述 CLI 确定性 validation 字符串的行(`--name`/`--description` 超长、`--users`/`--bots` 超数、3 条 `ou_xxx`/`cli_xxx` 格式错)——这些 `--help`/报错本身原样吐出,改为一句「format/limit validation 由 CLI 原样回显,limits 见 Parameters 表」的指针;**保留**需要额外动作的 2 行:Permission denied(99991672) 给 console action、`bot is invisible(232043)` 指回两步流。
|
||||
|
||||
## 为什么这么改(机制)
|
||||
- **省 context 的因果**:chat-create.md 是 lazy reference,读到即整文件进窗口(080/014 reach=0.667)。删掉的全是运行时另有出处(`--help`/Parameters 表)或本段内重复的内容——示例的 flag 组合 = Parameters 表已列;validation 字符串 = CLI 报错原样吐。删后 Agent 仍能:经 SKILL.md 选对 `+chat-create`、经 Parameters 表/`--help` 补全 flag 用法、遇 232043 走两步流。即 optimization-playbook §13 核心判据「删掉后 Agent 是否仍能选对命令并补到用法」——成立。
|
||||
- **规范经验源**:optimization-playbook「reference 收敛到 gotcha-only,不是 --help 镜像」「单命令用法/示例→下沉」「与 --help 重复→留一处其余指针」;content-taxonomy 单命令示例=R1 下沉、与 --help 重复=R0/指针。annotation 三段独立标 R1,本改动落在 R1 重构范围内,未触 R3 段。
|
||||
|
||||
## 预期效果
|
||||
- 成功率(effect,硬门槛):**不退化**。所有 effect 红线逐条保留并已 grep 校验(见下「刻意没做什么」)。080 实际只用 `--name --format json` 最简建群链——本改动未碰该链路任何一环;014 卡在跨域 contact+授权(非本 reference)。
|
||||
- context(分两层):
|
||||
- **(1) 静态字数差**:bytes 7996→6450(-19.3%)、chars -19.5%、words -23.0%、tiktoken(cl100k 代理) 2125→1714(-19.3%)。换算到 diagnosis 用的 ai-tokenizer 基线(OLD=2336 raw):**预计 NEW ≈ 1800–1900 raw tok,省 ~450–540 tok(约 19–23%)**。
|
||||
- **(2) 运行时 context 方向**:对**读到 chat-create.md 的题(080,及理论上 014)下降** ~450–540 tok;对没读它的题(015)**无影响**(015 大头是单次 `Read` 22.5k 工具输出,与本 reference 无关)。本改动是纯删减、无新增前置/增读拉力,不会抬升运行时 token。
|
||||
- **与 direction 一致**(objective=降 token),无张力。
|
||||
- **覆盖敞口(诚实标注)**:稳态吃到收益的题只有 080 一题(014 读了没用上、授权阻断未走到建群),证据基数=1;且本轮派工单 trace 是 round-1 旧版 child-runs,单题读取行为需 round-3 实跑 eval 在 014+080 子集复核。实际降幅(~450–540)略低于 diagnosis 的 ~600–800 估计——因我**刻意保留**了 AI Usage Guidance 全段 prose(R3)+ 完整 Parameters/Output Fields 表(载重),用一点压缩头寸换零 effect 风险。
|
||||
|
||||
## 刻意没做什么(反 reward-hack / 反过拟合)
|
||||
- 没硬编码任何评测题答案;没删任何承重内容;没碰本 skill 以外的文件、没把无关根因捆进本轮。
|
||||
- **逐条保留的载重红线(已 grep 校验存在)**:
|
||||
- 232043 两步流全段(contact search → `--users 当前用户` 建群 → `chat.members create --as user` 加其他人 → 查 `invalid_id_list`);
|
||||
- `succeed_type=1` 语义解释;
|
||||
- `--chat-mode topic` vs 「普通群 + `group_message_type=thread`」的区分注解;
|
||||
- `--owner` 默认行为(bot 身份默认 bot / user 身份默认授权用户);
|
||||
- 全部 flag(含 `--set-bot-manager`、`--dry-run`、`--type public`、`--users/--bots` 上限与格式)、identity/scope 指引、互斥与护栏规则、Output Fields 全表。
|
||||
- 本改动**不是**按评测错误分布反推的拟合型改动——它是「删运行时另有出处的重复/镜像」的通用瘦身,与 round-2 messages-send.md 同型同纪律;非针对某几题的特判。
|
||||
- 未做 RC-3(SKILL.md 进一步压缩):剩余多为全域 identity/路由,删错有 effect 风险,超出本轮低风险抓手范围。未做 015 的前置补充:那是增内容、与降 token 反向(方向冲突,diagnosis 已记录)。
|
||||
|
||||
## 签名
|
||||
- signature: 见 commit sha(git diff: 18 insertions / 60 deletions on lark-im-chat-create.md) tier: T1
|
||||
20
harness-opt/rounds/round-003/trend.json
Normal file
20
harness-opt/rounds/round-003/trend.json
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"round": 1,
|
||||
"n": 3,
|
||||
"pass_n": 0,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"tool_calls": 26.333333333333332,
|
||||
"duration_ms": 50189.0,
|
||||
"token": 31997.0
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"n": 3,
|
||||
"pass_n": 3,
|
||||
"cmd_fail_rate": 0.3466666666666667,
|
||||
"tool_calls": 10.0,
|
||||
"duration_ms": 69666.66666666667,
|
||||
"token": 42377.333333333336
|
||||
}
|
||||
]
|
||||
44
harness-opt/rounds/round-003/workorder.md
Normal file
44
harness-opt/rounds/round-003/workorder.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Round 3 归因派工单(parent=557349b40feb359bb791749a37571d59edb7e72e;模块未定,由 candidate-writer 据诊断点名)
|
||||
|
||||
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer)+ 逐题结构化 `attribution.json`(给 dashboard)。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
|
||||
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
|
||||
|
||||
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
|
||||
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read,都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置),正该选来修——不是白烧;reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
|
||||
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
|
||||
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
|
||||
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
|
||||
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
|
||||
|
||||
## 逐轮诊断信号趋势(纯诊断,不进判决)
|
||||
|
||||
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
|
||||
| R2 | 3 | 3 | 0.35 | 10 | 69667 | 42377 |
|
||||
|
||||
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
|
||||
|
||||
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✗ 使用当前用户身份创建名为「IM合作群」的群聊
|
||||
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
|
||||
✗ 将傅一铭和傅二铭加入该群
|
||||
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id,无后续添加操作。
|
||||
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
|
||||
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
|
||||
|
||||
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 成功定位名为「fusanming_at_openclaw群」的群,并获取最近包含「飞豆」关键字的消息
|
||||
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
|
||||
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
|
||||
|
||||
### 3 [PASS] ctx=35478 (acc=221685) 46540ms tools=22
|
||||
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
|
||||
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
|
||||
✓ 将该卡片发送到新建群中,预期返回 message_id
|
||||
5918
harness-opt/skill-annotations.json
Normal file
5918
harness-opt/skill-annotations.json
Normal file
File diff suppressed because it is too large
Load Diff
20
harness-opt/trend.json
Normal file
20
harness-opt/trend.json
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"round": 1,
|
||||
"n": 3,
|
||||
"pass_n": 0,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"tool_calls": 26.333333333333332,
|
||||
"duration_ms": 50189.0,
|
||||
"token": 31997.0
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"n": 3,
|
||||
"pass_n": 3,
|
||||
"cmd_fail_rate": 0.3466666666666667,
|
||||
"tool_calls": 10.0,
|
||||
"duration_ms": 69666.66666666667,
|
||||
"token": 42377.333333333336
|
||||
}
|
||||
]
|
||||
152
harness-opt/verify_results/round-001-lark-im-SKILL.patch
Normal file
152
harness-opt/verify_results/round-001-lark-im-SKILL.patch
Normal file
@@ -0,0 +1,152 @@
|
||||
From 237a77feb341e15656386d6952a875dc459fec8c Mon Sep 17 00:00:00 2001
|
||||
From: "zhangheng.023" <zhangheng.023@bytedance.com>
|
||||
Date: Tue, 23 Jun 2026 18:27:25 +0800
|
||||
Subject: [PATCH] =?UTF-8?q?opt(round-001):=20SKILL.md=20=E2=80=94=20fold?=
|
||||
=?UTF-8?q?=20USAGE=20method-index=20+=20scope=20table=20into=20a=20schema?=
|
||||
=?UTF-8?q?=20pointer=20(-40%=20resident=20tokens)?=
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
---
|
||||
skills/lark-im/SKILL.md | 122 +++-------------------------------------
|
||||
1 file changed, 8 insertions(+), 114 deletions(-)
|
||||
|
||||
diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md
|
||||
index bc39aae1..ac1c6900 100644
|
||||
--- a/skills/lark-im/SKILL.md
|
||||
+++ b/skills/lark-im/SKILL.md
|
||||
@@ -110,122 +110,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
|
||||
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
|
||||
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
|
||||
+| `reactions.*` (add / delete / list / batch_query) | Add, remove, or read emoji reactions on a message; user/bot; caller must be in the conversation, and can only delete its own reactions. Read first: [`lark-im-reactions.md`](references/lark-im-reactions.md) |
|
||||
+| `feed.groups.*` (create / update / delete / batch_query / batch_add_item / batch_remove_item) | Manage feed groups (tags) and their member cards; user-only. Read first: [`lark-im-feed-groups.md`](references/lark-im-feed-groups.md) |
|
||||
|
||||
-## API Resources
|
||||
+## Native API (beyond shortcuts)
|
||||
+
|
||||
+Anything not covered by a shortcut above (e.g. `chats.*`, `chat.members.*`, `chat.managers.*`, `chat.moderation.*`, `chat.user_setting.*`, `messages.delete|forward|merge_forward|read_users|urgent_*`, `threads.forward`, `images.create`, `pins.*`) is callable as a raw API:
|
||||
|
||||
```bash
|
||||
-lark-cli schema im.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
-lark-cli im <resource> <method> [flags] # 调用 API
|
||||
+lark-cli schema im.<resource>.<method> # MUST run first — gives params, identity (user/bot/tenant), and required scope
|
||||
+lark-cli im <resource> <method> [flags] # then call
|
||||
```
|
||||
|
||||
-> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
-
|
||||
-### chats
|
||||
-
|
||||
- - `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
|
||||
- - `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
|
||||
- - `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
|
||||
- - `update` — 更新群信息。Identity: supports `user` and `bot`.
|
||||
-
|
||||
-### chat.members
|
||||
-
|
||||
- - `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- - `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
-
|
||||
-### chat.user_setting
|
||||
-
|
||||
- - `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
- - `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
-
|
||||
-### chat.managers
|
||||
-
|
||||
- - `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
|
||||
- - `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
|
||||
-
|
||||
-### chat.moderation
|
||||
-
|
||||
- - `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
|
||||
- - `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
|
||||
-
|
||||
-### messages
|
||||
-
|
||||
- - `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
|
||||
- - `forward` — 转发消息。Identity: supports `user` and `bot`.
|
||||
- - `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- - `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
|
||||
- - `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
- - `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
- - `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
-
|
||||
-### reactions
|
||||
-
|
||||
- - `batch_query` — 批量获取消息表情。Identity: supports `user` and `bot`.[Must-read](references/lark-im-reactions.md)
|
||||
- - `create` — 添加消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
- - `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
|
||||
- - `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
-
|
||||
-### threads
|
||||
-
|
||||
- - `forward` — 转发话题。Identity: supports `user` and `bot`.
|
||||
-
|
||||
-### images
|
||||
-
|
||||
- - `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
|
||||
-
|
||||
-### pins
|
||||
-
|
||||
- - `create` — Pin 消息。Identity: supports `user` and `bot`.
|
||||
- - `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`.
|
||||
- - `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`.
|
||||
-
|
||||
-### feed.groups
|
||||
-
|
||||
- - `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- - `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- - `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- - `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- - `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- - `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
-
|
||||
-## 权限表
|
||||
-
|
||||
-| 方法 | 所需 scope |
|
||||
-|------|-----------|
|
||||
-| `chats.create` | `im:chat:create` |
|
||||
-| `chats.get` | `im:chat:read` |
|
||||
-| `chats.link` | `im:chat:read` |
|
||||
-| `chats.update` | `im:chat:update` |
|
||||
-| `chat.members.bots` | `im:chat.members:read` |
|
||||
-| `chat.members.create` | `im:chat.members:write_only` |
|
||||
-| `chat.members.delete` | `im:chat.members:write_only` |
|
||||
-| `chat.members.get` | `im:chat.members:read` |
|
||||
-| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
|
||||
-| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
|
||||
-| `chat.managers.add_managers` | `im:chat.managers:write_only` |
|
||||
-| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
|
||||
-| `chat.moderation.get` | `im:chat.moderation:read` |
|
||||
-| `chat.moderation.update` | `im:chat:moderation:write_only` |
|
||||
-| `messages.delete` | `im:message:recall` |
|
||||
-| `messages.forward` | `im:message` |
|
||||
-| `messages.merge_forward` | `im:message` |
|
||||
-| `messages.read_users` | `im:message:readonly` |
|
||||
-| `messages.urgent_app` | `im:message.urgent` |
|
||||
-| `messages.urgent_phone` | `im:message.urgent:phone` |
|
||||
-| `messages.urgent_sms` | `im:message.urgent:sms` |
|
||||
-| `reactions.batch_query` | `im:message.reactions:read` |
|
||||
-| `reactions.create` | `im:message.reactions:write_only` |
|
||||
-| `reactions.delete` | `im:message.reactions:write_only` |
|
||||
-| `reactions.list` | `im:message.reactions:read` |
|
||||
-| `threads.forward` | `im:message` |
|
||||
-| `images.create` | `im:resource` |
|
||||
-| `pins.create` | `im:message.pins:write_only` |
|
||||
-| `pins.delete` | `im:message.pins:write_only` |
|
||||
-| `pins.list` | `im:message.pins:read` |
|
||||
-| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
|
||||
-| `feed.groups.batch_query` | `im:feed_group_v1:read` |
|
||||
-| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
|
||||
-| `feed.groups.create` | `im:feed_group_v1:write` |
|
||||
-| `feed.groups.delete` | `im:feed_group_v1:write` |
|
||||
-| `feed.groups.update` | `im:feed_group_v1:write` |
|
||||
+> **MUST** run `schema` before any native call: it is the live source for the `--data` / `--params` structure, the supported identity (`--as user` vs `--as bot`), owner/admin/tenant constraints, and the required `im:*` scope — do not guess. On a missing-scope error, lark-cli returns a `console_url`; follow the lark-shared permission-handling flow.
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
From 82a099feafb45d101116f10230ce7c2f92fbcfe5 Mon Sep 17 00:00:00 2001
|
||||
From: "zhangheng.023" <zhangheng.023@bytedance.com>
|
||||
Date: Tue, 23 Jun 2026 19:17:24 +0800
|
||||
Subject: [PATCH] =?UTF-8?q?opt(round-002):=20lark-im-messages-send.md=20?=
|
||||
=?UTF-8?q?=E2=80=94=20consolidate=204x=20repeated=20content-flag=20rule,?=
|
||||
=?UTF-8?q?=20compress=20media=20enumeration=20&=20--help-mirror=20section?=
|
||||
=?UTF-8?q?s?=
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
---
|
||||
.../references/lark-im-messages-send.md | 259 ++++--------------
|
||||
1 file changed, 51 insertions(+), 208 deletions(-)
|
||||
|
||||
diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md
|
||||
index 484c024f..32818909 100644
|
||||
--- a/skills/lark-im/references/lark-im-messages-send.md
|
||||
+++ b/skills/lark-im/references/lark-im-messages-send.md
|
||||
@@ -1,10 +1,8 @@
|
||||
# im +messages-send
|
||||
|
||||
-> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
+> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
|
||||
|
||||
-Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
|
||||
-
|
||||
-This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
|
||||
+Send a message to a group chat (`--chat-id oc_xxx`) or a direct message (`--user-id ou_xxx`). One step, supports `--as user` and `--as bot` (default `bot`). Maps to shortcut `lark-cli im +messages-send` (`POST /open-apis/im/v1/messages`).
|
||||
|
||||
## Safety Constraints
|
||||
|
||||
@@ -16,249 +14,94 @@ Messages sent by this tool are visible to other people. Before calling it, you *
|
||||
|
||||
**Do not** send messages without explicit user approval.
|
||||
|
||||
-When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
-
|
||||
-When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
|
||||
+- `--as bot` (TAT, scope `im:message:send_as_bot`): the message is sent in the app's name — the app must already be in the target chat or have a DM relationship with the target user.
|
||||
+- `--as user` (UAT, scopes `im:message.send_as_user` + `im:message`): the message is sent as the authorized end user.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
-### Default Selection Rule For Agents
|
||||
-
|
||||
-- Prefer `--markdown` for headings, lists, links, summaries, reports, or Markdown-looking content.
|
||||
-- Use `--text` for exact plain text: logs, code, indentation-sensitive text, or literal Markdown.
|
||||
-- Use `--content` for exact `post` JSON, titles, multiple locales, cards, or unsupported structures.
|
||||
-
|
||||
-| Need | Recommended flag | Why |
|
||||
-|------|------|------|
|
||||
-| Send headings, lists, links, summaries, or reports | `--markdown` | Best default for lightweight formatting; converted to Feishu `post` JSON |
|
||||
-| Send plain text exactly as written | `--text` | Preserves literal text; no Markdown conversion |
|
||||
-| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
|
||||
-| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
|
||||
-
|
||||
-### `--text` vs `--markdown`
|
||||
-
|
||||
-- Use `--markdown` for lightweight formatted messages.
|
||||
-- Use `--text` for exact plain text, especially logs, code, indentation, or Markdown characters that should **not** render.
|
||||
-- Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures.
|
||||
-
|
||||
-## What `--markdown` Really Does
|
||||
-
|
||||
-`--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the message API.
|
||||
-
|
||||
-The shortcut does all of the following before sending:
|
||||
-
|
||||
-1. Forces `msg_type=post`
|
||||
-2. Resolves remote Markdown images like `` by downloading and uploading them first
|
||||
-3. Normalizes the Markdown for Feishu post rendering
|
||||
-4. Wraps the result as:
|
||||
-
|
||||
-```json
|
||||
-{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
-```
|
||||
-
|
||||
-This makes `--markdown` the simplest path for lightweight formatted messages.
|
||||
-
|
||||
-### Markdown Boundaries
|
||||
-
|
||||
-- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
|
||||
-- It always becomes a `post` payload with a single `zh_cn` locale.
|
||||
-- It does **not** let you set a `post` title. If you need a title, use `--msg-type post --content ...`.
|
||||
-- Headings are rewritten:
|
||||
- - `# Title` becomes `#### Title`
|
||||
- - `##` to `######` are normalized to `#####` when the content contains H1-H3
|
||||
-- Consecutive headings are separated with blank lines after heading normalization.
|
||||
-- Block spacing and line breaks may be normalized during conversion.
|
||||
-- Code blocks are preserved as code blocks.
|
||||
-- Excess blank lines are compressed.
|
||||
-- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
|
||||
-- Local paths in Markdown image syntax like `` are **not** supported and will not be auto-uploaded.
|
||||
-- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
|
||||
-
|
||||
-If you need a title, multiple locales, cards, unsupported rich structures, or byte-for-byte post JSON control, use `--content` and provide the final JSON yourself.
|
||||
+| Content | Flag | Why |
|
||||
+|---|---|---|
|
||||
+| Headings, lists, links, summaries, reports (lightweight formatting) | `--markdown` | Best default; converted to Feishu `post` JSON |
|
||||
+| Exact plain text — logs, code, indentation, literal Markdown chars that must **not** render | `--text` | Preserves literal text; no conversion |
|
||||
+| Exact `post` JSON, a `post` title, multiple locales, cards (`interactive`), `share_*`, or unsupported structures | `--content` | You provide the final JSON; it must match the effective `--msg-type` |
|
||||
+| Image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Uploads URLs or cwd-relative local files automatically |
|
||||
|
||||
-### Image Constraint for `--markdown`
|
||||
+These content flags (and the media flags) are **mutually exclusive** — pass exactly one. Media flags are also mutually exclusive with each other.
|
||||
|
||||
-When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `` for predictable results. Remote URLs may work but are not guaranteed.
|
||||
+## `--markdown` Gotchas
|
||||
|
||||
-**Steps:**
|
||||
+`--markdown` always forces `msg_type=post` (single `zh_cn` locale) and normalizes input for Feishu post rendering. Key boundaries (not full CommonMark/GFM):
|
||||
|
||||
-```bash
|
||||
-# 1. Upload image to get image_key
|
||||
-lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
|
||||
-# Returns: {"image_key":"img_v3_xxxx"}
|
||||
-
|
||||
-# 2. Use image_key in --markdown
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n\n\nSee above for details.'
|
||||
-```
|
||||
+- **No `post` title** — if you need one, use `--content` with `post` JSON.
|
||||
+- **Headings rewritten**: `# Title` → `#### Title`; `##`–`######` normalized to `#####` when content has H1–H3. Code blocks preserved; excess blank lines compressed.
|
||||
+- **Images**: pre-upload via `im images create` and reference `` for reliable results. Remote `https://` URLs are auto-downloaded+uploaded at runtime (removed with a warning if that fails). Local paths in `` are **not** supported and will not auto-upload.
|
||||
|
||||
-## Preserving Formatting
|
||||
+## Preserving Exact Formatting
|
||||
|
||||
-If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'` for either `--markdown` or `--text`.
|
||||
-
|
||||
-This is especially useful in `zsh` / `bash` because it lets you write `\n` explicitly instead of relying on the shell to preserve literal newlines.
|
||||
-
|
||||
-### When formatting must be preserved
|
||||
-
|
||||
-Use `--text` plus `$'...'`:
|
||||
-
|
||||
-```bash
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/im-docs\nAction: please check logs'
|
||||
-```
|
||||
+For multi-line text, indentation, code blocks, tabs, or many backslashes/quotes, use shell ANSI-C quoting `$'...'` so `\n` is written explicitly. Use `--text` + `$'...'` when the receiver must see the text exactly as entered:
|
||||
|
||||
```bash
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --text $'```bash\nmake test\nmake lint\n```'
|
||||
+lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/x\nAction: check logs'
|
||||
```
|
||||
|
||||
-Use this path when you want the receiver to see the text exactly as entered, not a converted Markdown post.
|
||||
-
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
-# Send a formatted update
|
||||
+# Formatted update (Markdown → post)
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
|
||||
|
||||
-# Send a plain one-line message
|
||||
+# Plain one-line text
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text "Hello"
|
||||
|
||||
-# Equivalent manual JSON
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --content '{"text":"Hello"}'
|
||||
-
|
||||
-# Send to a direct message (pass open_id)
|
||||
+# Direct message (pass open_id)
|
||||
lark-cli im +messages-send --user-id ou_xxx --text "Hello"
|
||||
|
||||
-# Send multi-line text while preserving formatting
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line'
|
||||
-
|
||||
-# Send Markdown with an image (must pre-upload via images.create)
|
||||
-lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
|
||||
-# Use the returned image_key in the markdown content
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Status\n\n\n\nDone.'
|
||||
-
|
||||
-# If you need exact post structure, send JSON directly
|
||||
+# Exact post structure with a title
|
||||
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
|
||||
|
||||
-# Send a local image (uploaded automatically before sending)
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
|
||||
-
|
||||
-# Or send directly with an existing image_key
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --image img_xxx
|
||||
+# Markdown with an image (pre-upload first)
|
||||
+lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png # -> {"image_key":"img_v3_xxxx"}
|
||||
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n\n\nDone.'
|
||||
|
||||
-# Send a local file (uploaded automatically before sending)
|
||||
+# Media (local files uploaded automatically; --video requires --video-cover)
|
||||
+lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
|
||||
lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
|
||||
-
|
||||
-# Send a video (--video-cover is required as the cover)
|
||||
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
|
||||
-
|
||||
-# Send audio
|
||||
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
|
||||
|
||||
-# Use an idempotency key (same key sends only once within 1 hour)
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-unique-id
|
||||
-
|
||||
-# Preview the request without executing it
|
||||
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
|
||||
+# Idempotency (same key sends only once within 1 hour) / preview without sending
|
||||
+lark-cli im +messages-send --chat-id oc_xxx --text "Hi" --idempotency-key my-id
|
||||
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhi' --dry-run
|
||||
```
|
||||
|
||||
-## Media Input Rules
|
||||
-
|
||||
-- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
|
||||
-- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
|
||||
-- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
|
||||
-
|
||||
-## Parameters
|
||||
-
|
||||
-| Parameter | Required | Description |
|
||||
-|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
-| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
|
||||
-| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
|
||||
-| `--text <string>` | One content option | Plain text message. Use when exact text and formatting preservation matter. Automatically wrapped as `{"text":"..."}` |
|
||||
-| `--markdown <string>` | One content option | Best default for lightweight formatted messages such as headings, lists, links, summaries, and reports. Internally converted to `post` JSON with Feishu-specific normalization |
|
||||
-| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
|
||||
-| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
|
||||
-| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
|
||||
-| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
|
||||
-| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
|
||||
-| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
|
||||
-| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
-| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
|
||||
-| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
-| `--dry-run` | No | Print the request only, do not execute it |
|
||||
-
|
||||
-> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
|
||||
->
|
||||
-> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
|
||||
-
|
||||
-## Common Mistakes
|
||||
-
|
||||
-- Choosing `--text` for headings, lists, links, summaries, or reports. Use `--markdown`.
|
||||
-- Choosing `--markdown` when you actually need exact plain text. If exact line breaks, spacing, logs, code, or literal Markdown characters matter, use `--text`, usually with `$'...'`.
|
||||
-- Assuming `--markdown` supports every Markdown feature. It is converted into a Feishu `post` payload and normalized first.
|
||||
-- Putting local image paths inside Markdown like ``. `--markdown` does not auto-upload those paths.
|
||||
-- **Using local file paths inside Markdown image syntax** (e.g. ``) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
|
||||
-- Using `--content` without making the JSON match the effective `--msg-type`.
|
||||
-- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
|
||||
-- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
|
||||
-
|
||||
-## `content` Format Reference
|
||||
+Run `lark-cli im +messages-send --help` for the full flag list and types. Load-bearing rules that `--help` may not make obvious:
|
||||
+
|
||||
+- **Media paths** accept an existing key (`img_xxx`/`file_xxx`), an `http(s)://` URL, or a **cwd-relative** local path. Absolute paths (e.g. `/tmp/x.png`) are rejected — run from the file's directory and pass `./x.png`. Upload and send use the same identity.
|
||||
+- **`--video` must be paired with `--video-cover`** (image key/URL/local path); `--video-cover` cannot be used alone.
|
||||
+- **`--msg-type`** is inferred from `--text`/`--markdown`/media flags; explicitly setting a conflicting type fails validation.
|
||||
+
|
||||
+## `content` Format Reference (for `--content`)
|
||||
|
||||
| `msg_type` | Example `content` |
|
||||
-|----------|-------------|
|
||||
+|---|---|
|
||||
| `text` | `{"text":"Hello <at user_id=\"ou_xxx\">name</at>"}` |
|
||||
| `post` | `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}` |
|
||||
-| `image` | `{"image_key":"img_xxx"}` |
|
||||
-| `file` | `{"file_key":"file_xxx"}` |
|
||||
-| `audio` | `{"file_key":"file_xxx"}` |
|
||||
-| `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover` — **required**) |
|
||||
-| `share_chat` | `{"chat_id":"oc_xxx"}` |
|
||||
-| `share_user` | `{"user_id":"ou_xxx"}` |
|
||||
-| `interactive` | Card JSON (see Feishu interactive card documentation) |
|
||||
+| `image` / `file` / `audio` | `{"image_key":"img_xxx"}` / `{"file_key":"file_xxx"}` / `{"file_key":"file_xxx"}` |
|
||||
+| `media` (video) | `{"file_key":"file_xxx","image_key":"img_xxx"}` (`image_key` is the **required** cover) |
|
||||
+| `share_chat` / `share_user` | `{"chat_id":"oc_xxx"}` / `{"user_id":"ou_xxx"}` |
|
||||
+| `interactive` (card) | Card JSON (see Feishu interactive card docs) |
|
||||
|
||||
-## Return Value
|
||||
-
|
||||
-```json
|
||||
-{
|
||||
- "message_id": "om_xxx",
|
||||
- "chat_id": "oc_xxx",
|
||||
- "create_time": "1234567890"
|
||||
-}
|
||||
-```
|
||||
+When using `--content`, you are responsible for making the JSON match the effective `msg_type`.
|
||||
|
||||
## @Mention Format
|
||||
|
||||
-The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
|
||||
-
|
||||
-### `text`
|
||||
-
|
||||
-- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
|
||||
-- @all: `<at user_id="all"></at>`
|
||||
-
|
||||
-### `post`
|
||||
+The `<at>` syntax differs by message type; the shortcut normalizes mentions for `text` and `post` only — `interactive` cards are passed through verbatim.
|
||||
|
||||
-- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
|
||||
-- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
|
||||
+- **`text`** / inside a `post` `text`/`md` element: `<at user_id="ou_xxx">name</at>` (inner name optional); @all: `<at user_id="all"></at>`. In `post` you may also use a node: `{"tag":"at","user_id":"ou_xxx"}` (`"all"` for everyone).
|
||||
+- **`interactive` (card)** — card-native syntax inside a `lark_md`/`markdown` element: `<at id=ou_xxx></at>`, multiple `<at ids=ou_1,ou_2></at>`, by email `<at email=user@example.com></at>`.
|
||||
|
||||
-### `interactive` (card)
|
||||
-
|
||||
-Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
|
||||
-
|
||||
-- single user by open_id: `<at id=ou_xxx></at>`
|
||||
-- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
|
||||
-- by email: `<at email=user@example.com></at>`
|
||||
-
|
||||
-## Notes
|
||||
+## Return Value
|
||||
|
||||
-- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
|
||||
-- `--content` must be valid JSON
|
||||
-- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
-- `--image`/`--file`/`--video`/`--audio` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
|
||||
-- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
-- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
|
||||
-- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
-- When using `--video`, `--video-cover` is required as the video cover
|
||||
-- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
-- Failures return an error code and message
|
||||
-- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
|
||||
-- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
-- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
-- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
|
||||
+```json
|
||||
+{"message_id": "om_xxx", "chat_id": "oc_xxx", "create_time": "1234567890"}
|
||||
+```
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
From cbd6e56ac07285fd973c53ff7382da0112b6cf5d Mon Sep 17 00:00:00 2001
|
||||
From: "zhangheng.023" <zhangheng.023@bytedance.com>
|
||||
Date: Tue, 23 Jun 2026 19:51:49 +0800
|
||||
Subject: [PATCH] =?UTF-8?q?opt(round-003):=20references/lark-im-chat-creat?=
|
||||
=?UTF-8?q?e.md=20=E2=80=94=20dedup=20Commands/Scenarios=20overlap=20+=20c?=
|
||||
=?UTF-8?q?ompress=20--help-mirroring=20Common=20Errors=20into=20pointers,?=
|
||||
=?UTF-8?q?=20keep=20232043=20two-step=20flow=20&=20all=20guardrails?=
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
---
|
||||
.../lark-im/references/lark-im-chat-create.md | 78 +++++--------------
|
||||
1 file changed, 18 insertions(+), 60 deletions(-)
|
||||
|
||||
diff --git a/skills/lark-im/references/lark-im-chat-create.md b/skills/lark-im/references/lark-im-chat-create.md
|
||||
index 76716f76..7d65e5d3 100644
|
||||
--- a/skills/lark-im/references/lark-im-chat-create.md
|
||||
+++ b/skills/lark-im/references/lark-im-chat-create.md
|
||||
@@ -12,43 +12,24 @@ This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `P
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
-# Create a private group (default)
|
||||
+# Private group (default)
|
||||
lark-cli im +chat-create --name "My Group"
|
||||
|
||||
-# Create a public group (name is required and must be at least 2 characters)
|
||||
+# Public group (--name required, min 2 chars)
|
||||
lark-cli im +chat-create --name "Public Group" --type public
|
||||
|
||||
-# Create a topic chat
|
||||
+# Topic chat (a 话题群; see note under Parameters)
|
||||
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
|
||||
|
||||
-# Specify the group owner
|
||||
-lark-cli im +chat-create --name "My Group" --owner ou_xxx
|
||||
+# Invite members and set owner (users: up to 50 ou_xxx; bots: up to 5 cli_xxx)
|
||||
+lark-cli im +chat-create --name "My Group" --owner ou_xxx --users "ou_aaa,ou_bbb" --bots "cli_aaa"
|
||||
|
||||
-# Invite user members (comma-separated open_ids, up to 50)
|
||||
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb"
|
||||
-
|
||||
-# Invite bot members (comma-separated app IDs, up to 5)
|
||||
-lark-cli im +chat-create --name "My Group" --bots "cli_aaa,cli_bbb"
|
||||
-
|
||||
-# Invite both users and bots
|
||||
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --bots "cli_aaa"
|
||||
-
|
||||
-# Make the creating bot a group manager (bot identity only)
|
||||
-lark-cli im +chat-create --name "My Group" --set-bot-manager --as bot
|
||||
-
|
||||
-# JSON output
|
||||
-lark-cli im +chat-create --name "My Group" --format json
|
||||
-
|
||||
-# Create a group with bot identity
|
||||
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot
|
||||
-
|
||||
-# Create a group with user identity
|
||||
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb" --as user
|
||||
-
|
||||
-# Preview the request without creating anything
|
||||
-lark-cli im +chat-create --name "My Group" --dry-run
|
||||
+# Bot identity, making the creating bot a manager
|
||||
+lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot --set-bot-manager
|
||||
```
|
||||
|
||||
+Run `lark-cli im +chat-create --help` for the full flag list, limits, and types. Single-flag variations (`--as user`, `--description`, `--format json`, `--dry-run` preview, etc.) follow the Parameters table below — `--dry-run` previews the request without creating anything.
|
||||
+
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Limits | Description |
|
||||
@@ -106,6 +87,13 @@ lark-cli im +chat-create --name "<group name>" --users "ou_aaa,ou_bbb" --as user
|
||||
|
||||
The authorized user is automatically the group creator and member.
|
||||
|
||||
+### Create a group, then send a welcome message
|
||||
+
|
||||
+```bash
|
||||
+CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
|
||||
+lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
|
||||
+```
|
||||
+
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
@@ -117,43 +105,13 @@ The authorized user is automatically the group creator and member.
|
||||
| `external` | Whether the group is external |
|
||||
| `share_link` | Group share link (omitted if retrieval fails) |
|
||||
|
||||
-## Usage Scenarios
|
||||
-
|
||||
-### Scenario 1: Create a group and specify the owner
|
||||
-
|
||||
-```bash
|
||||
-lark-cli im +chat-create --name "Project Discussion Group" --owner ou_xxx
|
||||
-```
|
||||
-
|
||||
-### Scenario 2: Create a group and invite users and a bot
|
||||
-
|
||||
-```bash
|
||||
-lark-cli im +chat-create --name "Project Discussion Group" \
|
||||
- --owner ou_xxx \
|
||||
- --users "ou_aaa,ou_bbb" \
|
||||
- --bots "cli_aaa"
|
||||
-```
|
||||
-
|
||||
-### Scenario 3: Create a group and send a welcome message
|
||||
-
|
||||
-```bash
|
||||
-CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
|
||||
-lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
|
||||
-```
|
||||
-
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
+Format/limit validation (`--name`/`--description`/`--users`/`--bots`/`--owner` length, count, and `ou_xxx`/`cli_xxx` format) is enforced by the CLI and reported verbatim with the fix — see the Parameters table for limits. The two errors needing extra action:
|
||||
+
|
||||
| Symptom | Root Cause | Solution |
|
||||
|---------|---------|---------|
|
||||
| Permission denied (99991672) | The app does not have `im:chat:create` (bot) or `im:chat:create_by_user` (user) permission enabled | Enable the required permission for the app in the Open Platform console |
|
||||
-| `--name is required for public groups and must be at least 2 characters` | A public group was created without a name or with a name shorter than 2 characters | Provide a name with at least 2 characters |
|
||||
-| `--name exceeds the maximum of 60 characters` | The group name is too long | Shorten the name to 60 characters or fewer |
|
||||
-| `--description exceeds the maximum of 100 characters` | The group description is too long | Shorten the description to 100 characters or fewer |
|
||||
-| `--users exceeds the maximum of 50` | Too many user members were provided | Split the operation into batches and add more members later |
|
||||
-| `--bots exceeds the maximum of 5` | Too many bot members were provided | Invite at most 5 bots at once |
|
||||
-| `invalid user id: expected open_id (ou_xxx)` | Invalid user ID format | Use the `ou_xxx` format for users |
|
||||
-| `invalid bot id: expected app ID (cli_xxx)` | Invalid bot ID format | Use the `cli_xxx` format for bots |
|
||||
-| `invalid --owner: expected open_id (ou_xxx)` | Invalid owner ID format | Use the `ou_xxx` format for the owner |
|
||||
| `bot is invisible to user` (232043) | The bot and target users are mutually invisible | Follow the two-step flow in AI Usage Guidance above — do not pass other users in `--users` during creation |
|
||||
|
||||
## References
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
@@ -131,3 +131,31 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,31 +29,3 @@ func checkOwnerUID(path, label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,22 +5,7 @@
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.56",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -179,10 +179,7 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
@@ -201,9 +198,8 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -214,8 +210,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -85,9 +84,6 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -144,22 +140,18 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -176,30 +168,6 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -222,9 +190,6 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,82 +503,3 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,7 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -206,61 +199,6 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -440,11 +378,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,40 +394,6 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1502,125 +1468,6 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
|
||||
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepRecordList = "use +record-list to list records in the resolved table"
|
||||
titleResolveQueryMaxLen = 30
|
||||
)
|
||||
|
||||
var BaseURLResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+url-resolve",
|
||||
Description: "Resolve a Base-related URL into Base coordinates",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readURLResolveInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
|
||||
default:
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseURLResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTitleResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+title-resolve",
|
||||
Description: "Resolve a Base title or keyword through Drive search",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:docs:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readTitleResolveQuery(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/search/v2/doc_wiki/search").
|
||||
Body(buildTitleResolveSearchBody(query))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseTitleResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
|
||||
return "", resolveValidationError(
|
||||
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
|
||||
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
}
|
||||
return pickedValue, nil
|
||||
}
|
||||
|
||||
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
out, err := resolveWikiBaseURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
out, err := resolveRecordShareURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "form_share_url":
|
||||
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
|
||||
return nil
|
||||
case "view_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "dashboard_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "workspace_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "add_record_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
default:
|
||||
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolveURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func classifyBaseURL(u *url.URL) string {
|
||||
path := normalizeResolvePath(u.Path)
|
||||
switch {
|
||||
case pathSegmentExists(path, "/base/workspace/"):
|
||||
return "workspace_url"
|
||||
case pathSegmentExists(path, "/base/add/"):
|
||||
return "add_record_url"
|
||||
case pathSegmentExists(path, "/base/"):
|
||||
return "base_url"
|
||||
case pathSegmentExists(path, "/wiki/"):
|
||||
return "wiki_url"
|
||||
case pathSegmentExists(path, "/record/"):
|
||||
return "record_share_url"
|
||||
case pathSegmentExists(path, "/share/base/form/"):
|
||||
return "form_share_url"
|
||||
case pathSegmentExists(path, "/share/base/view/"):
|
||||
return "view_share_url"
|
||||
case pathSegmentExists(path, "/share/base/dashboard/"):
|
||||
return "dashboard_share_url"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
|
||||
if objType != "bitable" {
|
||||
return nil, resolveValidationError(
|
||||
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
|
||||
"Use the corresponding skill for that resource, or provide a Base URL.",
|
||||
)
|
||||
}
|
||||
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
|
||||
if baseToken == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"input_type": "wiki_url",
|
||||
"resource_type": "bitable",
|
||||
"wiki_node_token": token,
|
||||
"base_token": baseToken,
|
||||
"title": common.GetString(node, "title"),
|
||||
"hint": resolveHint("", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
shareToken := firstPathSegmentAfter(u.Path, "/record/")
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"input_type": "record_share_url",
|
||||
"resource_type": "bitable",
|
||||
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
|
||||
"base_token": common.GetString(data, "base_token"),
|
||||
"table_id": common.GetString(data, "table_id"),
|
||||
"record_id": common.GetString(data, "record_id"),
|
||||
}
|
||||
enrichRecordShareResolveHint(runtime, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveFormShareURL(u *url.URL) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"input_type": "form_share_url",
|
||||
"resource_type": "bitable_form",
|
||||
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return resolveValidationError(
|
||||
"No Base matched this title or keyword.",
|
||||
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
case 1:
|
||||
out := map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"title": candidates[0]["title"],
|
||||
"base_token": candidates[0]["base_token"],
|
||||
"url": candidates[0]["url"],
|
||||
"owner_name": candidates[0]["owner_name"],
|
||||
"update_time": candidates[0]["update_time"],
|
||||
"hint": resolveHint("", nil),
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"candidates": candidates,
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": baseTitleResolveHint,
|
||||
},
|
||||
}, nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
return
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
|
||||
hint := map[string]interface{}{}
|
||||
if baseToken != "" && tableID != "" && recordID != "" {
|
||||
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
|
||||
hint["record_data"] = formatResolvedRecordData(record)
|
||||
}
|
||||
}
|
||||
if baseToken != "" && tableID != "" {
|
||||
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
|
||||
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
|
||||
}
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, hint)
|
||||
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{"record_id_list": []string{recordID}}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
|
||||
return handleBaseAPIResult(result, err, "batch get records")
|
||||
}
|
||||
|
||||
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
|
||||
fieldIDs := common.GetSlice(record, "field_id_list")
|
||||
fieldNames := common.GetSlice(record, "fields")
|
||||
rows := common.GetSlice(record, "data")
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if len(rows) > 0 {
|
||||
if values, ok := rows[0].([]interface{}); ok {
|
||||
for i, value := range values {
|
||||
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
|
||||
if index < len(fieldIDs) {
|
||||
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
|
||||
return fieldID
|
||||
}
|
||||
}
|
||||
if index < len(fieldNames) {
|
||||
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("field_%d", index+1)
|
||||
}
|
||||
|
||||
func recordShareNextStep(baseToken, tableID, recordID string) string {
|
||||
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
|
||||
hint := map[string]interface{}{}
|
||||
for key, value := range extra {
|
||||
hint[key] = value
|
||||
}
|
||||
if strings.TrimSpace(tableID) != "" {
|
||||
hint["next_step"] = nextStepRecordList
|
||||
} else {
|
||||
hint["next_step"] = nextStepBaseBlockList
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
func buildTitleResolveSearchBody(query string) map[string]interface{} {
|
||||
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
|
||||
return map[string]interface{}{
|
||||
"query": query,
|
||||
"page_size": 5,
|
||||
"doc_filter": filter,
|
||||
"wiki_filter": filter,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
|
||||
candidates := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
meta, _ := row["result_meta"].(map[string]interface{})
|
||||
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(meta, "token"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(common.GetString(row, "title"))
|
||||
}
|
||||
candidates = append(candidates, map[string]interface{}{
|
||||
"title": title,
|
||||
"base_token": token,
|
||||
"url": common.GetString(meta, "url"),
|
||||
"owner_name": common.GetString(meta, "owner_name"),
|
||||
"update_time": common.GetString(meta, "update_time_iso"),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
|
||||
|
||||
func stripSearchHighlight(s string) string {
|
||||
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func resolveValidationError(message, hint string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func normalizeResolvePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathSegmentExists(path, prefix string) bool {
|
||||
return firstPathSegmentAfter(path, prefix) != ""
|
||||
}
|
||||
|
||||
func firstPathSegmentAfter(path, prefix string) string {
|
||||
path = normalizeResolvePath(path)
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func valueOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unknown resource type"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base only", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepBaseBlockList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not Base") {
|
||||
t.Fatalf("err=%v, want non-Base validation error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
recordData, _ := hint["record_data"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enrichment failure still returns meta", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["record_data"]; ok {
|
||||
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_share_token": shareToken,
|
||||
"base_token": baseToken,
|
||||
"table_id": tableID,
|
||||
"record_id": recordID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveFormShareURL(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantText string
|
||||
wantHint string
|
||||
}{
|
||||
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
|
||||
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
|
||||
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
|
||||
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
|
||||
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
|
||||
{"not url", "bas123", "only accepts full URLs", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", tc.rawURL, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("err=%v, want typed error with hint", err)
|
||||
}
|
||||
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
|
||||
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "original /base/{base_token}") {
|
||||
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
shortcut string
|
||||
definition common.Shortcut
|
||||
primaryFlag string
|
||||
primaryDesc string
|
||||
aliasFlags []string
|
||||
}{
|
||||
{
|
||||
shortcut: "+url-resolve",
|
||||
definition: BaseURLResolve,
|
||||
primaryFlag: "url",
|
||||
primaryDesc: "Base/Wiki/record-share URL to resolve",
|
||||
aliasFlags: []string{"query"},
|
||||
},
|
||||
{
|
||||
shortcut: "+title-resolve",
|
||||
definition: BaseTitleResolve,
|
||||
primaryFlag: "title",
|
||||
primaryDesc: "Base title keyword",
|
||||
aliasFlags: []string{"query", "url"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tc.definition.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
primary := cmd.Flags().Lookup(tc.primaryFlag)
|
||||
primaryUsage := ""
|
||||
if primary != nil {
|
||||
primaryUsage = primary.Usage
|
||||
}
|
||||
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
|
||||
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTitleResolve(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Sales <h>Pipeline</h>",
|
||||
"result_meta": map[string]interface{}{
|
||||
"doc_types": "BITABLE",
|
||||
"token": "bas123",
|
||||
"url": "https://example.larkoffice.com/base/bas123",
|
||||
"owner_name": "Alice",
|
||||
"update_time_iso": "2026-06-09T10:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple results and filter non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Doc hit",
|
||||
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>One</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>Two</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--url", "Base", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates=%#v, want 2", data["candidates"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub(nil))
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "missing", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
|
||||
t.Fatalf("err=%v, want no result validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query too long", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
|
||||
t.Fatalf("err=%v, want query length validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"res_units": items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"total": 2,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
|
||||
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{recordID},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_status"},
|
||||
"fields": []interface{}{"Name", "Status"},
|
||||
"data": []interface{}{[]interface{}{"Alice", "Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
|
||||
@@ -8,8 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseURLResolve,
|
||||
BaseTitleResolve,
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
|
||||
@@ -199,7 +199,16 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ func TestNormalizeMCPToolResult(t *testing.T) {
|
||||
|
||||
got, err := normalizeMCPToolResult(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,7 +49,6 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -1030,6 +1029,7 @@ func stripUTF8BOM(s string) string {
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
@@ -1049,14 +1049,11 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
// A process has a single stdin, so we reject a second Input flag
|
||||
// trying to use `-` after the first one has already consumed it.
|
||||
if rctx.stdinConsumed {
|
||||
if stdinUsed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
@@ -1169,13 +1166,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
hints = append(hints, "@file")
|
||||
}
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
// "- reads stdin" intentionally avoids implying each flag has
|
||||
// its own stdin: a process has a single stdin, so at most one
|
||||
// flag per call may use "-" (the rest must use @file). The old
|
||||
// per-flag "- for stdin" wording led AI agents to write
|
||||
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
||||
// the first and `--a` reads the wrong payload.
|
||||
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
||||
hints = append(hints, "- for stdin")
|
||||
}
|
||||
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
@@ -40,7 +39,6 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
@@ -198,7 +197,6 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
@@ -220,7 +218,6 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
@@ -241,7 +238,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
@@ -282,7 +279,6 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
@@ -295,7 +291,6 @@ func TestBotInfo_NilFunc(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support stdin") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support file input") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "cannot read file") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,14 +216,9 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
// The hint must steer an AI agent to the fix (@file for the extra flags),
|
||||
// since `--a - <x --b - <y` is the exact misuse this guards against.
|
||||
if !strings.Contains(vErr.Hint, "@file") {
|
||||
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,9 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
@@ -206,7 +208,9 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
requireValidation(t, err, "invalid jq expression")
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
|
||||
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-download",
|
||||
Command: "resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-update",
|
||||
Command: "resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-delete",
|
||||
Command: "resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"+resource-download",
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"+resource-download",
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"+resource-delete",
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"+resource-delete",
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type imMarkdownContext struct {
|
||||
baseURL string
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
|
||||
|
||||
type imMarkdownTagHandler struct {
|
||||
closeRE *regexp.Regexp
|
||||
handle imMarkdownHandleFunc
|
||||
}
|
||||
|
||||
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
|
||||
imMarkdownHandlers[tag] = imMarkdownTagHandler{
|
||||
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
|
||||
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
|
||||
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
|
||||
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
|
||||
)
|
||||
|
||||
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
|
||||
|
||||
func init() {
|
||||
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
|
||||
for level := 1; level <= 9; level++ {
|
||||
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
|
||||
}
|
||||
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
|
||||
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
|
||||
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
|
||||
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
|
||||
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
|
||||
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
|
||||
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
|
||||
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
|
||||
registerIMMarkdownHandler("table", handleIMMarkdownTable)
|
||||
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
|
||||
registerIMMarkdownHandler("code", handleIMMarkdownCode)
|
||||
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
|
||||
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
|
||||
registerIMMarkdownHandler("img", handleIMMarkdownImage)
|
||||
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("source", handleIMMarkdownSource)
|
||||
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
|
||||
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
|
||||
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
|
||||
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
|
||||
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
|
||||
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
|
||||
}
|
||||
|
||||
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
|
||||
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
|
||||
}
|
||||
|
||||
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
|
||||
}
|
||||
|
||||
func newIMMarkdownContext(docInput string) imMarkdownContext {
|
||||
base := "https://larkoffice.com"
|
||||
raw := strings.TrimSpace(docInput)
|
||||
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
|
||||
base = extracted
|
||||
}
|
||||
return imMarkdownContext{baseURL: base}
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
|
||||
c.blockquoteDepth++
|
||||
return c
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) inBlockquote() bool {
|
||||
return c.blockquoteDepth > 0
|
||||
}
|
||||
|
||||
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
|
||||
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
|
||||
// intentionally strips host information, so it cannot serve this formatting path.
|
||||
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := strings.Trim(raw[:idx], "/")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
|
||||
return "https://" + u.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset := 0; offset < len(content); {
|
||||
// Scan only to the next XML-like opening tag. Plain Markdown text between
|
||||
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
|
||||
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
|
||||
handler, ok := imMarkdownHandlers[tag]
|
||||
if !ok {
|
||||
// Unknown tags are left intact. im-markdown only downgrades tags with
|
||||
// explicit handlers so future server output does not get guessed at.
|
||||
out.WriteString(content[offset:openEnd])
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(content[offset:start])
|
||||
opening := content[start:openEnd]
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
out.WriteString(handler.handle(opening, "", attrs, imCtx))
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the handler's precompiled close regexp to find the matching end tag.
|
||||
// Depth tracking keeps nested same-name containers paired correctly.
|
||||
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
|
||||
if !found {
|
||||
// Malformed or truncated fragments are preserved as-is from the opening
|
||||
// tag onward; do not drop content when the XML-ish structure is incomplete.
|
||||
out.WriteString(content[start:])
|
||||
break
|
||||
}
|
||||
segment := content[start:closeEnd]
|
||||
inner := content[openEnd:closeStart]
|
||||
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
|
||||
offset = closeEnd
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseIMMarkdownAttrs(opening string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
|
||||
value := match[2]
|
||||
if value == "" {
|
||||
value = match[3]
|
||||
}
|
||||
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSelfClosingIMMarkdownTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
|
||||
}
|
||||
|
||||
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "# " + text
|
||||
}
|
||||
|
||||
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
|
||||
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
markdownLevel := level
|
||||
if markdownLevel > 6 {
|
||||
markdownLevel = 6
|
||||
}
|
||||
return strings.Repeat("#", markdownLevel) + " " + text
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if imCtx.inBlockquote() {
|
||||
return body + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, false, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, true, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
prefix := "-"
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
emoji := strings.TrimSpace(attrs["emoji"])
|
||||
if emoji != "" {
|
||||
if body == "" {
|
||||
body = emoji
|
||||
} else {
|
||||
body = emoji + " " + body
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "---\n---"
|
||||
}
|
||||
return fmt.Sprintf("---\n%s\n---", body)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
lines[i] = ">"
|
||||
continue
|
||||
}
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
lang := strings.TrimSpace(attrs["lang"])
|
||||
code := strings.TrimSpace(inner)
|
||||
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
|
||||
code = match[1]
|
||||
}
|
||||
return imMarkdownFencedCode(html.UnescapeString(code), lang)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(markdownPlainText(inner))
|
||||
}
|
||||
|
||||
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
expr := strings.TrimSpace(markdownPlainText(inner))
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
|
||||
}
|
||||
|
||||
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return "---"
|
||||
}
|
||||
|
||||
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
|
||||
return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
|
||||
}
|
||||
|
||||
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
name := strings.TrimSpace(attrs["name"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return imMarkdownInlineCode(name)
|
||||
}
|
||||
|
||||
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
for _, attrName := range attrNames {
|
||||
if strings.TrimSpace(attrs[attrName]) != "" {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
token := strings.TrimSpace(attrs["token"])
|
||||
if token == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
label := "sheet"
|
||||
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
|
||||
label = "sheet " + sheetID
|
||||
}
|
||||
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
|
||||
if href == "" {
|
||||
return name
|
||||
}
|
||||
return markdownLink(name, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "**" + body + "**"
|
||||
}
|
||||
|
||||
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "*" + body + "*"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "~~" + body + "~~"
|
||||
}
|
||||
|
||||
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
|
||||
if href == "" {
|
||||
return text
|
||||
}
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
|
||||
case "user":
|
||||
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
|
||||
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
|
||||
if userID == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
|
||||
case "doc":
|
||||
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(title, href)
|
||||
}
|
||||
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
|
||||
if docID == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
|
||||
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
|
||||
case "citation":
|
||||
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
|
||||
}
|
||||
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
|
||||
default:
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
// Rows and cells are matched with tag-depth tracking instead of non-greedy
|
||||
// regex captures. A table nested inside a cell can contain its own </tr> and
|
||||
// </td>; treating those as the outer row/cell boundary corrupts the table.
|
||||
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
|
||||
if len(rowBodies) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rowBodies))
|
||||
for _, rowBody := range rowBodies {
|
||||
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
|
||||
if len(cellBodies) == 0 {
|
||||
continue
|
||||
}
|
||||
row := make([]string, 0, len(cellBodies))
|
||||
for _, cellBody := range cellBodies {
|
||||
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
|
||||
separator := make([]string, cols)
|
||||
for i := range separator {
|
||||
separator[i] = "-"
|
||||
}
|
||||
writeIMMarkdownTableRow(&out, separator)
|
||||
for _, row := range rows[1:] {
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// extractIMMarkdownElementBodies returns the inner content of each top-level
|
||||
// element matched by tagRE. tagRE must expose the optional closing slash as its
|
||||
// first capture group, matching the row/cell regexes above.
|
||||
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
|
||||
var bodies []string
|
||||
for offset := 0; offset < len(content); {
|
||||
loc := tagRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := content[openStart:openEnd]
|
||||
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
bodies = append(bodies, "")
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
bodies = append(bodies, content[openEnd:closeStart])
|
||||
offset = closeEnd
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeIMMarkdownTableCell(cell string) string {
|
||||
const brPlaceholder = "\x00BR\x00"
|
||||
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
|
||||
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
|
||||
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
|
||||
if name == "at" {
|
||||
return tag
|
||||
}
|
||||
return ""
|
||||
})
|
||||
cell = html.UnescapeString(cell)
|
||||
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
|
||||
cell = strings.ReplaceAll(cell, " \n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "\n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "|", `\|`)
|
||||
lines := strings.Fields(cell)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
|
||||
out.WriteString("| ")
|
||||
out.WriteString(strings.Join(row, " | "))
|
||||
out.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func padIMMarkdownTableRow(row []string, cols int) []string {
|
||||
if len(row) >= cols {
|
||||
return row
|
||||
}
|
||||
padded := make([]string, cols)
|
||||
copy(padded, row)
|
||||
return padded
|
||||
}
|
||||
|
||||
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset, index := 0, 1; offset < len(inner); {
|
||||
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := inner[openStart:openEnd]
|
||||
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
|
||||
if body != "" {
|
||||
prefix := "-"
|
||||
if ordered {
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%d.", index)
|
||||
}
|
||||
index++
|
||||
}
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(" ")
|
||||
out.WriteString(indentIMMarkdownListContinuation(body))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
offset = closeEnd
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func indentIMMarkdownListContinuation(body string) string {
|
||||
return strings.ReplaceAll(body, "\n", "\n ")
|
||||
}
|
||||
|
||||
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
|
||||
match := imMarkdownLinkRE.FindStringSubmatch(inner)
|
||||
if match == nil {
|
||||
return "", "", false
|
||||
}
|
||||
href := match[1]
|
||||
if href == "" {
|
||||
href = match[2]
|
||||
}
|
||||
text := strings.TrimSpace(markdownPlainText(match[3]))
|
||||
if text == "" {
|
||||
text = href
|
||||
}
|
||||
return text, html.UnescapeString(href), true
|
||||
}
|
||||
|
||||
func markdownPlainText(s string) string {
|
||||
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
|
||||
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func markdownLinkLabelText(s string) string {
|
||||
text := markdownPlainText(s)
|
||||
if !strings.Contains(text, "---") {
|
||||
return text
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func markdownLink(text, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `[`, `\[`)
|
||||
text = strings.ReplaceAll(text, `]`, `\]`)
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkDestination(href string) string {
|
||||
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
|
||||
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
|
||||
// characters that can terminate or split the Markdown link destination.
|
||||
var out strings.Builder
|
||||
out.Grow(len(href))
|
||||
for i := 0; i < len(href); {
|
||||
if href[i] == '%' {
|
||||
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
|
||||
out.WriteString(href[i : i+3])
|
||||
i += 3
|
||||
} else {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if href[i] < utf8.RuneSelf {
|
||||
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
} else {
|
||||
out.WriteByte(href[i])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(href[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for _, b := range []byte(href[i : i+size]) {
|
||||
writePercentEncodedByte(&out, b)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
|
||||
if b <= ' ' || b >= 0x7f {
|
||||
return true
|
||||
}
|
||||
switch b {
|
||||
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writePercentEncodedByte(out *strings.Builder, b byte) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hex[b>>4])
|
||||
out.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
|
||||
}
|
||||
|
||||
func imMarkdownInlineCode(s string) string {
|
||||
maxRun := 0
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r == '`' {
|
||||
run++
|
||||
if run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
fence := strings.Repeat("`", maxRun+1)
|
||||
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
|
||||
return fence + " " + s + " " + fence
|
||||
}
|
||||
return fence + s + fence
|
||||
}
|
||||
|
||||
func imMarkdownFencedCode(code, lang string) string {
|
||||
maxRun := 0
|
||||
for _, line := range strings.Split(code, "\n") {
|
||||
if run := leadingBacktickRun(line); run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
}
|
||||
fenceLen := maxRun + 1
|
||||
if fenceLen < 3 {
|
||||
fenceLen = 3
|
||||
}
|
||||
fence := strings.Repeat("`", fenceLen)
|
||||
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
|
||||
}
|
||||
|
||||
func leadingBacktickRun(s string) int {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r != '`' {
|
||||
break
|
||||
}
|
||||
run++
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
@@ -72,9 +72,6 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
if isIMMarkdownFetch(runtime) {
|
||||
applyFetchIMMarkdown(data, runtime.Str("doc"))
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -88,7 +85,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"format": runtime.Str("doc-format"),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
@@ -125,14 +122,6 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
if format == "im-markdown" {
|
||||
return "markdown"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
|
||||
@@ -6,12 +6,9 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -107,369 +104,6 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "revision-id", "42")
|
||||
mustSetFetchFlag(t, runtime, "detail", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["revision_id"]; got != 42 {
|
||||
t.Fatalf("revision_id = %#v, want 42", got)
|
||||
}
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "detail", "with-ids")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "scope", "section")
|
||||
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full omits read option",
|
||||
setFlags: map[string]string{
|
||||
"scope": "full",
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "outline with max depth",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
"max-depth": "3",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "outline",
|
||||
"max_depth": "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with block ids and context",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"end-block-id": "blk_end",
|
||||
"context-before": "2",
|
||||
"context-after": "1",
|
||||
"max-depth": "0",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "blk_start",
|
||||
"end_block_id": "blk_end",
|
||||
"context_before": "2",
|
||||
"context_after": "1",
|
||||
"max_depth": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with query",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context-before": "1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context_before": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section keeps unlimited depth omitted",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
"max-depth": "-1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "negative context before",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-before": "-1",
|
||||
},
|
||||
wantParam: "--context-before",
|
||||
},
|
||||
{
|
||||
name: "negative context after",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-after": "-1",
|
||||
},
|
||||
wantParam: "--context-after",
|
||||
},
|
||||
{
|
||||
name: "max depth below unlimited sentinel",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"max-depth": "-2",
|
||||
},
|
||||
wantParam: "--max-depth",
|
||||
},
|
||||
{
|
||||
name: "range needs boundary",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
},
|
||||
wantParams: []string{
|
||||
"--start-block-id",
|
||||
"--end-block-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword needs keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "section needs start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
err := validateReadModeFlags(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateReadModeFlags() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "outline",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section with start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
t.Fatalf("validateReadModeFlags() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid doc",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.com/sheets/sht_token",
|
||||
},
|
||||
wantParam: "--doc",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateFetchV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateFetchV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "xml format",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "xml",
|
||||
"detail": "full",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markdown simple detail",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": "simple",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
|
||||
t.Fatalf("warning = %q, want empty", got)
|
||||
}
|
||||
if _, ok := data["warnings"]; ok {
|
||||
t.Fatalf("unexpected warnings: %#v", data["warnings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -507,54 +141,36 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "im-markdown",
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
|
||||
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, format := range []string{"markdown", "im-markdown"} {
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(format+"/"+detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": format,
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,107 +261,6 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999999,
|
||||
"msg": "fetch failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchAPIError",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mountAndRunDocs() succeeded, want API error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 999999 {
|
||||
t.Errorf("code = %d, want 999999", p.Code)
|
||||
}
|
||||
if p.Message != "fetch failed" {
|
||||
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdown",
|
||||
"revision_id": float64(1),
|
||||
"content": strings.Join([]string{
|
||||
`<title>Doc Title</title>`,
|
||||
`<callout emoji="💡">Read **this**.</callout>`,
|
||||
`<bookmark name="Example" href="https://example.com"></bookmark>`,
|
||||
}, "\n\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdown",
|
||||
"--doc-format", "im-markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
for _, want := range []string{
|
||||
"# Doc Title",
|
||||
"---\n💡 Read **this**.\n---",
|
||||
"[Example](https://example.com)",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("converted content missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
|
||||
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -776,7 +291,6 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
@@ -802,14 +316,6 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,24 +15,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wrapExportContextErr converts a context cancellation / deadline error into a
|
||||
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
|
||||
// preserved for errors.Is) instead of an untyped context.Canceled /
|
||||
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
|
||||
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
|
||||
func wrapExportContextErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
msg := "drive +export polling cancelled: %s"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
msg = "drive +export polling deadline exceeded: %s"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
@@ -59,305 +40,236 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateExport(exportParamsFromFlags(runtime))
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
OnlySchema: p.OnlySchema,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// validateExport runs the CLI-level export constraint checks. Unexported because
|
||||
// only drive +export's Validate consumes it directly; sheets +workbook-export
|
||||
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
|
||||
// validation, so there is no cross-package call site to keep exported.
|
||||
func validateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
return err
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return wrapExportContextErr(ctx.Err())
|
||||
case <-time.After(driveExportPollInterval):
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return wrapExportContextErr(err)
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
return err
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -498,72 +497,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1101,37 +1034,3 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
|
||||
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
|
||||
// directly so an untyped context.Canceled would escape as a plain string at
|
||||
// the command layer, bypassing the typed-error contract.
|
||||
func TestWrapExportContextErr(t *testing.T) {
|
||||
if err := wrapExportContextErr(nil); err != nil {
|
||||
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
|
||||
}
|
||||
|
||||
cancelled := wrapExportContextErr(context.Canceled)
|
||||
var netErrCancel *errs.NetworkError
|
||||
if !errors.As(cancelled, &netErrCancel) {
|
||||
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
|
||||
}
|
||||
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(cancelled, context.Canceled) {
|
||||
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
|
||||
}
|
||||
|
||||
deadline := wrapExportContextErr(context.DeadlineExceeded)
|
||||
var netErrDeadline *errs.NetworkError
|
||||
if !errors.As(deadline, &netErrDeadline) {
|
||||
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
|
||||
}
|
||||
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if !errors.Is(deadline, context.DeadlineExceeded) {
|
||||
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,164 +35,132 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -469,29 +469,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
|
||||
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": audio,
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
@@ -515,17 +492,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "bad_thread",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user