Files
AskaEth 6e7ec56f9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00

289 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"sort"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// wsEventsAdapter 是一个两步脚本适配器:第一步发出一个文本增量后
// 返回原生工具调用,第二步再发一个增量并收尾 —— 恰好覆盖
// v0.50 事件批的全部帧形态(turn 边界、两次模型流的 streamId、
// tool_execution 三事件中的 start/end)。
type wsEventsAdapter struct{ calls int }
func (a *wsEventsAdapter) Name() string { return "ws-events-test" }
func (a *wsEventsAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "done"}, nil
}
func (a *wsEventsAdapter) ChatStream(_ context.Context, _ model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.calls++
if a.calls == 1 {
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0})
}
return model.Response{Model: "m", Content: "", ToolCalls: []model.ToolCall{{
ID: "call_ws_1", Name: "datetime", Args: map[string]any{},
}}}, nil
}
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "lo", Index: 0})
}
return model.Response{Model: "m", Content: "Hello"}, nil
}
// wsEventsTool 是注册进真实 dispatcher 的最小工具,使 runTurn 走过
// 完整的工具执行路径(含 tool_execution.start/end 与 phase)。
type wsEventsTool struct{}
func (wsEventsTool) Name() string { return "datetime" }
func (wsEventsTool) Description() string { return "test datetime tool" }
func (wsEventsTool) Parameters() (map[string]any, error) { return map[string]any{}, nil }
func (wsEventsTool) Execute(context.Context, any) (any, error) {
return map[string]any{"now": "2026-10-04 12:00:00"}, nil
}
// newEventsStreamHub 把 hub 接到带工具的真实 loop,OnStream 闭包与
// cmd/cyrene 的接线一致(整个 StreamEvent 发布到 ws.stream),因此
// 测试驱动的是生产路径 OnStream → bus → relay → broadcastStream。
func newEventsStreamHub(t *testing.T) (*hub, *wsConn) {
t.Helper()
b := bus.New()
reg := registry.New()
td, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if err := td.Register(wsEventsTool{}); err != nil {
t.Fatalf("register tool: %v", err)
}
ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsEventsAdapter{}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
lp := loop.New(loop.Options{
Agent: ag,
MaxRounds: 8,
OnStream: func(ev model.StreamEvent) {
_ = b.Publish(context.Background(), wsStreamSubject, ev)
},
})
hb := newTestHub(lp, b)
hb.streaming = true
hb.start()
t.Cleanup(hb.close)
c := newOwnerConn(hb, 64)
hb.register(c)
return hb, c
}
// collectFramesUntilIdle 收集帧直到凑满 want 帧(含 stream_end)后再
// 静默 200ms 确认没有迟到的 bus 帧。loop 帧经 bus 异步抵达而
// stream_end 由 runTurn 直接广播,因此不能假定两者相对顺序。
func collectFramesUntilIdle(t *testing.T, c *wsConn, want int) []wsEvent {
t.Helper()
var frames []wsEvent
decode := func(b []byte) wsEvent {
var ev wsEvent
if err := json.Unmarshal(b, &ev); err != nil {
t.Fatalf("decode frame %s: %v", b, err)
}
return ev
}
deadline := time.After(5 * time.Second)
for {
if len(frames) >= want {
select {
case b := <-c.send:
frames = append(frames, decode(b))
case <-time.After(200 * time.Millisecond):
return frames
case <-deadline:
return frames
}
continue
}
select {
case b := <-c.send:
frames = append(frames, decode(b))
case <-deadline:
t.Fatalf("timed out with %d frames, want at least %d: %+v", len(frames), want, frames)
}
}
}
// runEventsTurn 驱动一个完整的工具回合并返回按 seq 排序的帧,
// 以及其中的 stream_end(loop 帧经 bus 异步抵达而 stream_end 由
// runTurn 直接广播,因此不能假定两者相对顺序,排序后统一断言)。
func runEventsTurn(t *testing.T) ([]wsEvent, *wsEvent) {
t.Helper()
hb, c := newEventsStreamHub(t)
if _, err := hb.runTurn(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "time?")}, loop.OwnerEnv()); err != nil {
t.Fatalf("runTurn: %v", err)
}
frames := collectFramesUntilIdle(t, c, 7)
sort.SliceStable(frames, func(i, j int) bool { return frames[i].Seq < frames[j].Seq })
var streamEnd *wsEvent
for i := range frames {
if frames[i].Type == "stream_end" && streamEnd == nil {
streamEnd = &frames[i]
}
}
return frames, streamEnd
}
// eventsByType 把 loop 帧(排除 stream_end)按类型归组,同时校验
// 每帧都带会话与 turn-* 作用域。
func eventsByType(t *testing.T, frames []wsEvent) map[string][]wsEvent {
t.Helper()
byType := map[string][]wsEvent{}
for _, f := range frames {
if f.Type == "stream_end" {
continue
}
byType[f.Type] = append(byType[f.Type], f)
if f.SessionID != "web" || !strings.HasPrefix(f.TurnID, "turn-") {
t.Fatalf("frame %s scope = session %q turn %q, want web + turn-*", f.Type, f.SessionID, f.TurnID)
}
}
return byType
}
// TestStreamFramesTurnSequenceAndBoundaries 是 v0.50 回合边界与帧序的
// 端到端锁定(裁决 01.4.3):经真实 loop + bus + hub 驱动的一个工具
// 回合,loop 帧必须按序发出 turn.start → message.delta →
// tool_execution.start → tool_execution.end → message.delta →
// turn.end;turn 边界帧无载荷、整轮 turnId 一致、stream_end 照常收尾。
func TestStreamFramesTurnSequenceAndBoundaries(t *testing.T) {
t.Parallel()
frames, streamEnd := runEventsTurn(t)
var seq []string
for _, f := range frames {
if f.Type != "stream_end" {
seq = append(seq, f.Type)
}
}
want := []string{
"turn.start", "message.delta",
"tool_execution.start", "tool_execution.end",
"message.delta", "turn.end",
}
if len(seq) != len(want) {
t.Fatalf("loop frame types = %v, want %v (frames=%+v)", seq, want, frames)
}
for i := range want {
if seq[i] != want[i] {
t.Fatalf("loop frame[%d] = %q, want %q (all=%v)", i, seq[i], want[i], seq)
}
}
if streamEnd == nil {
t.Fatalf("no stream_end frame arrived (frames=%+v)", frames)
}
byType := eventsByType(t, frames)
turnID := byType["turn.start"][0].TurnID
if byType["turn.end"][0].TurnID != turnID || byType["message.delta"][0].TurnID != turnID {
t.Fatalf("turnId differs across the turn: %q / %q / %q", turnID, byType["turn.end"][0].TurnID, byType["message.delta"][0].TurnID)
}
for _, boundary := range []wsEvent{byType["turn.start"][0], byType["turn.end"][0]} {
if boundary.Data != nil {
t.Fatalf("%s carries data %+v; the boundary frames must be payload-free", boundary.Type, boundary.Data)
}
}
}
// TestStreamFramesStreamIdAndToolExecutionPayloads 是 v0.50 streamId
// 与工具执行帧载荷的端到端锁定(裁决 01.4.1 / 01.4.2):模型流帧带
// 每流独立的 streamId(同流共享、跨流相异);tool_execution 帧带
// 结构化 tool/phase/id,且不带 delta 拼串、不带 streamId。
func TestStreamFramesStreamIdAndToolExecutionPayloads(t *testing.T) {
t.Parallel()
frames, _ := runEventsTurn(t)
byType := eventsByType(t, frames)
d1 := dataMap(t, byType["message.delta"][0])
d2 := dataMap(t, byType["message.delta"][1])
if d1["delta"] != "Hel" || d2["delta"] != "lo" {
t.Fatalf("deltas = %v / %v, want Hel / lo", d1["delta"], d2["delta"])
}
s1, _ := d1["streamId"].(string)
s2, _ := d2["streamId"].(string)
if !strings.HasPrefix(s1, "stream-") || !strings.HasPrefix(s2, "stream-") {
t.Fatalf("streamIds = %q / %q, want two stream-* ids (one per model stream)", s1, s2)
}
if s1 == s2 {
t.Fatalf("the tool turn's two model streams share streamId %q; each ChatStream call must mint a fresh one", s1)
}
startData := dataMap(t, byType["tool_execution.start"][0])
endData := dataMap(t, byType["tool_execution.end"][0])
if startData["tool"] != "datetime" || endData["tool"] != "datetime" {
t.Fatalf("tool_execution tool = %v / %v, want datetime", startData["tool"], endData["tool"])
}
if startData["phase"] != model.ToolPhaseRunning || endData["phase"] != model.ToolPhaseDone {
t.Fatalf("tool_execution phases = %v / %v, want running / done", startData["phase"], endData["phase"])
}
if startData["id"] != "call_ws_1" || endData["id"] != "call_ws_1" {
t.Fatalf("tool_execution ids = %v / %v, want the provider call id", startData["id"], endData["id"])
}
for typ, data := range map[string]map[string]any{"start": startData, "end": endData} {
if _, ok := data["delta"]; ok {
t.Fatalf("tool_execution.%s carries a delta key; phase must not ride in delta: %v", typ, data)
}
if _, ok := data["streamId"]; ok {
t.Fatalf("tool_execution.%s carries a streamId; tool execution is not a model stream: %v", typ, data)
}
}
}
// dataMap 取帧 data 的映射形态,形状不符时直接失败。
func dataMap(t *testing.T, ev wsEvent) map[string]any {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame %s data = %+v, want a map", ev.Type, ev.Data)
}
return data
}
// TestStreamFramesToolExecutionErrorPhase 是失败路径的帧形状锁定:
// 直接经生产映射路径(broadcastStream,即 relay 消费 ws.stream 的
// 同一入口)广播一个 error phase 的 end 事件,验证帧携带
// phase=error(裁决 03.4.4:前端/流程图据此把 tool 节点翻成
// error 态)。真实的失败产生路径由 loop 包的
// TestRun_ToolExecutionErrorPhase 端到端覆盖。
func TestStreamFramesToolExecutionErrorPhase(t *testing.T) {
t.Parallel()
hb, c := newEventsStreamHub(t)
hb.broadcastStream(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd,
Tool: "datetime",
Phase: model.ToolPhaseError,
ID: "call_ws_1",
SessionID: "web",
TurnID: "turn-err",
})
ev := readFakeFrame(t, c)
if ev.Type != "tool_execution.end" {
t.Fatalf("frame type = %q, want tool_execution.end (frame=%+v)", ev.Type, ev)
}
data := dataMap(t, ev)
if data["phase"] != model.ToolPhaseError || data["tool"] != "datetime" {
t.Fatalf("frame data = %+v, want phase=error tool=datetime", data)
}
}