Skip to content
Open
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
2 changes: 1 addition & 1 deletion agent/acp/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func extractToolCallContentText(blocks []struct {
func mapSessionUpdateFallback(sessionID string, kind string, update json.RawMessage) []core.Event {
// Some agents may send reasoning as a dedicated discriminator; map to EventThinking.
switch strings.ToLower(kind) {
case "reasoning", "reasoning_chunk", "thinking", "agent_thinking_chunk":
case "reasoning", "reasoning_chunk", "thinking", "agent_thinking_chunk", "agent_thought_chunk":
var u struct {
Content struct {
Type string `json:"type"`
Expand Down
17 changes: 17 additions & 0 deletions agent/acp/mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,23 @@ func TestMapSessionUpdate_reasoningChunk(t *testing.T) {
}
}

// TestMapSessionUpdate_agentThoughtChunk covers the discriminator Copilot CLI uses
// for reasoning/thinking content in ACP mode. Without this case the chunk would
// hit mapSessionUpdateFallback's default branch and leak as EventText.
func TestMapSessionUpdate_agentThoughtChunk(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": {"type": "text", "text": "The user needs to check health"}
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 || evs[0].Type != core.EventThinking || evs[0].Content != "The user needs to check health" {
t.Fatalf("got %+v", evs)
}
}

func TestMapSessionUpdate_toolCall(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
Expand Down
27 changes: 27 additions & 0 deletions agent/copilot/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ func (cs *copilotSession) handleSessionEvent(params json.RawMessage) {
}
}

case "assistant.reasoning_delta":
// Streaming reasoning/thinking chunk. Copilot sends these with the
// same deltaContent shape as message deltas, but they must be mapped
// to EventThinking (not EventText) so thinking_messages=false can
// suppress them and they don't get concatenated with the answer.
if content := copilotEventText(evt.Event.Data); content != "" {
e := core.Event{Type: core.EventThinking, Content: content}
select {
case cs.events <- e:
case <-cs.ctx.Done():
}
}

case "assistant.reasoning":
// Final aggregated reasoning. Same mapping as the delta stream.
if content := copilotEventText(evt.Event.Data); content != "" {
e := core.Event{Type: core.EventThinking, Content: content}
select {
case cs.events <- e:
case <-cs.ctx.Done():
}
}

case "assistant.message":
usage := copilotEventUsage(evt.Event.Data)
if len(evt.Event.Data) > 0 {
Expand Down Expand Up @@ -494,6 +517,10 @@ func normalizeCopilotEventType(eventType string) string {
return "assistant.message_delta"
case "assistant_streaming_delta":
return "assistant.streaming_delta"
case "assistant_reasoning":
return "assistant.reasoning"
case "assistant_reasoning_delta":
return "assistant.reasoning_delta"
}
return eventType
}
Expand Down
55 changes: 55 additions & 0 deletions agent/copilot/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,61 @@ func TestHandleSessionEvent_MessageDelta(t *testing.T) {
}
}

func TestHandleSessionEvent_ReasoningDelta(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{events: make(chan core.Event, 10), ctx: ctx, cancel: cancel}
cs.alive.Store(true)

data, _ := json.Marshal(map[string]any{
"reasoningId": "r-1",
"deltaContent": "let me think",
})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
Event: sessionEventInner{Type: "assistant.reasoning_delta", Data: data},
})))

select {
case evt := <-cs.events:
if evt.Type != core.EventThinking {
t.Fatalf("event type = %v, want EventThinking", evt.Type)
}
if evt.Content != "let me think" {
t.Fatalf("content = %q, want 'let me think'", evt.Content)
}
default:
t.Fatal("no thinking event emitted for reasoning_delta")
}
}

func TestHandleSessionEvent_ReasoningFinal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{events: make(chan core.Event, 10), ctx: ctx, cancel: cancel}
cs.alive.Store(true)

data, _ := json.Marshal(map[string]any{
"reasoningId": "r-1",
"content": "final reasoning",
})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
Event: sessionEventInner{Kind: "assistant_reasoning", Data: data},
})))

select {
case evt := <-cs.events:
if evt.Type != core.EventThinking {
t.Fatalf("event type = %v, want EventThinking", evt.Type)
}
if evt.Content != "final reasoning" {
t.Fatalf("content = %q, want 'final reasoning'", evt.Content)
}
default:
t.Fatal("no thinking event emitted for reasoning final")
}
}


func TestHandleSessionEvent_KindAndCurrentCopilotEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down
Loading