feat(gateway,loop): A1 流式帧附加 sessionId/turnId — 多会话并发流式可路由
- model.StreamEvent 增 SessionID/TurnID(适配器在 loop 之下,恒留零值) - loop 在回合边界注入;覆盖 stream.go 与 tools.go 的 tool_execution 发射点 - 网关 runTurn 每回合生成 turnID 并经 loop.WithTurnID 挂进运行上下文(编排层与 legacy 直连的唯一收口点);broadcastStream 填充 wsEvent - Docs/api-client v0.36 契约与变更日志;v0.10 条目标注为已被补齐 - 新增 loop/gateway 测试,含变异验证
This commit is contained in:
1 parent
29aa60d0eb
commit
61cc32c44b
9 files changed
+332
-10
No files matched your search
@@ -19,3 +19,22 @@ func loopSession(ctx context.Context) string {
|
||||
s, _ := ctx.Value(loopSessionIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// loopTurnIDKey is the context key for the assistant turn the current Run is
|
||||
// producing. The gateway attaches it (WithTurnID) before starting a turn so the
|
||||
// streaming events the loop emits can be correlated to that turn by the client.
|
||||
type loopTurnIDKey struct{}
|
||||
|
||||
// WithTurnID attaches turnID to ctx so the streaming events the loop emits carry
|
||||
// the turn they belong to. It is exported because the gateway calls it once per
|
||||
// turn, before Run; a caller that attaches none leaves the events' TurnID empty,
|
||||
// which keeps the correlation fields backward compatible.
|
||||
func WithTurnID(ctx context.Context, turnID string) context.Context {
|
||||
return context.WithValue(ctx, loopTurnIDKey{}, turnID)
|
||||
}
|
||||
|
||||
// loopTurn returns the turn id on ctx, or "" when none was attached.
|
||||
func loopTurn(ctx context.Context) string {
|
||||
s, _ := ctx.Value(loopTurnIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -32,6 +32,12 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
}
|
||||
|
||||
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
|
||||
// Stamp the session/turn identity at the loop boundary: the adapter runs
|
||||
// below the loop and cannot know which conversation the stream belongs to,
|
||||
// so the loop fills it from the context it was driven with (design/16 §4.2)
|
||||
// and the frontend can route the delta to the active session.
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
// Suppress UI broadcast for internal autonomous passes (backthink),
|
||||
// whose intermediate reasoning must not leak into the chat stream.
|
||||
if l.onStream != nil && !isMutedBroadcast(ctx) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// collectStream returns an OnStream callback plus a reader for the events it saw,
|
||||
// so a test can assert the scope the loop stamped on each emitted event.
|
||||
func collectStream() (func(model.StreamEvent), func() []model.StreamEvent) {
|
||||
var mu sync.Mutex
|
||||
var events []model.StreamEvent
|
||||
on := func(ev model.StreamEvent) {
|
||||
mu.Lock()
|
||||
events = append(events, ev)
|
||||
mu.Unlock()
|
||||
}
|
||||
read := func() []model.StreamEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]model.StreamEvent(nil), events...)
|
||||
}
|
||||
return on, read
|
||||
}
|
||||
|
||||
// TestRun_StreamCarriesSessionAndTurn locks the A1 contract: the loop stamps the
|
||||
// session it was driven for and the turn id attached with WithTurnID on every
|
||||
// streamed event, so the WS hub can route message.delta/thinking.delta/tool_call.*
|
||||
// to the right conversation and turn (design/16 §4.2).
|
||||
func TestRun_StreamCarriesSessionAndTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{
|
||||
stream: []mgResp{{resp: model.Response{Content: "Hello world"}}},
|
||||
chunks: [][]string{{"Hel", "lo"}},
|
||||
}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
ctx := WithTurnID(context.Background(), "turn-xyz")
|
||||
if _, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
events := streams()
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("stream events = %d, want 2", len(events))
|
||||
}
|
||||
for i, ev := range events {
|
||||
if ev.SessionID != "web" || ev.TurnID != "turn-xyz" {
|
||||
t.Fatalf("event[%d] scope = session %q turn %q, want session web turn turn-xyz", i, ev.SessionID, ev.TurnID)
|
||||
}
|
||||
}
|
||||
if events[0].Delta != "Hel" || events[1].Delta != "lo" {
|
||||
t.Fatalf("deltas = [%q %q], want [Hel lo]", events[0].Delta, events[1].Delta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StreamWithoutTurnIDLeavesItEmpty keeps the backward-compatible default:
|
||||
// a caller that attaches no turn id still gets the session on each event, but an
|
||||
// empty TurnID — the field's zero value is the old behavior.
|
||||
func TestRun_StreamWithoutTurnIDLeavesItEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{
|
||||
stream: []mgResp{{resp: model.Response{Content: "ok"}}},
|
||||
chunks: [][]string{{"o", "k"}},
|
||||
}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
events := streams()
|
||||
if len(events) == 0 {
|
||||
t.Fatal("no stream events were emitted")
|
||||
}
|
||||
for i, ev := range events {
|
||||
if ev.SessionID != "web" || ev.TurnID != "" {
|
||||
t.Fatalf("event[%d] scope = session %q turn %q, want session web turn \"\"", i, ev.SessionID, ev.TurnID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ToolExecutionCarriesScope covers the loop's other stream emitter: the
|
||||
// tool_execution_update progress frame must be scoped too, otherwise it would be
|
||||
// the one frame a multi-session client could not route.
|
||||
func TestRun_ToolExecutionCarriesScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{
|
||||
{resp: nativeDatetimeCall()},
|
||||
{resp: model.Response{Model: "m", Content: "done"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}})
|
||||
a := newTestAgent(t, ad, td)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
ctx := WithTurnID(context.Background(), "turn-tool")
|
||||
if _, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "time?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
found := 0
|
||||
for _, ev := range streams() {
|
||||
if ev.Kind != model.StreamKindToolExecution {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if ev.SessionID != "web" || ev.TurnID != "turn-tool" {
|
||||
t.Fatalf("tool_execution scope = session %q turn %q, want session web turn turn-tool", ev.SessionID, ev.TurnID)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("no tool_execution_update event was emitted")
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,10 @@ func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
l.onStream(model.StreamEvent{Kind: model.StreamKindToolExecution, Delta: phase + ":" + name})
|
||||
ev := model.StreamEvent{Kind: model.StreamKindToolExecution, Delta: phase + ":" + name}
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
l.onStream(ev)
|
||||
}
|
||||
|
||||
// invokeParallel runs calls at idxs concurrently, capped at the loop's
|
||||
|
||||
@@ -57,4 +57,15 @@ type StreamEvent struct {
|
||||
// ID is the provider block/index identifier for a tool call (e.g. Anthropic's
|
||||
// tool_use block id). Empty for text segments.
|
||||
ID string
|
||||
// SessionID is the conversation the streamed event belongs to. It is stamped
|
||||
// by the loop at the turn boundary (loop.streamStep), which reads the session
|
||||
// the Run was started for; a model adapter, running below the loop and with no
|
||||
// notion of a session, leaves it zero. A consumer that routes the event (the
|
||||
// WS hub) reads it to target the right conversation.
|
||||
SessionID string
|
||||
// TurnID is the assistant turn the event belongs to. Like SessionID it is
|
||||
// stamped by the loop, which reads the turn identity the caller attached with
|
||||
// loop.WithTurnID; it is zero when no turn id was attached, so a consumer must
|
||||
// treat both fields as best-effort correlation rather than a hard guarantee.
|
||||
TurnID string
|
||||
}
|
||||
@@ -948,8 +948,10 @@ func TestHubBroadcastStream(t *testing.T) {
|
||||
|
||||
// A text_delta StreamEvent on the OnStream→bus→relay path becomes a typed
|
||||
// message.delta frame carrying the delta text and a monotonically increasing
|
||||
// seq, leaving the legacy stream_chunk shape out of the way.
|
||||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0})
|
||||
// seq, leaving the legacy stream_chunk shape out of the way. The loop stamps
|
||||
// the session/turn identity on the event and the hub must carry it onto the
|
||||
// frame, so a client can route the delta to the active session (design/16 §4.2).
|
||||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0, SessionID: "web", TurnID: "turn-abc"})
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "message.delta" {
|
||||
t.Fatalf("frame type = %q, want message.delta (frame=%+v)", ev.Type, ev)
|
||||
@@ -961,6 +963,9 @@ func TestHubBroadcastStream(t *testing.T) {
|
||||
if ev.Seq != 1 {
|
||||
t.Fatalf("frame seq = %d, want 1", ev.Seq)
|
||||
}
|
||||
if ev.SessionID != "web" || ev.TurnID != "turn-abc" {
|
||||
t.Fatalf("frame scope = session %q turn %q, want session web turn turn-abc", ev.SessionID, ev.TurnID)
|
||||
}
|
||||
|
||||
// A tool_call event maps to tool_call.*; a non-StreamEvent payload is dropped.
|
||||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindToolCallEnd, ID: "toolu_1", Index: 0})
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"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"
|
||||
)
|
||||
|
||||
// scopeStreamAdapter is a model.Adapter that emits two text deltas so a test can
|
||||
// drive the real loop and observe the framed events it produces.
|
||||
type scopeStreamAdapter struct{}
|
||||
|
||||
func (scopeStreamAdapter) Name() string { return "scope-test" }
|
||||
|
||||
func (scopeStreamAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "Hel lo"}, nil
|
||||
}
|
||||
|
||||
func (scopeStreamAdapter) ChatStream(_ context.Context, _ model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel"})
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "lo"})
|
||||
}
|
||||
return model.Response{Content: "Hel lo"}, nil
|
||||
}
|
||||
|
||||
// newScopedStreamHub wires a hub to a real loop whose OnStream closure mirrors
|
||||
// cmd/cyrene's streamChunk (publish the whole StreamEvent value to ws.stream), so
|
||||
// a test exercises the production path OnStream → bus → relay → frame. It returns
|
||||
// the hub and a registered fake connection.
|
||||
func newScopedStreamHub(t *testing.T) (*hub, *wsConn) {
|
||||
t.Helper()
|
||||
b := bus.New()
|
||||
ag, err := agent.New("You are a helpful assistant.", nil, nil, agent.WithModel(scopeStreamAdapter{}, 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 := newFakeConn(hb, 64)
|
||||
hb.register(c)
|
||||
return hb, c
|
||||
}
|
||||
|
||||
// readDeltas scans the connection's frames until want message.delta frames have
|
||||
// arrived. The deltas reach the conn asynchronously through the bus while
|
||||
// runTurn's own stream_end is sent straight to the conn, so a test cannot assume
|
||||
// an order.
|
||||
func readDeltas(t *testing.T, c *wsConn, want int) []wsEvent {
|
||||
t.Helper()
|
||||
var deltas []wsEvent
|
||||
deadline := time.After(3 * time.Second)
|
||||
for len(deltas) < want {
|
||||
select {
|
||||
case raw := <-c.send:
|
||||
var ev wsEvent
|
||||
if err := json.Unmarshal(raw, &ev); err != nil {
|
||||
t.Fatalf("decode frame %s: %v", raw, err)
|
||||
}
|
||||
if ev.Type == "message.delta" {
|
||||
deltas = append(deltas, ev)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out with %d message.delta frames, want %d", len(deltas), want)
|
||||
}
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
// TestStreamFramesCarrySessionScope is the end-to-end lock for A1: a turn driven
|
||||
// through the hub's runTurn must surface message.delta frames that carry the
|
||||
// session and the turn id, so a client can route them to the active session. It
|
||||
// runs the real loop instead of calling broadcastStream directly, which also
|
||||
// proves the bus carries the new fields untouched — no publisher change needed.
|
||||
func TestStreamFramesCarrySessionScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb, c := newScopedStreamHub(t)
|
||||
|
||||
if _, err := hb.runTurn(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}, loop.OwnerEnv()); err != nil {
|
||||
t.Fatalf("runTurn: %v", err)
|
||||
}
|
||||
deltas := readDeltas(t, c, 2)
|
||||
|
||||
for i, ev := range deltas {
|
||||
if ev.SessionID != "web" {
|
||||
t.Fatalf("delta[%d] sessionId = %q, want web (frame=%+v)", i, ev.SessionID, ev)
|
||||
}
|
||||
if !strings.HasPrefix(ev.TurnID, "turn-") {
|
||||
t.Fatalf("delta[%d] turnId = %q, want a turn-* id (frame=%+v)", i, ev.TurnID, ev)
|
||||
}
|
||||
}
|
||||
if deltas[0].TurnID != deltas[1].TurnID {
|
||||
t.Fatalf("turnId differs within one turn: %q vs %q", deltas[0].TurnID, deltas[1].TurnID)
|
||||
}
|
||||
if deltas[0].Seq == 0 || deltas[1].Seq <= deltas[0].Seq {
|
||||
t.Fatalf("seq not increasing: %d then %d", deltas[0].Seq, deltas[1].Seq)
|
||||
}
|
||||
if data, ok := deltas[0].Data.(map[string]any); !ok || data["delta"] != "Hel" {
|
||||
t.Fatalf("first delta data = %+v, want delta=Hel", deltas[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamFramesTurnScopeChangesPerTurn covers the orchestrated resume shape: a
|
||||
// pending turn drained after the first one completes is a second runTurn call, so
|
||||
// it must take a fresh turnId. Without that, deltas from the resumed turn would
|
||||
// be indistinguishable from the previous turn's.
|
||||
func TestStreamFramesTurnScopeChangesPerTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb, c := newScopedStreamHub(t)
|
||||
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "hi")}
|
||||
if _, err := hb.runTurn(context.Background(), "web", msgs, loop.OwnerEnv()); err != nil {
|
||||
t.Fatalf("first runTurn: %v", err)
|
||||
}
|
||||
first := readDeltas(t, c, 2)
|
||||
if _, err := hb.runTurn(context.Background(), "web", msgs, loop.OwnerEnv()); err != nil {
|
||||
t.Fatalf("second runTurn: %v", err)
|
||||
}
|
||||
second := readDeltas(t, c, 2)
|
||||
|
||||
for i, ev := range second {
|
||||
if ev.SessionID != "web" {
|
||||
t.Fatalf("second turn delta[%d] sessionId = %q, want web", i, ev.SessionID)
|
||||
}
|
||||
if ev.TurnID != second[0].TurnID {
|
||||
t.Fatalf("second turn delta[%d] turnId = %q, want %q for the whole turn", i, ev.TurnID, second[0].TurnID)
|
||||
}
|
||||
}
|
||||
if first[0].TurnID == second[0].TurnID {
|
||||
t.Fatalf("turnId reused across turns: %q", first[0].TurnID)
|
||||
}
|
||||
if second[0].Seq <= first[1].Seq {
|
||||
t.Fatalf("seq not monotonic across turns: %d then %d", first[1].Seq, second[0].Seq)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,12 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
// emits a single whole-text chunk to hold the stream contract.
|
||||
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
turnID := newTurnID()
|
||||
// Thread the turn identity into the run context so the loop stamps it on every
|
||||
// streamed event it emits (message.*/thinking.*/tool_call.*); the hub then puts
|
||||
// it on the outbound frame. Every turn takes a fresh id — a session has at most
|
||||
// one in-flight turn — so a client can correlate one burst of deltas to one
|
||||
// assistant turn (design/16 §4.2).
|
||||
ctx = loop.WithTurnID(ctx, turnID)
|
||||
if h.runner == nil || len(msgs) == 0 {
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}})
|
||||
return &loop.Result{StopReason: "idle"}, nil
|
||||
@@ -117,16 +123,17 @@ func (h *hub) handleInterrupt(payload json.RawMessage) {
|
||||
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
|
||||
// type and fans it out to every client. It runs on the bus subscription worker;
|
||||
// seq is assigned atomically so a burst stays ordered even if the loop emits
|
||||
// events from more than one goroutine. TurnId is left empty here because the
|
||||
// OnStream bus payload does not carry the turn identity (future work would
|
||||
// thread a turn id through loop → streamChunk → payload).
|
||||
// events from more than one goroutine. SessionID/TurnID ride on the event itself
|
||||
// (the loop stamps them at the turn boundary from the run context), so the frame
|
||||
// carries the scope a client needs to route it to the active session and to one
|
||||
// assistant turn.
|
||||
func (h *hub) broadcastStream(payload any) {
|
||||
ev, ok := payload.(model.StreamEvent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
typ, data := streamEventData(ev)
|
||||
h.broadcast(wsEvent{Type: typ, Seq: int(h.streamSeq.Add(1)), Data: data})
|
||||
h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data})
|
||||
}
|
||||
|
||||
// chatStreamRequest is the decoded ws.chat payload.
|
||||
|
||||
Reference in new issue
Block a user