Skip to content

Commit 8b85b46

Browse files
committed
feat(tui): mark sub-agent returns in the transcript
When a forwarded sub-agent (transfer_task / run_skill) handed control back to its caller, the only cue was the next parent badge, so a run of delegations read as one undifferentiated wall. Insert an explicit return marker — "↩ child → parent ───" in the child's accent color — when AgentSwitching(false, child, parent) fires, i.e. the deferred restore in runForwarding. By design only the EXIT is bracketed; the existing transfer_task card remains the entry. Background agents and permanent handoffs never emit this event, so they are unaffected. The marker is a static, cacheable MessageTypeDelegationReturn list item; it is not spinner-driven. Refs #3102
1 parent 8ed8de7 commit 8b85b46

7 files changed

Lines changed: 146 additions & 0 deletions

File tree

pkg/tui/components/message/message.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,13 @@ func (mv *messageModel) render(width int) string {
375375
return msg.Content
376376
case types.MessageTypeCancelled:
377377
return styles.WarningStyle.Render("⚠ stream cancelled ⚠")
378+
case types.MessageTypeDelegationReturn:
379+
label := "↩ " + msg.Sender + " → " + msg.Content
380+
styled := styles.AgentAccentStyleFor(msg.Sender).Render(label)
381+
// Trailing faded rule out to width, mirroring the collapsed-sidebar divider.
382+
ruleW := max(width-ansi.StringWidth(styled)-3, 0)
383+
rule := styles.FadingStyle.Render(" " + strings.Repeat("─", ruleW))
384+
return styles.NoStyle.MarginLeft(2).Render(styled + rule)
378385
case types.MessageTypeWelcome:
379386
messageStyle := styles.WelcomeMessageStyle
380387
// Convert explicit newlines to markdown hard line breaks (two trailing spaces)

pkg/tui/components/message/message_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,21 @@ func TestBareSpinnerKeepsPlayfulView(t *testing.T) {
324324
assert.True(t, mv.isSpinnerDriven())
325325
assert.Equal(t, mv.spinner.View(), mv.View(), "empty label must keep the default spinner rendering")
326326
}
327+
328+
// TestDelegationReturnMarkerRenders covers the transcript return marker inserted
329+
// when a forwarded sub-agent (child) hands control back to its caller (parent):
330+
// it renders "↩ child → parent" and, being static, is NOT spinner-driven so it
331+
// caches normally.
332+
func TestDelegationReturnMarkerRenders(t *testing.T) {
333+
t.Parallel()
334+
335+
// Sender holds the child (drives the accent color); Content holds the parent.
336+
msg := types.DelegationReturn("librarian", "root")
337+
mv := New(msg, nil)
338+
mv.SetSize(80, 0)
339+
340+
assert.False(t, mv.isSpinnerDriven(), "static return marker must be cacheable, not spinner-driven")
341+
342+
out := stripANSI(mv.View())
343+
assert.Contains(t, out, "↩ librarian → root", "marker should read ↩ child → parent")
344+
}

pkg/tui/components/messages/messages.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type Model interface {
5757
AddErrorMessage(content string) tea.Cmd
5858
AddAssistantMessage(sender, label string) tea.Cmd
5959
AddCancelledMessage() tea.Cmd
60+
AddDelegationReturn(child, parent string) tea.Cmd
6061
AddWelcomeMessage(content string) tea.Cmd
6162
AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status types.ToolStatus) tea.Cmd
6263
AppendToolOutput(msg *runtime.ToolCallOutputEvent) tea.Cmd
@@ -1025,6 +1026,9 @@ func (m *model) shouldCacheMessage(index int) bool {
10251026
return false
10261027
case types.MessageTypeUser:
10271028
return true
1029+
case types.MessageTypeDelegationReturn:
1030+
// Static divider line; safe to cache like a user message.
1031+
return true
10281032
default:
10291033
return false
10301034
}
@@ -1255,6 +1259,10 @@ func (m *model) AddCancelledMessage() tea.Cmd {
12551259
return view.Init()
12561260
}
12571261

1262+
func (m *model) AddDelegationReturn(child, parent string) tea.Cmd {
1263+
return m.addMessage(types.DelegationReturn(child, parent))
1264+
}
1265+
12581266
func (m *model) AddWelcomeMessage(content string) tea.Cmd {
12591267
if content == "" || len(m.views) > 0 {
12601268
return nil

pkg/tui/components/messages/messages_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ func TestViewDoesNotWrapWideLines(t *testing.T) {
3939
}
4040
}
4141

42+
// TestAddDelegationReturnAppendsMarker verifies AddDelegationReturn appends a
43+
// single MessageTypeDelegationReturn item (Sender=child, Content=parent) and
44+
// that it renders the "↩ child → parent" divider.
45+
func TestAddDelegationReturnAppendsMarker(t *testing.T) {
46+
t.Parallel()
47+
48+
sessionState := &service.SessionState{}
49+
m := NewScrollableView(80, 24, sessionState).(*model)
50+
m.SetSize(80, 24)
51+
52+
cmd := m.AddDelegationReturn("librarian", "root")
53+
require.NotNil(t, cmd, "appending the marker auto-scrolls a fresh transcript, yielding a cmd")
54+
55+
require.Len(t, m.messages, 1)
56+
assert.Equal(t, types.MessageTypeDelegationReturn, m.messages[0].Type)
57+
assert.Equal(t, "librarian", m.messages[0].Sender)
58+
assert.Equal(t, "root", m.messages[0].Content)
59+
60+
assert.Contains(t, ansi.Strip(m.View()), "↩ librarian → root")
61+
}
62+
4263
func TestMouseClickOnURLOpensURL(t *testing.T) {
4364
t.Parallel()
4465

pkg/tui/page/chat/runtime_events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) {
122122

123123
case *runtime.AgentSwitchingEvent:
124124
p.sidebar.SetAgentSwitching(msg.Switching)
125+
if !msg.Switching && msg.FromAgent != "" && msg.ToAgent != "" {
126+
// A forwarded sub-agent (FromAgent) just returned control to its
127+
// caller (ToAgent); bracket the exit with a transcript marker.
128+
return true, p.messages.AddDelegationReturn(msg.FromAgent, msg.ToAgent)
129+
}
125130
return true, nil
126131

127132
case *runtime.ToolsetInfoEvent:
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package chat
2+
3+
import (
4+
"testing"
5+
6+
"github.com/charmbracelet/x/ansi"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/runtime"
11+
"github.com/docker/docker-agent/pkg/tui/components/messages"
12+
"github.com/docker/docker-agent/pkg/tui/components/sidebar"
13+
"github.com/docker/docker-agent/pkg/tui/service"
14+
)
15+
16+
func newDelegationMarkerTestPage() *chatPage {
17+
sessionState := &service.SessionState{}
18+
msgs := messages.NewScrollableView(80, 24, sessionState)
19+
msgs.SetSize(80, 24)
20+
return &chatPage{
21+
sidebar: sidebar.New(sessionState),
22+
messages: msgs,
23+
sessionState: sessionState,
24+
}
25+
}
26+
27+
// TestAgentSwitchingInsertsReturnMarker covers the Phase 2 design decision to
28+
// bracket only the EXIT of a forwarded sub-agent. AgentSwitching(false, child,
29+
// parent) — emitted when a transfer_task/run_skill sub-session returns — inserts
30+
// a "↩ child → parent" marker, while the entry event AgentSwitching(true, ...)
31+
// inserts nothing (the transfer_task card is the visible entry).
32+
func TestAgentSwitchingInsertsReturnMarker(t *testing.T) {
33+
t.Parallel()
34+
35+
t.Run("return (Switching=false) inserts a marker", func(t *testing.T) {
36+
t.Parallel()
37+
p := newDelegationMarkerTestPage()
38+
39+
handled, cmd := p.handleRuntimeEvent(&runtime.AgentSwitchingEvent{
40+
Switching: false,
41+
FromAgent: "librarian",
42+
ToAgent: "root",
43+
})
44+
45+
require.True(t, handled)
46+
require.NotNil(t, cmd, "a returning sub-agent should insert a marker")
47+
assert.Contains(t, ansi.Strip(p.messages.View()), "↩ librarian → root")
48+
})
49+
50+
t.Run("entry (Switching=true) inserts nothing", func(t *testing.T) {
51+
t.Parallel()
52+
p := newDelegationMarkerTestPage()
53+
54+
handled, cmd := p.handleRuntimeEvent(&runtime.AgentSwitchingEvent{
55+
Switching: true,
56+
FromAgent: "root",
57+
ToAgent: "librarian",
58+
})
59+
60+
require.True(t, handled)
61+
assert.Nil(t, cmd, "entering a delegation must not insert a return marker")
62+
assert.NotContains(t, ansi.Strip(p.messages.View()), "↩")
63+
})
64+
65+
t.Run("missing agent names insert nothing", func(t *testing.T) {
66+
t.Parallel()
67+
p := newDelegationMarkerTestPage()
68+
69+
handled, cmd := p.handleRuntimeEvent(&runtime.AgentSwitchingEvent{
70+
Switching: false,
71+
FromAgent: "",
72+
ToAgent: "root",
73+
})
74+
75+
require.True(t, handled)
76+
assert.Nil(t, cmd)
77+
assert.NotContains(t, ansi.Strip(p.messages.View()), "↩")
78+
})
79+
}

pkg/tui/types/types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
MessageTypeToolResult
2323
MessageTypeWelcome
2424
MessageTypeLoading
25+
MessageTypeDelegationReturn // "↩ child → parent" divider when a sub-agent returns
2526
)
2627

2728
const (
@@ -110,6 +111,13 @@ func Cancelled() *Message {
110111
}
111112
}
112113

114+
// DelegationReturn marks control returning from a sub-agent (child) to its
115+
// caller (parent). Sender holds the child (drives the accent color); Content
116+
// holds the parent name.
117+
func DelegationReturn(child, parent string) *Message {
118+
return &Message{Type: MessageTypeDelegationReturn, Sender: child, Content: parent}
119+
}
120+
113121
func Welcome(content string) *Message {
114122
return &Message{
115123
Type: MessageTypeWelcome,

0 commit comments

Comments
 (0)