From 34a1540c18a4985ebb79a728e8d806ebe4578a6d Mon Sep 17 00:00:00 2001 From: yatul Date: Tue, 25 Aug 2026 22:03:41 +0400 Subject: [PATCH 1/2] fix(teams): stream teammate runs so long generations keep the connection alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleTeammateMessage pinned Stream: false, so every team member run — coder, reviewer, researcher — made a non-streamed provider call. On a slow reasoning model that means a silent connection for the whole generation, and ResponseHeaderTimeout eventually kills it: iter 0 think: llm call: litellm: request failed: Post ".../v1/chat/completions": http2: timeout awaiting response headers Observed on a member asked to write a large single-file page: 15 minutes, zero output tokens, run failed. The same model over the same provider is fine on an ordinary channel run, which streams by default. Streaming here is for connection liveness, not delivery. A teammate run is never registered with the channel manager, so HandleAgentEvent returns on its first line and the chunks are dropped; nothing reaches a user incrementally. The task result is unchanged — it still comes from the final RunResult, since ChatStream returns the complete response once the stream ends. Co-Authored-By: Claude Opus 5 --- cmd/gateway_consumer_handlers.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index cb80c848..b68a5f1d 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -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], From e7e3f25eeb06986a918a0dc969d33e43bcc4ffc0 Mon Sep 17 00:00:00 2001 From: yatul Date: Wed, 26 Aug 2026 16:09:12 +0400 Subject: [PATCH 2/2] test(teams): pin the teammate streaming contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #1533 asked for coverage of the behavioural contract rather than the field assignment, and that is the right call — the PR text arguing a test would only restate the assignment was wrong, because the contract has two halves that a refactor could break independently. The test drives handleTeammateMessage through a real scheduler and asserts both: the scheduled run requests streaming, and the run is not registered with the channel manager, so its chunks cannot be routed to a user. It fails with Stream reverted to false. The remaining half — a streamed call still yielding the complete final response — is already covered in internal/agent/loop_pipeline_callbacks_test.go. Co-Authored-By: Claude Opus 5 --- cmd/gateway_consumer_teammate_stream_test.go | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 cmd/gateway_consumer_teammate_stream_test.go diff --git a/cmd/gateway_consumer_teammate_stream_test.go b/cmd/gateway_consumer_teammate_stream_test.go new file mode 100644 index 00000000..d35e8809 --- /dev/null +++ b/cmd/gateway_consumer_teammate_stream_test.go @@ -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) + } +}