Compare commits

...

4 Commits

Author SHA1 Message Date
zhengzhijie
d48bf26278 test(sheets): cover undo shortcut edge cases 2026-07-07 11:22:49 +08:00
zhengzhijie
f5b09daf2c feat(sheets): support multi-step undo 2026-07-07 11:22:49 +08:00
zhengzhijie
b2a21f5952 docs(sheets): sync undo shortcut docs 2026-07-07 11:22:48 +08:00
zhengzhijie
8d8271b552 feat(sheets): add undo shortcut 2026-07-07 11:22:48 +08:00
7 changed files with 279 additions and 4 deletions

View File

@@ -4950,6 +4950,40 @@
}
]
},
"+undo": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "count",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.",
"default": "1"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [

View File

@@ -995,6 +995,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+undo": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", Default: "1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var Undo = common.Shortcut{
Service: "sheets",
Command: "+undo",
Description: "Undo the current user's latest spreadsheet write.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user"},
HasFormat: true,
// History shortcuts keep locator flags hand-written because they share
// revision/revert helpers; keep --count here in sync with sheet-skill-spec.
Flags: append(historyLocatorFlags(),
common.Flag{Name: "count", Type: "int", Default: "1", Desc: "Number of user undo stack entries to undo sequentially (1-20)."},
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if runtime.Int("count") < 1 || runtime.Int("count") > 20 {
return sheetsValidationForFlag("count", "--count must be between 1 and 20")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(runtime, token))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func undoInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
// Always send count, including the default 1, so dry-run mirrors the exact
// request body sent by Execute.
return map[string]interface{}{
"excel_id": token,
"count": runtime.Int("count"),
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
func TestUndo_DryRun(t *testing.T) {
t.Parallel()
args := []string{"--url", testURL, "--as", "user"}
callURL := dryRunFirstCallURL(t, Undo, args)
if !containsSuffix(callURL, "invoke_write") {
t.Errorf("invoke url = %q, want invoke_write", callURL)
}
body := parseDryRunBody(t, Undo, args)
got := decodeToolInput(t, body, "undo_last")
assertInputEquals(t, got, map[string]interface{}{
"excel_id": testToken,
"count": float64(1),
})
}
func TestExecute_Undo(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"undone":3,"op_id":"op-3","top_doc_revision":30,"new_revision":31,"op_ids":["op-3","op-2","op-1"],"results":[{"op_id":"op-3","top_doc_revision":30,"new_revision":31},{"op_id":"op-2","top_doc_revision":20,"new_revision":32},{"op_id":"op-1","top_doc_revision":10,"new_revision":33}]}`)
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "3", "--as", "user"}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "undo_last")
assertInputEquals(t, input, map[string]interface{}{
"excel_id": testToken,
"count": float64(3),
})
data := decodeEnvelopeData(t, out)
if data["undone"].(float64) != 3 {
t.Fatalf("unexpected output data: %#v", data)
}
results := data["results"].([]interface{})
first := results[0].(map[string]interface{})
if data["op_id"] != first["op_id"] ||
data["top_doc_revision"] != first["top_doc_revision"] ||
data["new_revision"] != first["new_revision"] {
t.Fatalf("legacy fields are not aligned with first result: %#v", data)
}
}
func TestUndo_ValidateCount(t *testing.T) {
t.Parallel()
tests := []struct {
name string
count string
wantSub string
}{
{name: "zero", count: "0", wantSub: "--count must be between 1 and 20"},
{name: "negative", count: "-1", wantSub: "--count must be between 1 and 20"},
{name: "too large", count: "21", wantSub: "--count must be between 1 and 20"},
{name: "non integer", count: "1.5", wantSub: "invalid argument"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", tt.count, "--as", "user"})
if err == nil {
t.Fatal("expected count validation error")
}
if !strings.Contains(err.Error(), tt.wantSub) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestExecute_UndoReasonOutputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
outputJSON string
wantReason string
wantUndone float64
}{
{
name: "partial success because requested count exceeds stack",
outputJSON: `{"undone":2,"reason":"undo_stack_empty","op_ids":["op-2","op-1"],"results":[{"op_id":"op-2"},{"op_id":"op-1"}],"warning_message":"Requested 3 undo step(s), but only 2 active undo entries were available."}`,
wantReason: "undo_stack_empty",
wantUndone: 2,
},
{
name: "raw undo changeset missing or expired",
outputJSON: `{"undone":0,"reason":"undo_entry_missing","warning_message":"The undo stack entry points to raw undo changeset data that is missing or expired. Use history_list/history_revert to roll the spreadsheet back via history."}`,
wantReason: "undo_entry_missing",
wantUndone: 0,
},
{
name: "unsupported object undo",
outputJSON: `{"undone":0,"reason":"unsupported_object_undo","warning_message":"The top undo entry contains changes this undo implementation cannot replay."}`,
wantReason: "unsupported_object_undo",
wantUndone: 0,
},
{
name: "apply failed",
outputJSON: `{"undone":0,"reason":"undo_apply_failed","warning_message":"Failed to apply the top undo entry."}`,
wantReason: "undo_apply_failed",
wantUndone: 0,
},
{
name: "marker persist failed after apply",
outputJSON: `{"undone":1,"reason":"undo_state_persist_failed","op_ids":["op-1"],"results":[{"op_id":"op-1","reason":"undo_state_persist_failed","warning_message":"Undo was applied, but the marker was not persisted."}]}`,
wantReason: "undo_state_persist_failed",
wantUndone: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", tt.outputJSON)
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--as", "user"}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["undone"].(float64) != tt.wantUndone || data["reason"] != tt.wantReason {
t.Fatalf("unexpected output data: %#v", data)
}
if tt.wantReason == "undo_entry_missing" && !strings.Contains(data["warning_message"].(string), "history_list/history_revert") {
t.Fatalf("missing history fallback hint: %#v", data)
}
})
}
}

View File

@@ -160,5 +160,6 @@ func shortcutList() []common.Shortcut {
HistoryList,
HistoryRevert,
HistoryRevertStatus,
Undo,
}
}

View File

@@ -159,7 +159,7 @@ metadata:
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 |
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本回滚到指定版本,或撤销当前用户最近一次 AI 工具写入。当用户需要查看编辑历史、回滚历史版本、查询回滚状态,或说“撤销我刚才的修改 / undo 上一步 AI 写入”时使用。历史回滚为异步全表恢复;用户维度 undo 只撤当前用户自己的 undo 栈顶。仅针对飞书表格。 |
| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本CS revision之间的 changeset原始变更操作清单用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 |
## 公共 flag 速查

View File

@@ -6,26 +6,35 @@
回滚revert把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。若只是想拿**当前文档版本号revision**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量
`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次或最近多次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户自己的 undo 栈,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户最近的 AI 工具写入。若只是想拿**当前文档版本号revision**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
## 使用场景
读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut
读取历史版本、发起回滚、查询回滚状态,或撤销当前用户最近一次 AI 工具写入。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version` |
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
| 撤销自己最近的 AI 写入 | `+undo` | 只需 spreadsheet 定位;默认撤当前用户 undo 栈顶;可用 `--count` 连续撤销多步;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 |
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id``+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;传 `--count N` 时会按当前用户栈顶从新到旧连续撤销,返回的 `undone` 是实际撤销数量。返回 `undone=0``reason=undo_stack_empty` 表示当前用户没有可撤销项。
**注意事项(必须了解)**
- **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。
- **回滚是异步的**`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id <transaction_id>` 确认最终结果。
- **`history_version_id``transaction_id` 不是同一个**`history_version_id` 用于 `+history-revert`(取自 `+history-list``transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`XOR不需要子表选择器。
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version``has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
- **`+undo` 是用户维度**:只撤当前登录用户的 undo 栈顶;同一文档里其他用户的写入不会被撤销。
- **`+undo --count` 是顺序多步撤销**:从当前用户最新栈项开始逐条撤销,最多 20 步;如果可撤销项不足或中途遇到不可撤销项,会停止并返回实际 `undone` 数量与 `reason`
- **`+undo` 不区分 session / agent**:同一用户的不同 CLI 会话或 agent 共用同一个用户 undo 栈。
- **`+undo` 不进入 `+batch-update`**:撤销本身依赖用户栈状态,不能作为批量子操作嵌套执行。
## Shortcuts
@@ -34,6 +43,7 @@
| `+history-list` | read | 历史版本 |
| `+history-revert` | write | 历史版本 |
| `+history-revert-status` | read | 历史版本 |
| `+undo` | write | 历史版本 |
## Flags
@@ -61,9 +71,17 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert |
### `+undo`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--count` | int | optional | 连续撤销的用户 undo 栈项数量,默认 1最大 20。按当前用户栈顶从新到旧顺序撤销实际撤销数量以返回的 undone 为准。 |
## Examples
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`XOR二选一`+history-revert``--history-version-id`(取自 `+history-list``+history-revert-status``--transaction-id`(取自 `+history-revert` 的异步受理标识)。
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`XOR二选一`+history-revert``--history-version-id`(取自 `+history-list``+history-revert-status``--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id也不需要 history version连续撤销多步时传 `--count`
### `+history-list`
@@ -91,3 +109,16 @@ lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx
# 查询某次回滚的当前状态(进行中 / 成功 / 失败)
lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "<transaction-id-from-history-revert>"
```
### `+undo`
```bash
# 撤销当前用户最近一次 AI 工具写入
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
# 连续撤销当前用户最近 3 次 AI 工具写入;实际撤销数量以返回的 undone 为准
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3
# 先预览将调用的底层 undo_last 请求
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3 --dry-run
```