Merge pull request #1533 from yatul/fix/teammate-run-streaming

fix(teams): stream teammate runs so long generations keep the connection alive
This commit is contained in:
Clark Cant authored and GitHub committed 2026-09-01 06:41:54 +07:00
commit 8331211abf
2 files changed
+94 -1

No files matched your search

+11 -1
View File
@@ -276,7 +276,17 @@ func handleTeammateMessage(
SenderID: teammateSenderID, // real user who triggered the teammate dispatch (#915)
Role: teammateRole, // RBAC role for admin bypass during teammate turn (#915)
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata[tools.MetaFromAgent], msg.Metadata[tools.MetaToAgent]),
Stream: false,
// Streamed for connection liveness, not for delivery. A teammate run is
// never registered with the channel manager, so HandleAgentEvent drops its
// chunks on the first line and nothing is delivered incrementally; the task
// result still comes from the final RunResult. What streaming buys is the
// response headers arriving immediately: a non-streamed request to a slow
// reasoning model holds a silent connection for the whole generation, and
// ResponseHeaderTimeout kills it — observed as
// `http2: timeout awaiting response headers` on a member asked to produce a
// large file, while the same model over the same provider was fine on a
// streamed channel run.
Stream: true,
TeamTaskID: msg.Metadata[tools.MetaTeamTaskID],
TeamWorkspace: msg.Metadata[tools.MetaTeamWorkspace],
LeaderAgentID: msg.Metadata[tools.MetaLeaderAgentID],
@@ -0,0 +1,83 @@
package cmd
import (
"context"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// A teammate run must ask the provider to stream. Without it a slow reasoning
// model holds a silent connection for the whole generation and
// ResponseHeaderTimeout kills the request before a single token is produced.
//
// The same test pins the other half of the contract: streaming here is for
// connection liveness only. The run is deliberately never registered with the
// channel manager, so HandleAgentEvent drops its chunks and nothing reaches a
// user incrementally — the task result keeps coming from the final RunResult.
func TestHandleTeammateMessageSchedulesStreamedRun(t *testing.T) {
var gotReq agent.RunRequest
ran := make(chan struct{})
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
MaxConcurrent: 1,
},
func(_ context.Context, req agent.RunRequest) (*agent.RunResult, error) {
gotReq = req
close(ran)
return &agent.RunResult{Content: "member deliverable"}, nil
},
)
defer sched.Stop()
channelMgr := channels.NewManager(nil)
deps := &ConsumerDeps{
Cfg: &config.Config{},
Sched: sched,
ChannelMgr: channelMgr,
}
msg := bus.InboundMessage{
Channel: tools.ChannelSystem,
SenderID: "teammate:dashboard",
AgentID: "coder",
Content: "[Assigned task #1 (id: 00000000-0000-0000-0000-000000000001)]: build something",
Metadata: map[string]string{
tools.MetaOriginChannel: "telegram",
tools.MetaOriginChatID: "12345",
tools.MetaFromAgent: "brain",
tools.MetaToAgent: "coder",
},
}
if !handleTeammateMessage(context.Background(), msg, deps) {
t.Fatal("handleTeammateMessage() = false, want true for a teammate: message on the system channel")
}
select {
case <-ran:
case <-time.After(5 * time.Second):
t.Fatal("teammate run was never scheduled")
}
if !gotReq.Stream {
t.Error("teammate run requested a non-streamed provider call: a slow model then holds a silent " +
"connection for the whole generation until ResponseHeaderTimeout kills it")
}
if delivered, last := channelMgr.InterimDeliverySnapshot(gotReq.RunID); delivered != 0 || last != "" {
t.Errorf("teammate run is registered for channel delivery (delivered=%d, last=%q); "+
"streamed chunks would reach a user incrementally", delivered, last)
}
}