mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add +session-messages-list for session turn reply messages (#1402)
* feat(apps): add +message-get to fetch session turn reply messages * docs(apps): add +message-get skill reference * fix(apps): drop Required flags on +message-get so missing ids return structured exit-2 envelope * docs(apps): route turn reply-message queries to +message-get in SKILL.md * docs(apps): guide cloud-dev to read live turn progress via +message-get * docs(apps): note +message-get reads a still-running turn incrementally * docs(apps): route live-turn reply queries to +message-get in SKILL.md * refactor(apps): rename +message-get to +session-messages-list with page_token paging * refactor(apps): use typed errs validation in +session-messages-list * docs(apps): clarify +session-messages-list paging stops on has_more, not token
This commit is contained in:
103
shortcuts/apps/apps_session_messages_list.go
Normal file
103
shortcuts/apps/apps_session_messages_list.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const sessionMessagesListHint = "verify --app-id / --session-id / --turn-id are correct; get the latest turn_id via `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>`"
|
||||
|
||||
var AppsSessionMessagesList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-messages-list",
|
||||
Description: "List the reply messages of a session turn (page_token pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +session-messages-list --app-id <app_id> --session-id <session_id> --turn-id <turn_id>",
|
||||
"Tip: turn_id comes from `+session-get` latest_turn.turn_id; page with --page-token <next_page_token>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// app-id / session-id / turn-id are intentionally NOT Required:true. The
|
||||
// framework maps Required:true to cobra's MarkFlagRequired, whose error is
|
||||
// plain-text exit-1, bypassing the structured envelope. Spec §6 mandates
|
||||
// exit-2 + a {"ok":false,"error":{...}} validation envelope, so the
|
||||
// emptiness checks live in Validate (errs.NewValidationError -> exit 2).
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "session-id", Desc: "session ID"},
|
||||
{Name: "turn-id", Desc: "turn ID (from +session-get latest_turn.turn_id)"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response next_page_token (omit for first page)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--session-id is required").WithParam("--session-id")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--turn-id is required").WithParam("--turn-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(replyMessagePath(rctx.Str("app-id"), rctx.Str("session-id"), rctx.Str("turn-id"))).
|
||||
Desc("List the reply messages of a session turn").
|
||||
Params(buildSessionMessagesListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("GET",
|
||||
replyMessagePath(rctx.Str("app-id"), rctx.Str("session-id"), rctx.Str("turn-id")),
|
||||
buildSessionMessagesListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, sessionMessagesListHint)
|
||||
}
|
||||
messages, _ := data["messages"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
rows := make([]map[string]interface{}, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"message_id": m["message_id"],
|
||||
"role": m["role"],
|
||||
"content": m["content"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "next_page_token: %v has_more: %v\n", data["next_page_token"], data["has_more"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func replyMessagePath(appID, sessionID, turnID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/sessions/%s/turns/%s/reply_message",
|
||||
apiBasePath,
|
||||
validate.EncodePathSegment(strings.TrimSpace(appID)),
|
||||
validate.EncodePathSegment(strings.TrimSpace(sessionID)),
|
||||
validate.EncodePathSegment(strings.TrimSpace(turnID)))
|
||||
}
|
||||
|
||||
func buildSessionMessagesListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
123
shortcuts/apps/apps_session_messages_list_test.go
Normal file
123
shortcuts/apps/apps_session_messages_list_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const sessionMessagesListURL = "/open-apis/spark/v1/apps/app_x/sessions/sess_1/turns/turn_9/reply_message"
|
||||
|
||||
func TestAppsSessionMessagesList_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: sessionMessagesListURL,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{"message_id": "m1", "role": "assistant", "content": "hi"},
|
||||
},
|
||||
"next_page_token": "tok_next",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsSessionMessagesList,
|
||||
[]string{"+session-messages-list", "--app-id", "app_x", "--session-id", "sess_1", "--turn-id", "turn_9", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"message_id": "m1"`) || !strings.Contains(got, `"next_page_token": "tok_next"`) {
|
||||
t.Fatalf("stdout missing messages/next_page_token: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionMessagesList_PageTokenOnlyWhenSet(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionMessagesList,
|
||||
[]string{"+session-messages-list", "--app-id", "app_x", "--session-id", "sess_1", "--turn-id", "turn_9", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "page_token") {
|
||||
t.Fatalf("page_token must be absent when --page-token not set: %s", stdout.String())
|
||||
}
|
||||
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsSessionMessagesList,
|
||||
[]string{"+session-messages-list", "--app-id", "app_x", "--session-id", "sess_1", "--turn-id", "turn_9", "--page-token", "tok_5", "--dry-run", "--as", "user"},
|
||||
factory2, stdout2); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout2.String()
|
||||
if !strings.Contains(got, "page_token") || !strings.Contains(got, "tok_5") {
|
||||
t.Fatalf("dry-run missing page_token=tok_5: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionMessagesList_RequiresIDs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{"no app-id", []string{"+session-messages-list", "--app-id", "", "--session-id", "s", "--turn-id", "t", "--as", "user"}, "--app-id"},
|
||||
{"no session-id", []string{"+session-messages-list", "--app-id", "a", "--session-id", "", "--turn-id", "t", "--as", "user"}, "--session-id"},
|
||||
{"no turn-id", []string{"+session-messages-list", "--app-id", "a", "--session-id", "s", "--turn-id", "", "--as", "user"}, "--turn-id"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsSessionMessagesList, c.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", c.wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed Problem error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error type=%v subtype=%v, want %v/%v (err=%v)",
|
||||
p.Category, p.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument, err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != c.wantParam {
|
||||
t.Fatalf("Param = %q, want %q (err=%v)", ve.Param, c.wantParam, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSessionMessagesList_APIErrorSurfacesHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: sessionMessagesListURL,
|
||||
Body: map[string]interface{}{"code": 1254043, "msg": "permission denied"},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsSessionMessagesList,
|
||||
[]string{"+session-messages-list", "--app-id", "app_x", "--session-id", "sess_1", "--turn-id", "turn_9", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected API error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed Problem error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+session-get") {
|
||||
t.Fatalf("error should carry domain hint pointing at +session-get, got hint=%q (err=%v)", p.Hint, err)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsSessionList,
|
||||
AppsSessionGet,
|
||||
AppsSessionStop,
|
||||
AppsSessionMessagesList,
|
||||
AppsChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
|
||||
// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init)
|
||||
// + 3 git-credential + 5 session(create/list/get/stop/chat)= 23。
|
||||
func TestAppsShortcuts_Returns23(t *testing.T) {
|
||||
// + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 24。
|
||||
func TestAppsShortcuts_Returns24(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 23 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 23", len(got))
|
||||
if len(got) != 24 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 24", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ metadata:
|
||||
| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id <id>`(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) |
|
||||
|
||||
## 选择开发路径(进意图路由前先判这步)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- `latest_turn.status`:最近一轮的状态,只有 `running` / `completed` / `failed` / `cancelled`。
|
||||
- `latest_turn.turn_id`:最近一轮的句柄(`+session-stop --turn-id` 用它)。
|
||||
- `latest_turn.user_message`:本轮用户发的消息。
|
||||
- `latest_turn.messages`:这一轮里妙搭 Agent 执行产生的消息列表,按时序排列、每条带 `role`(用户消息、模型回复、工具调用等都在内,role 取值如 `user` / `assistant` / `tool`)。要回看本轮做了什么、结果如何,读这个列表。
|
||||
- `latest_turn.messages`:本轮完成后回看全貌的消息列表,按时序排列、每条带 `role`(用户消息、模型回复、工具调用等都在内,role 取值如 `user` / `assistant` / `tool`)。注意它在 `latest_turn` 仍 running/初始化期可能为空——该轮**进行中**的实时进展改用 `+session-messages-list --turn-id <latest_turn.turn_id>` 读(见下方轮询规则)。
|
||||
- `queued_messages` / `queued_count`:还没开始跑、排在后面的消息。
|
||||
- `next_poll_after_ms`:建议的下次轮询间隔(毫秒,固定值);非空时优先用它。
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
- `failed` / `cancelled` 时转述错误字段或 hint,由用户决定是否重试,不要静默重发。
|
||||
- 不知道某 app 有哪些 session 时,先 `+session-list --app-id <id>`,再选最近活跃的或让用户确认,别直接猜 `session_id`。
|
||||
- 要中止正在运行的一轮,从 `+session-get` 的 `latest_turn.turn_id` 取值,再调用 `+session-stop --turn-id <turn_id>`。
|
||||
- 状态与节奏看 `+session-get`,本轮实时内容看 `+session-messages-list`:想在 running 期间向用户播报"云端 Agent 此刻在做什么",用 `+session-messages-list --turn-id <latest_turn.turn_id>` 读已产出的增量消息(running 期间即可读,不必等本轮结束)。复用上面的轮询节奏、不另起更密的轮询;续拉时把上次响应的 `next_page_token` 作 `--page-token` 只取新消息,转述时简述进展、不原样打印整段消息或工具输出。
|
||||
|
||||
### 典型链路
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# apps +session-messages-list
|
||||
|
||||
按 page_token 分页读取某个会话轮次(turn)的回复消息。运行时命令事实以 `lark-cli apps +session-messages-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用于拉取妙搭应用一轮对话(turn)产生的回复消息列表。只读,scope `spark:app:read`,用户身份。对仍在 running 的 turn 也可读——消息随生成增量出现,配合 `--page-token` 续拉新消息,可用于云端开发期间实时播报本轮进展。它不发消息、也不判断轮次状态;想知道某轮是否跑完、拿 `turn_id`,仍先用 `+session-get`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
```bash
|
||||
lark-cli apps +session-messages-list --app-id <app_id> --session-id <session_id> --turn-id <turn_id> [--page-token <token>]
|
||||
```
|
||||
|
||||
| 旗标 | 必填 | 说明 |
|
||||
|------|:----:|------|
|
||||
| `--app-id` | 是 | 应用 ID |
|
||||
| `--session-id` | 是 | 会话 ID |
|
||||
| `--turn-id` | 是 | 轮次 ID,来自 `+session-get` 的 `latest_turn.turn_id` |
|
||||
| `--page-token` | 否 | string,上一页响应里的 `next_page_token`;首页省略 |
|
||||
|
||||
## turn_id 来源
|
||||
|
||||
`--turn-id` 不是用户能直接提供的,必须先跑 `+session-get` 拿 `latest_turn.turn_id`。没有 `turn_id` 时不要猜,先 `+session-get`。
|
||||
|
||||
## 示例
|
||||
|
||||
先取最新轮次的 `turn_id`,再拉第一页,最后用 `next_page_token` 续拉下一页:
|
||||
|
||||
```bash
|
||||
# 1. 从 +session-get 提取 latest_turn.turn_id
|
||||
TURN_ID=$(lark-cli apps +session-get --app-id app_xxx --session-id conv_xxx -q '.data.latest_turn.turn_id')
|
||||
|
||||
# 2. 拉第一页(省略 --page-token)
|
||||
lark-cli apps +session-messages-list --app-id app_xxx --session-id conv_xxx --turn-id "$TURN_ID"
|
||||
|
||||
# 3. has_more=true 时,把上一页的 next_page_token 作为 --page-token 续拉
|
||||
lark-cli apps +session-messages-list --app-id app_xxx --session-id conv_xxx --turn-id "$TURN_ID" --page-token tok_next
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `data.messages[]`:每条含 `message_id`、`role`、`content`。
|
||||
- `data.next_page_token`(string):下一页分页令牌,作为下次调用的 `--page-token`。**注意它在最后一页仍非空**(解码形如 `{"offset":N}`),不能用它是否为空判断还有没有下一页。
|
||||
- `data.has_more`(bool):是否还有更多消息。**这是判断要不要续拉的唯一依据。**
|
||||
- pretty 输出为消息表 + 末行 `next_page_token: <token> has_more: <bool>`;自动化取字段用 JSON 或 `-q`。
|
||||
- 业务失败(app/session/turn 不存在或 ID 写错)通常带 `error.hint` 指向 `+session-get`,优先转述 hint。
|
||||
|
||||
## 分页规则
|
||||
|
||||
单次调用只返回一页。Agent 自行续拉:把本次响应的 `next_page_token` 作为下次的 `--page-token`,直到 `has_more` 为 `false` 才停。首页不要传 `--page-token`。
|
||||
|
||||
> ⚠️ **终止条件只看 `has_more`,不要拿 `next_page_token` 是否为空判断。** 即使 `has_more=false`(已是最后一页),后端仍会返回一个非空的 `next_page_token`(解码形如 `{"offset":N}`);若以「token 非空就继续」为循环条件,会在末页之后继续翻出空页(每页 0 条),白费调用。读到 `has_more=false` 立即停止,不要再用该 token 续拉。
|
||||
Reference in New Issue
Block a user