From 292504735ea0d741a186e6dc60c684cfabb828a8 Mon Sep 17 00:00:00 2001 From: chenchen Date: Wed, 10 Jun 2026 21:17:32 +0800 Subject: [PATCH] feat(swarm): add goal_summary to swarm status view (#45/#28 consumer ask) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Mem0ried 客户端 consumer 验收(#59)指出 GET /swarms/:id 缺 goal_summary —— Run 列表 只能显示 deployment_id/status,体验差。补:swarmDeploymentView 增加 goal_summary,从持久化 plan_json 顶层 objective 提取,折叠空白为单行 + 截断(200 rune)+ RedactText 兜底;取不到 (无 plan / 无 objective / 坏 JSON)返回空串,不臆造。list 与 detail 同走 swarmDeploymentView, 两处都带上。 文档 docs/integration/heicode-desktop-client-api.md §5.2 状态样例补 goal_summary 字段说明。 测试 TestSwarmGoalSummary 覆盖:空/坏 JSON/无 objective→空;多行多空格折叠;误入 sk- 被脱敏; 超长截断带省略号。go build ./... 与 controller 测试全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration/heicode-desktop-client-api.md | 1 + heicode/controller/agent_swarm_query.go | 30 +++++++++++++++++-- heicode/controller/agent_swarm_query_test.go | 23 ++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index f16e35c..972b800 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -331,6 +331,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) "deployment_id":"dep_…", "swarm_id":"swarm-…", "correlation_id":"…", "status":"blocked", // 运行时真实状态(契约 §4) "display_status":"degraded", // 客户端展示态(§4.1 映射:blocked→degraded,余直通) + "goal_summary":"…", // 单行目标(从 plan objective 提取,折叠/截断/脱敏;取不到为空串) "phase":"…", "runtime_state":"…", "failure_reason":"", "created_at":"…","updated_at":"…","runtime_last_sync_at":"…" }} diff --git a/heicode/controller/agent_swarm_query.go b/heicode/controller/agent_swarm_query.go index a5dea9b..71da921 100644 --- a/heicode/controller/agent_swarm_query.go +++ b/heicode/controller/agent_swarm_query.go @@ -60,8 +60,9 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H { "swarm_id": dep.RuntimeSwarmID, "runtime_deployment_id": dep.RuntimeDeploymentID, "correlation_id": dep.CorrelationID, - "status": dep.Status, // 运行时真实状态(契约 §4) + "status": dep.Status, // 运行时真实状态(契约 §4) "display_status": swarmDisplayStatus(dep.Status), // 客户端展示态(契约 §4.1 映射) + "goal_summary": swarmGoalSummary(dep.PlanJSON), // 单行目标(客户端 Run 列表展示;@Mem0ried #28 消费需求) "phase": dep.Phase, "runtime_state": dep.RuntimeState, "failure_reason": dep.FailureReason, @@ -71,6 +72,31 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H { } } +// swarmGoalSummary 从持久化的 plan_json 提取顶层 objective,折叠为单行供客户端 Run 列表展示 +// (#28 消费需求:列表不止 deployment_id/status)。空白折叠 + 截断 + RedactText 兜底(objective +// 是用户目标文本而非密钥,但仍按统一红线剥离误入的 sk-/token);取不到则返回空串(不臆造)。 +func swarmGoalSummary(planJSON string) string { + if strings.TrimSpace(planJSON) == "" { + return "" + } + var plan struct { + Objective string `json:"objective"` + } + if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil { + return "" + } + s := strings.TrimSpace(plan.Objective) + if s == "" { + return "" + } + s = strings.Join(strings.Fields(s), " ") // 折叠所有空白(含换行)为单空格 + const maxLen = 200 + if len([]rune(s)) > maxLen { + s = strings.TrimSpace(string([]rune(s)[:maxLen])) + "…" + } + return model.RedactText(s) +} + // swarmDisplayStatus 把运行时真实状态映射到客户端展示态(agent_swarm runtime-contract §4.1)。 // 运行时只有 waiting_approval/running/blocked/completed/failed/stopped;`blocked` 展示为 // `degraded`,其余直通。preparing/verifying 是 running 的子态、由运行时阶段决定,HM 未单独存, @@ -138,7 +164,7 @@ func sanitizeSwarmPayload(raw string) map[string]any { // swarmEventView maps a persisted callback event to the client view (payload 脱敏)。 func swarmEventView(e model.AgentCallbackEvent) gin.H { return gin.H{ - "id": e.Id, // HM 不透明分页游标(next_after);单调,兼容 sequence 未上线 + "id": e.Id, // HM 不透明分页游标(next_after);单调,兼容 sequence 未上线 "sequence": e.Sequence, // agent_swarm v1 per-swarm 序号(客户端去重/排序;0=envelope 未带) "event_id": e.EventID, "event_type": e.EventType, diff --git a/heicode/controller/agent_swarm_query_test.go b/heicode/controller/agent_swarm_query_test.go index 406c091..e6ee5f4 100644 --- a/heicode/controller/agent_swarm_query_test.go +++ b/heicode/controller/agent_swarm_query_test.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "strings" "testing" "github.com/heicode/manager/model" @@ -91,3 +92,25 @@ func TestSwarmFrozenEventTypesRegistered(t *testing.T) { require.True(t, inReq, "event_type %s 应在 requiredFields 注册", et) } } + +// #28(@Mem0ried 消费需求):goal_summary 从 plan_json 顶层 objective 提取 —— 折叠空白为单行、 +// 截断、脱敏;无 plan / 无 objective / 坏 JSON 返回空串(不臆造)。 +func TestSwarmGoalSummary(t *testing.T) { + require.Empty(t, swarmGoalSummary("")) + require.Empty(t, swarmGoalSummary("not-json")) + require.Empty(t, swarmGoalSummary(`{"sub_mode":"swarm"}`)) // 无 objective + + // 多行/多空格折叠成单行(JSON 里的 \n 是合法转义,用原始串避免再转义) + got := swarmGoalSummary(`{"objective":" 迁移 支付服务\n 到 K8s "}`) + require.Equal(t, "迁移 支付服务 到 K8s", got) + + // 误入的 sk- 被 RedactText 兜底 + red := swarmGoalSummary(`{"objective":"deploy with key sk-abcdEFGH1234567890XYZ now"}`) + require.NotContains(t, red, "sk-abcdEFGH1234567890XYZ") + + // 超长截断带省略号 + long := `{"objective":"` + strings.Repeat("x", 400) + `"}` + out := swarmGoalSummary(long) + require.LessOrEqual(t, len([]rune(out)), 201) // 200 + … + require.True(t, strings.HasSuffix(out, "…")) +}