diff --git a/agent/acp/mapping.go b/agent/acp/mapping.go index 286a1288f..d1e078055 100644 --- a/agent/acp/mapping.go +++ b/agent/acp/mapping.go @@ -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"` diff --git a/agent/acp/mapping_test.go b/agent/acp/mapping_test.go index f2e476ea6..4c94fc7b9 100644 --- a/agent/acp/mapping_test.go +++ b/agent/acp/mapping_test.go @@ -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", diff --git a/agent/copilot/session.go b/agent/copilot/session.go index 2099c72c4..571796e7b 100644 --- a/agent/copilot/session.go +++ b/agent/copilot/session.go @@ -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 { @@ -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 } diff --git a/agent/copilot/session_test.go b/agent/copilot/session_test.go index 87187ec5b..10ed48b06 100644 --- a/agent/copilot/session_test.go +++ b/agent/copilot/session_test.go @@ -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()