diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index 436b8e4..62c3daf 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -5,6 +5,7 @@ package cardsvc import ( "errors" + "sync" "time" "agent-system-backend/internal/store" @@ -21,14 +22,19 @@ var ( ErrNotAwaiting = errors.New("card: not awaiting action") ) +// cardAppendWindow 是流式追加(Append)的 card.updated 发布节流窗口(D45)。 +const cardAppendWindow = 250 * time.Millisecond + type Service struct { - st *store.Store - emit Emitter - now func() time.Time + st *store.Store + emit Emitter + now func() time.Time + emitMu sync.Mutex + lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡) } func New(st *store.Store, emit Emitter) *Service { - return &Service{st: st, emit: emit, now: time.Now} + return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}} } // Create 补全标识与时间戳、持久化并发布 card.created。ref 为投影引用 @@ -74,6 +80,42 @@ func (s *Service) Update(owner string, card core.ActivityCard) error { return nil } +// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown +// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow +// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖 +// 省略的中间投影)。终态发布不受节流影响(SetStatus/Update 恒发布)。 +func (s *Service) Append(owner, id, text string) error { + c, err := s.st.GetCard(owner, id) + if err != nil { + return err + } + if text != "" { + if n := len(c.Body); n > 0 && c.Body[n-1].Type == core.UIMarkdown { + c.Body[n-1].Markdown += text + } else { + c.Body = append(c.Body, core.UINode{Type: core.UIMarkdown, Markdown: text}) + } + } + c.UpdatedAt = s.now().UTC() + if err := s.st.SaveCard(owner, &c, ""); err != nil { + return err + } + if c.ConversationID == "" || s.emit == nil { + return nil + } + s.emitMu.Lock() + last, seen := s.lastEmit[id] + emit := !seen || s.now().Sub(last) >= cardAppendWindow + if emit { + s.lastEmit[id] = s.now() + } + s.emitMu.Unlock() + if !emit { + return nil + } + return s.emit(c.ConversationID, c.SourceRunID, core.EvCardUpdated, core.CardPayload{Card: c}) +} + // SetStatus 状态迁移:更新并发布 card.status。 func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) { c, err := s.st.GetCard(owner, id) diff --git a/internal/cardsvc/cardsvc_test.go b/internal/cardsvc/cardsvc_test.go index de5507b..1cd8c9c 100644 --- a/internal/cardsvc/cardsvc_test.go +++ b/internal/cardsvc/cardsvc_test.go @@ -4,6 +4,7 @@ import ( "errors" "sync" "testing" + "time" "agent-system-backend/internal/store" core "agent-system-core" @@ -39,6 +40,84 @@ func sampleCard() core.ActivityCard { } } +// TestAppendStreamThrottleAndMerge 验证 D45:Append 并入尾部 markdown 节点 +// (尾节点非 markdown 则新增),card.updated 按窗口节流(窗口内只落库不发布), +// SetStatus 终态恒发布。 +func TestAppendStreamThrottleAndMerge(t *testing.T) { + st, svc, evsFn := openTest(t) + if _, err := st.CreateProject(store.OwnerDefault, "p"); err != nil { + t.Fatal(err) + } + base := sampleCard() + base.Kind = core.CardKindResult + base.Status = core.CardActive + base.Body = []core.UINode{{Type: core.UIKV, KV: [][2]string{{"摘要", "流式"}}}} + card, err := svc.Create(store.OwnerDefault, base, "") + if err != nil { + t.Fatal(err) + } + + // 受控时钟:t0 起步,逐次 +100ms(< 250ms 窗口),最后一步跨窗口。 + now := time.Now() + svc.now = func() time.Time { return now } + + if err := svc.Append(store.OwnerDefault, card.ID, "## 进展\n"); err != nil { + t.Fatal(err) + } + now = now.Add(100 * time.Millisecond) + if err := svc.Append(store.OwnerDefault, card.ID, "- 全绿\n"); err != nil { + t.Fatal(err) + } + now = now.Add(100 * time.Millisecond) + if err := svc.Append(store.OwnerDefault, card.ID, "## 风险\n"); err != nil { + t.Fatal(err) + } + // 前三次都在 250ms 窗口内:只有第一次发布 card.updated。 + updated := 0 + for _, e := range evsFn() { + if e.Type == core.EvCardUpdated { + updated++ + } + } + if updated != 1 { + t.Fatalf("窗口内 card.updated 应节流为 1 次,got %d", updated) + } + + now = now.Add(300 * time.Millisecond) + if err := svc.Append(store.OwnerDefault, card.ID, "- 无\n"); err != nil { + t.Fatal(err) + } + if _, err := svc.SetStatus(store.OwnerDefault, card.ID, core.CardDone, "ok", ""); err != nil { + t.Fatal(err) + } + updated = 0 + for _, e := range evsFn() { + if e.Type == core.EvCardUpdated { + updated++ + } + } + if updated != 3 { // 首次 + 跨窗口一次 + SetStatus 的 Update 恒发布 + t.Fatalf("终态后 card.updated 应为 3 次,got %d", updated) + } + + got, err := svc.Get(store.OwnerDefault, card.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != core.CardDone { + t.Errorf("status = %s, want done", got.Status) + } + full := "" + for _, n := range got.Body { + if n.Type == core.UIMarkdown { + full += n.Markdown + } + } + if full != "## 进展\n- 全绿\n## 风险\n- 无\n" { + t.Errorf("流式正文合并结果不符: %q", full) + } +} + func TestCardLifecycle(t *testing.T) { st, svc, evsFn := openTest(t) if _, err := st.CreateProject(store.OwnerDefault, "p"); err != nil { diff --git a/internal/prompts/library/style-cardflow.md b/internal/prompts/library/style-cardflow.md index 17ec171..89cc0f2 100644 --- a/internal/prompts/library/style-cardflow.md +++ b/internal/prompts/library/style-cardflow.md @@ -1,16 +1,16 @@ --- id: style.cardflow layer: style -version: 1 +version: 2 title: 长结果进卡片 description: 产物型长内容经 submit_card 进右侧收件箱,对话只留结论摘要(设置 ui.cardflow 开启时注入) outputContract: | 1. 判定内容类型:报告、纪要、方案、对比、文档草稿、结构化清单 = 产物型;解释、澄清、追问、简短结论 = 对话型 - 2. 产物型长内容(预期超过约 300 字或含表格/多级结构):调用 submit_card(title, summary, body) 提交,卡片承载全文 + 2. 产物型长内容(预期超过约 300 字或含表格/多级结构):优先流式三段式——card_begin(title, summary?) 建卡,card_append(text) 逐段追加 Markdown 正文,写完 card_commit() 收尾(用户在收件箱可见增量生成);一次性短产物可直接 submit_card(title, summary, body) 3. 对话中只保留结论先行的简短摘要(要点 + 一句话结果),并自然提及"详见右侧卡片" 4. 对话型内容(含解释型长回答)一律直接写在对话里,不进卡片 - 5. 单次任务至多一张产物卡;不要把回答拆成多张小卡 + 5. 单次任务至多一张产物卡;同一时刻至多一张流式卡未收尾(多次 card_begin 以最后一卡为准,未收尾卡会被系统标注「未正常收尾」);不要把回答拆成多张小卡 --- -你具备把产物型长内容放进右侧收件箱的能力:当任务的交付物是报告、纪要、方案、对比或文档草稿这类"产物"时,调用 submit_card 工具提交全文(title 为产物标题,summary 为一两句结论摘要,body 为完整 Markdown 正文),随后在对话里给出结论先行的简短回复并自然提及产物已放入右侧卡片(如"已完成,要点如下…全文见右侧卡片")。 +你具备把产物型长内容放进右侧收件箱的能力:当任务的交付物是报告、纪要、方案、对比或文档草稿这类"产物"时,把全文提交为产物卡——长正文优先流式三段式(card_begin 建卡 → card_append 逐段追加正文 → card_commit 收尾,用户在收件箱实时看到生成进度);一次性短产物可单次 submit_card(title 为产物标题,summary 为一两句结论摘要,body 为完整 Markdown 正文)。提交后在对话里给出结论先行的简短回复并自然提及产物已放入右侧卡片(如"已完成,要点如下…全文见右侧卡片")。 边界:解释型长回答(用户问"为什么/怎么做"的讲解)即使很长也直接写在对话里——卡片只承载"产物",不承载"解释"。拆分拿不准时留在对话里(不丢内容的安全侧)。 diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 01c7d7b..e1a9df5 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -56,6 +56,7 @@ type runHandle struct { assistant []core.Part ctxObs *core.ContextObservation submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) + streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45) started time.Time } @@ -263,6 +264,55 @@ func (c *runCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { return created, nil } +// BeginStreamCard:card_begin 工具入口——建 active 产物卡并设为活跃流式卡。 +// 多次 begin 以最后一卡为活跃(提案 §4);未收尾的旧卡由 finishRun 兜底置态。 +// 收件箱关闭(cards==nil 或返回空 ID)时报错,工具层回退为对话内直写。 +func (c *runCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) { + card.Status = core.CardActive + created, err := c.CreateCard(card) + if err != nil { + return created, err + } + if created.ID == "" { + return created, errors.New("card: inbox disabled") + } + c.mgr.mu.Lock() + c.h.streamCardID = created.ID + c.mgr.mu.Unlock() + return created, nil +} + +// AppendStreamCard:card_append 工具入口——向活跃流式卡追加正文(事件节流)。 +func (c *runCtx) AppendStreamCard(text string) error { + c.mgr.mu.Lock() + id := c.h.streamCardID + c.mgr.mu.Unlock() + if id == "" { + return errors.New("card: no active stream card (card_begin first)") + } + return c.mgr.cards.Append(c.run.Owner, string(id), text) +} + +// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填 +// 并清空活跃指针。 +func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) { + c.mgr.mu.Lock() + id := c.h.streamCardID + c.h.streamCardID = "" + c.mgr.mu.Unlock() + if id == "" { + return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)") + } + card, err := c.mgr.cards.SetStatus(c.run.Owner, string(id), core.CardDone, "ok", "") + if err != nil { + return card, err + } + c.mgr.mu.Lock() + c.h.submittedCards = append(c.h.submittedCards, card.ID) + c.mgr.mu.Unlock() + return card, nil +} + // Card:按 ID 取卡(ctxasm 回填;无此卡或收件箱关闭返回 nil)。 func (c *runCtx) Card(id string) *core.ActivityCard { if c.mgr.cards == nil { @@ -556,6 +606,24 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}}) } + // 流式卡兜底(D45):run 终态时仍有未 commit 的流式卡——按 run 结果置 + // done/failed,tone 标注「未正常收尾」;SetStatus 恒发布,收件箱可见终态。 + m.mu.Lock() + streamID := rc.h.streamCardID + rc.h.streamCardID = "" + m.mu.Unlock() + if streamID != "" && m.cards != nil { + st := core.CardDone + if status != core.RunCompleted { + st = core.CardFailed + } + if card, err := m.cards.SetStatus(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil { + m.mu.Lock() + rc.h.submittedCards = append(rc.h.submittedCards, card.ID) + m.mu.Unlock() + } + } + switch status { case core.RunCompleted: usagePayload := core.RunCompletedPayload{Usage: usage} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index ad22d47..ca620d2 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -31,6 +31,11 @@ const ( toolTodos = "update_todos" toolSquad = "squad_orchestrate" toolCard = "submit_card" // 产物卡提交(ui.cardflow 开启时声明;D39 显式路由) + // 流式产卡三段式(同门控声明;D44/D45):begin 建卡、append 逐段追加、 + // commit 收尾——长正文优先走此路径,submit_card 留给一次性短产物。 + toolCardBegin = "card_begin" + toolCardAppend = "card_append" + toolCardCommit = "card_commit" ) // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。 @@ -176,6 +181,21 @@ func (l *LLMRunner) Execute(rc RunCtx) error { Description: l.toolDesc(prof.Owner, toolCard, "提交产物型长内容(报告/纪要/方案/对比/文档草稿)为右侧收件箱卡片:卡片承载全文,对话中只保留结论摘要。解释型回答不要用此工具"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string","description":"产物标题"},"summary":{"type":"string","description":"一两句结论摘要(显示在卡片与对话锚点上)"},"body":{"type":"string","description":"完整 Markdown 正文"}},"required":["title","summary","body"]}`), }) + decls = append(decls, agentllm.ToolDecl{ + Name: toolCardBegin, + Description: l.toolDesc(prof.Owner, toolCardBegin, "开始流式生成右侧收件箱产物卡(长正文推荐):先给出标题与可选摘要,随后用 card_append 逐段追加正文,写完用 card_commit 收尾"), + Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string","description":"产物标题"},"summary":{"type":"string","description":"一两句结论摘要(可选)"}},"required":["title"]}`), + }) + decls = append(decls, agentllm.ToolDecl{ + Name: toolCardAppend, + Description: l.toolDesc(prof.Owner, toolCardAppend, "向当前流式产物卡追加一段 Markdown 正文(只追加,不改写已提交内容)"), + Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string","description":"本段 Markdown 文本"}},"required":["text"]}`), + }) + decls = append(decls, agentllm.ToolDecl{ + Name: toolCardCommit, + Description: l.toolDesc(prof.Owner, toolCardCommit, "收尾当前流式产物卡:卡片转为完成态并锚定到本条对话"), + Parameters: json.RawMessage(`{"type":"object","properties":{}}`), + }) } var toolDescs []core.ToolDesc for _, t := range toolList(rc) { @@ -340,6 +360,53 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", a.Title, card.ID) + case toolCardBegin: + if !cardflow { + out = "card_begin 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里" + break + } + var a struct{ Title, Summary string } + if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" { + out = "card_begin 参数错误(title 必填): " + err.Error() + break + } + var body []core.UINode + if strings.TrimSpace(a.Summary) != "" { + body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}}) + } + card, cerr := rc.BeginStreamCard(core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardActive, Tone: "ok", Body: body}) + if cerr != nil { + out = "card_begin 失败: " + cerr.Error() + break + } + out = fmt.Sprintf("流式卡已开始(%s,card:%s)。用 card_append 逐段追加正文,写完调用 card_commit 收尾。", a.Title, card.ID) + case toolCardAppend: + if !cardflow { + out = "card_append 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里" + break + } + var a struct{ Text string } + if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil { + out = "card_append 参数错误: " + err.Error() + break + } + if aerr := rc.AppendStreamCard(a.Text); aerr != nil { + out = "card_append 失败: " + aerr.Error() + break + } + out = "已追加" + case toolCardCommit: + if !cardflow { + out = "card_commit 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里" + break + } + card, cerr := rc.CommitStreamCard() + if cerr != nil { + out = "card_commit 失败: " + cerr.Error() + break + } + finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) + out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID) default: res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)}) if res.Status != "ok" { @@ -352,7 +419,8 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated}) } - if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad || call.Name == toolCard { + if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad || call.Name == toolCard || + call.Name == toolCardBegin || call.Name == toolCardAppend || call.Name == toolCardCommit { rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)}) } diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index bf09cf3..4e61839 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -2,7 +2,9 @@ package runtime import ( "context" + "errors" "fmt" + "strings" "testing" agentllm "agent-native/runtime/llm" @@ -22,6 +24,7 @@ type fakeRunCtx struct { events []emittedEvent assistant []core.Part cards []core.ActivityCard + streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义) toolDecls []agentllm.ToolDecl execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径) } @@ -74,6 +77,51 @@ func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, erro func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { return f.CreateCard(card) } + +// 流式三段式(D44/D45):fake 侧以 streamCardID 指针 + 尾部 markdown 合并模拟 +// runmgr/cardsvc 语义,供工具编排层断言。 +func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) { + card.Status = core.CardActive + created, err := f.CreateCard(card) + if err != nil { + return created, err + } + f.streamCardID = created.ID + return created, nil +} + +func (f *fakeRunCtx) AppendStreamCard(text string) error { + if f.streamCardID == "" { + return errors.New("card: no active stream card (card_begin first)") + } + for i := range f.cards { + if f.cards[i].ID != f.streamCardID { + continue + } + if n := len(f.cards[i].Body); n > 0 && f.cards[i].Body[n-1].Type == core.UIMarkdown { + f.cards[i].Body[n-1].Markdown += text + } else { + f.cards[i].Body = append(f.cards[i].Body, core.UINode{Type: core.UIMarkdown, Markdown: text}) + } + return nil + } + return errors.New("card: not found") +} + +func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) { + if f.streamCardID == "" { + return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)") + } + id := f.streamCardID + f.streamCardID = "" + for i := range f.cards { + if f.cards[i].ID == id { + f.cards[i].Status = core.CardDone + return f.cards[i], nil + } + } + return core.ActivityCard{}, errors.New("card: not found") +} func (f *fakeRunCtx) Card(id string) *core.ActivityCard { for i := range f.cards { if string(f.cards[i].ID) == id { @@ -324,3 +372,99 @@ func hasToolDecl(decls []agentllm.ToolDecl, name string) bool { } return false } + +// TestLLMRunnerCardStreamTools 验证 D44/D45:cardflow 开启时三段式工具声明, +// begin→append×N→commit 编排为一张 done 产物卡(正文逐段合并)+ cardId 引用 +// 部件;commit 后活跃指针清空,再 append 被拒绝。 +func TestLLMRunnerCardStreamTools(t *testing.T) { + turns := [][]agentllm.Event{ + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "开始生成周报。"}, + agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ + {ID: "c1", Name: "card_begin", Arguments: `{"title":"项目周报","summary":"流式演示"}`}, + }}}, + }, + { + agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ + {ID: "c2", Name: "card_append", Arguments: `{"text":"## 进展\n- 全绿\n"}`}, + }}}, + }, + { + agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ + {ID: "c3", Name: "card_append", Arguments: `{"text":"## 风险\n- 无\n"}`}, + {ID: "c4", Name: "card_commit", Arguments: "{}"}, + }}}, + }, + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "周报完成,详见卡片。"}, + agentllm.Event{Kind: agentllm.KindMessageEnd}, + }, + } + var declsOn []agentllm.ToolDecl + r := &LLMRunner{Stream: scriptStream(turns, &declsOn)} + rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + for _, n := range []string{"card_begin", "card_append", "card_commit", "submit_card"} { + if !hasToolDecl(declsOn, n) { + t.Errorf("%s 应在 ui.cardflow 开启时声明", n) + } + } + if len(rc.cards) != 1 { + t.Fatalf("应恰好产 1 张流式卡,got %d", len(rc.cards)) + } + card := rc.cards[0] + if card.Status != core.CardDone || card.Kind != core.CardKindResult { + t.Errorf("卡 status/kind = %s/%s, want done/result", card.Status, card.Kind) + } + full := "" + for _, n := range card.Body { + full += n.Markdown + } + if !strings.Contains(full, "## 进展") || !strings.Contains(full, "## 风险") { + t.Errorf("流式正文未逐段合并齐全: %q", full) + } + var refPart *core.Part + for i := range rc.assistant { + if rc.assistant[i].Type == core.PartCard { + refPart = &rc.assistant[i] + } + } + if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(card.ID) { + t.Errorf("消息应含 cardId=%s 的引用部件", card.ID) + } + if err := rc.AppendStreamCard("late"); err == nil { + t.Error("commit 后 append 应报无活跃卡") + } +} + +// TestLLMRunnerCardStreamGated 验证:ui.cardflow 关闭时三工具不声明;幻觉调用 +// begin 被拒绝且不产卡。 +func TestLLMRunnerCardStreamGated(t *testing.T) { + turns := [][]agentllm.Event{ + { + agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ + {ID: "c1", Name: "card_begin", Arguments: `{"title":"越权卡"}`}, + }}}, + }, + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "好的。"}, + agentllm.Event{Kind: agentllm.KindMessageEnd}, + }, + } + var declsOff []agentllm.ToolDecl + r := &LLMRunner{Stream: scriptStream(turns, &declsOff)} + rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + for _, n := range []string{"card_begin", "card_append", "card_commit"} { + if hasToolDecl(declsOff, n) { + t.Errorf("%s 不应在关闭时声明", n) + } + } + if len(rc.cards) != 0 { + t.Errorf("关闭时幻觉 begin 不应产卡,got %d", len(rc.cards)) + } +} diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index 517d632..f4c1617 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -140,6 +140,34 @@ func (m *MockRunner) Execute(rc RunCtx) error { addText("已完成会议纪要:三项决议、两项待办,注册路径定为 510(k)。全文见右侧卡片,可在收件箱置顶或收纳。") parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) + case strings.Contains(user, "#cardstream"): + // 流式产卡演示(CF-S/D44-D45):card_begin → append×N → card_commit, + // 期间收件箱可见 active 卡随 card.updated 增量刷新。 + rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "流式生成周报……"}) + parts = append(parts, core.Part{Type: core.PartThinking, Text: "流式生成周报……", Process: true}) + if _, err := rc.BeginStreamCard(core.ActivityCard{ + Kind: core.CardKindResult, Title: "项目周报 · 流式", Tone: "ok", + Body: []core.UINode{{Type: core.UIKV, KV: [][2]string{{"摘要", "三段式流式产卡演示:进展 / 风险 / 下周"}}}}, + }); err != nil { + addText("收件箱未启用或流式卡开始失败(" + err.Error() + "),内容直接写在对话中。") + } else { + for _, seg := range []string{ + "## 进展\n\n- 卡流三段式工具上线\n- 组合验证全绿\n", + "## 风险\n\n- Gitea 主机偶发中断(重试可恢复)\n", + "## 下周\n\n- artifact 统一(CF-A)\n- 跨会话收件箱(CF3)\n", + } { + if aerr := rc.AppendStreamCard(seg); aerr != nil { + break + } + } + if done, cerr := rc.CommitStreamCard(); cerr == nil { + addText("周报已流式生成完毕,全文见右侧卡片。") + parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: done.ID}}) + } else { + addText("周报流式收尾失败:" + cerr.Error()) + } + } + case strings.Contains(user, "#doc"): rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"}) parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true}) diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index b3e4523..8ecb17d 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -36,6 +36,13 @@ type RunCtx interface { // SubmitCard:产物卡提交(submit_card 工具)——建卡并登记,finishRun 落库助手 // 消息后回填 AnchorMessageID(卡→消息互定位)。 SubmitCard(card core.ActivityCard) (core.ActivityCard, error) + // BeginStreamCard / AppendStreamCard / CommitStreamCard:流式产物卡三段式 + // (card_begin/card_append/card_commit 工具;D44/D45)。Begin 建 active 产物卡 + // 并设为活跃流式卡(多次 begin 以最后一卡为活跃);Append 向活跃卡追加正文 + // (card.updated 按 250ms 窗口节流);Commit 置 done 并登记锚点回填。 + BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) + AppendStreamCard(text string) error + CommitStreamCard() (core.ActivityCard, error) // Card:按 ID 取卡(ctxasm 回填 card 部件正文用;无则 nil)。 Card(id string) *core.ActivityCard // SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。 diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 4adce47..d21620a 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -188,6 +188,75 @@ func TestMockCardflowFullLoop(t *testing.T) { } } +// TestMockCardflowStreamLoop 流式产卡全链路(CF-S/D44-D45):mock #cardstream → +// card_begin(active 卡入收件箱)→ card_append×N(card.updated 增量)→ +// card_commit(done + cardId 引用部件 + finishRun 锚点回填),且正文逐段合并。 +func TestMockCardflowStreamLoop(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + runID := postMessage(t, h, convID, "#cardstream") + terminal := waitRunTerminal(t, h, runID, 5*time.Second) + if terminal["status"] != "completed" { + t.Fatalf("run status: %v", terminal["status"]) + } + + // 收件箱出现 result 卡(commit 后终态 done)。 + _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) + cards, _ := cardsResp["cards"].([]any) + var streamCard map[string]any + for _, ci := range cards { + c := ci.(map[string]any) + if c["kind"] == "result" && c["title"] == "项目周报 · 流式" { + streamCard = c + } + } + if streamCard == nil { + t.Fatalf("stream card missing from inbox: %v", cards) + } + if streamCard["status"] != "done" { + t.Errorf("stream card status = %v, want done", streamCard["status"]) + } + + // 正文逐段合并(begin 的 kv 摘要 + 三段 append)。 + body, _ := streamCard["body"].([]any) + full := "" + for _, bi := range body { + n := bi.(map[string]any) + if n["type"] == "markdown" { + full += n["markdown"].(string) + } + } + for _, seg := range []string{"## 进展", "## 风险", "## 下周", "artifact 统一"} { + if !strings.Contains(full, seg) { + t.Errorf("stream body missing %q, got %q", seg, full) + } + } + + // 助手消息含 cardId 引用部件 + 锚点回填。 + _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) + msgs := detail["messages"].([]any) + assistant := msgs[len(msgs)-1].(map[string]any) + cardID := streamCard["id"].(string) + refFound := false + for _, pi := range assistant["parts"].([]any) { + p := pi.(map[string]any) + if p["type"] != "card" { + continue + } + cp := p["card"].(map[string]any) + if cp["cardId"] == cardID { + refFound = true + } + } + if !refFound { + t.Errorf("assistant message missing card-part with cardId=%s", cardID) + } + if anchor, _ := streamCard["anchorMessageId"].(string); anchor != assistant["id"].(string) { + t.Errorf("stream card anchorMessageId = %q, want assistant message %q", anchor, assistant["id"]) + } +} + func TestMockConversationFlow(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler()