diff --git a/shortcuts/apps/apps_session_messages_list.go b/shortcuts/apps/apps_session_messages_list.go new file mode 100644 index 00000000..ef180539 --- /dev/null +++ b/shortcuts/apps/apps_session_messages_list.go @@ -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 --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 --session-id --turn-id ", + "Tip: turn_id comes from `+session-get` latest_turn.turn_id; page with --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 +} diff --git a/shortcuts/apps/apps_session_messages_list_test.go b/shortcuts/apps/apps_session_messages_list_test.go new file mode 100644 index 00000000..c2bf899e --- /dev/null +++ b/shortcuts/apps/apps_session_messages_list_test.go @@ -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) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index d5a22658..e15489fa 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut { AppsSessionList, AppsSessionGet, AppsSessionStop, + AppsSessionMessagesList, AppsChat, } } diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 689bc924..264c7ed4 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -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)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index d0475000..ef9da374 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -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 `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/lark-apps-cloud-dev.md b/skills/lark-apps/references/lark-apps-cloud-dev.md index 36432bda..7561d34b 100644 --- a/skills/lark-apps/references/lark-apps-cloud-dev.md +++ b/skills/lark-apps/references/lark-apps-cloud-dev.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 ` 读(见下方轮询规则)。 - `queued_messages` / `queued_count`:还没开始跑、排在后面的消息。 - `next_poll_after_ms`:建议的下次轮询间隔(毫秒,固定值);非空时优先用它。 @@ -36,6 +36,7 @@ - `failed` / `cancelled` 时转述错误字段或 hint,由用户决定是否重试,不要静默重发。 - 不知道某 app 有哪些 session 时,先 `+session-list --app-id `,再选最近活跃的或让用户确认,别直接猜 `session_id`。 - 要中止正在运行的一轮,从 `+session-get` 的 `latest_turn.turn_id` 取值,再调用 `+session-stop --turn-id `。 +- 状态与节奏看 `+session-get`,本轮实时内容看 `+session-messages-list`:想在 running 期间向用户播报"云端 Agent 此刻在做什么",用 `+session-messages-list --turn-id ` 读已产出的增量消息(running 期间即可读,不必等本轮结束)。复用上面的轮询节奏、不另起更密的轮询;续拉时把上次响应的 `next_page_token` 作 `--page-token` 只取新消息,转述时简述进展、不原样打印整段消息或工具输出。 ### 典型链路 diff --git a/skills/lark-apps/references/lark-apps-session-messages-list.md b/skills/lark-apps/references/lark-apps-session-messages-list.md new file mode 100644 index 00000000..220b80bb --- /dev/null +++ b/skills/lark-apps/references/lark-apps-session-messages-list.md @@ -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 --session-id --turn-id [--page-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: has_more: `;自动化取字段用 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 续拉。