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:
raistlin042
2026-06-17 20:12:22 +08:00
committed by GitHub
parent c5b5aece33
commit 76f5419a0d
7 changed files with 287 additions and 5 deletions

View 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
}

View 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)
}
}

View File

@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
AppsSessionList,
AppsSessionGet,
AppsSessionStop,
AppsSessionMessagesList,
AppsChat,
}
}

View File

@@ -11,11 +11,11 @@ import (
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 dbtable-list/table-schema/sql/dev-init
// + 3 git-credential + 5 sessioncreate/list/get/stop/chat= 23
func TestAppsShortcuts_Returns23(t *testing.T) {
// + 3 git-credential + 5 sessioncreate/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))
}
}

View File

@@ -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) |
## 选择开发路径(进意图路由前先判这步)

View File

@@ -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` 只取新消息,转述时简述进展、不原样打印整段消息或工具输出。
### 典型链路

View File

@@ -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 续拉。