Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pkg/board/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type client struct {
// unix socket and targets the given session id.
func newClient(socket, session string) *client {
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
Expand Down Expand Up @@ -149,8 +150,7 @@ func (c *client) Followup(ctx context.Context, idempotencyKey, message string) e
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("followup: %s", resp.Status)
}
// Drain so the connection can be reused; the body only reports whether
// the delivery was a duplicate, which the board does not care about.
// Drain the small response body before closing it.
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
Expand Down Expand Up @@ -201,6 +201,7 @@ func (c *client) StreamEvents(ctx context.Context, since uint64, onEvent func(ev
var seq uint64
for scanner.Scan() {
line := scanner.Text()
resetWatchdog()
if strings.HasPrefix(line, ":") {
// Heartbeat comment: the server sends them, so silence now means
// a hung transport. Arm the watchdog on the first one.
Expand All @@ -212,7 +213,6 @@ func (c *client) StreamEvents(ctx context.Context, since uint64, onEvent func(ev
}
continue
}
resetWatchdog()
if id, ok := strings.CutPrefix(line, "id:"); ok {
seq, _ = strconv.ParseUint(strings.TrimSpace(id), 10, 64)
continue
Expand Down
25 changes: 25 additions & 0 deletions pkg/board/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@ func TestStreamEventsIdleWatchdogAbortsSilentStream(t *testing.T) {
assert.Equal(t, eventStreamStarted, got[0].Type)
}

func TestStreamEventsHeartbeatLinesResetWatchdog(t *testing.T) {
old := streamIdleTimeout
streamIdleTimeout = 250 * time.Millisecond
t.Cleanup(func() { streamIdleTimeout = old })

socket := serveUnix(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f := w.(http.Flusher)
for range 6 {
fmt.Fprint(w, ": ping\n")
f.Flush()
time.Sleep(50 * time.Millisecond) //nolint:forbidigo // heartbeat cadence is under test
}
fmt.Fprint(w, "data: {\"type\":\"stream_stopped\"}\n\n")
f.Flush()
<-r.Context().Done()
}))

c := newClient(socket, "sess-1")
err := c.StreamEvents(t.Context(), 0, func(ev event) bool {
assert.Equal(t, eventStreamStopped, ev.Type)
return false
})
require.NoError(t, err)
}

// Without any heartbeat from the server (an older docker-agent), the watchdog
// stays unarmed: a quiet stream is left alone and events keep flowing.
func TestStreamEventsNoHeartbeatNoWatchdog(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions pkg/board/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -480,6 +481,10 @@ func TestSplitTitle(t *testing.T) {
l1, l2 = splitTitle("A somewhat longer card title", 10)
assert.Equal(t, "A somewhat", l1)
assert.NotEmpty(t, l2)

l1, l2 = splitTitle(strings.Repeat("x", 30)+" tail", 10)
assert.LessOrEqual(t, lipgloss.Width(l1), 10)
assert.LessOrEqual(t, lipgloss.Width(l2), 10)
}

func TestColorizeDiffKeepsLineCount(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/board/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ func splitTitle(title string, width int) (string, string) {
candidate = line1 + " " + word
}
if line1 != "" && lipgloss.Width(candidate) > width {
return line1, toolcommon.TruncateText(strings.Join(words[i:], " "), width)
return toolcommon.TruncateText(line1, width), toolcommon.TruncateText(strings.Join(words[i:], " "), width)
}
line1 = candidate
}
Expand Down
7 changes: 6 additions & 1 deletion pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,8 +624,13 @@ func (s *Server) sessionEvents(c echo.Context) error {
var writeMu sync.Mutex

heartbeatCtx, stopHeartbeat := context.WithCancel(c.Request().Context())
defer stopHeartbeat()
heartbeatDone := make(chan struct{})
defer func() {
stopHeartbeat()
<-heartbeatDone
}()
go func() {
defer close(heartbeatDone)
ticker := time.NewTicker(s.heartbeatInterval)
defer ticker.Stop()
for {
Expand Down
2 changes: 1 addition & 1 deletion pkg/tools/mcp/session_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ func (c *sessionClient) handleSamplingWithToolsRequest(ctx context.Context, req
// requests carrying a tools array from the MCP server.
func (c *sessionClient) SetSamplingWithToolsHandler(handler tools.SamplingWithToolsHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.samplingWithToolsHandler = handler
c.mu.Unlock()
}

// applySamplingHandlerOpts wires the SDK CreateMessage* callback into opts.
Expand Down
Loading