From 98f2a37c855ab3a2f884d0d79ea8aec38c05bd65 Mon Sep 17 00:00:00 2001 From: zhengweixing <529459515@qq.com> Date: Wed, 8 Jul 2026 11:44:57 +0800 Subject: [PATCH 1/2] fix(agent/copilot): map reasoning deltas to EventThinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CLI streams thinking/reasoning content as `assistant.reasoning_delta` (streaming, with `deltaContent`) and `assistant.reasoning` (final, with `content`). The copilot adapter had no case for either, so these events fell through to the default handler and never emitted an event — meaning reasoning could be misrouted or lost, and `thinking_messages=false` could not suppress it because nothing was tagged as thinking. Captured the real wire format by probing `copilot --stdio`: reasoning deltas carry `{"reasoningId":"...","deltaContent":"..."}`, mirroring the message delta shape, so they reuse `copilotEventText`. Changes: - handleSessionEvent: add `assistant.reasoning_delta` and `assistant.reasoning` cases, emitting `core.EventThinking`. - normalizeCopilotEventType: normalize `assistant_reasoning` / `assistant_reasoning_delta` underscore variants. - Add tests for reasoning delta and final reasoning -> EventThinking. With this, `thinking_messages=false` correctly suppresses Copilot reasoning and it no longer mixes into the answer text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/copilot/session.go | 27 +++++++++++++++++ agent/copilot/session_test.go | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) 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() From b59530068b1eaad05dc031216970796f46a1972d Mon Sep 17 00:00:00 2001 From: zhengweixing <529459515@qq.com> Date: Thu, 9 Jul 2026 13:21:15 +0800 Subject: [PATCH 2/2] fix(agent/acp): map agent_thought_chunk to EventThinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When cc-connect bridges Copilot CLI through the generic ACP adapter ([projects.agent] type = "acp"), Copilot streams its reasoning/thinking content as session/update frames whose sessionUpdate discriminator is `agent_thought_chunk` (captured live via ACP probing against `copilot --acp --stdio`; payload is '{"content":{"type":"text","text":"..."}}', e.g. "The user needs to check the health status of their PaaS microservices..."). The reasoning whitelist in mapSessionUpdateFallback only listed "reasoning", "reasoning_chunk", "thinking", "agent_thinking_chunk" — note `agent_thinking_chunk` (gerund) vs the actually-emitted `agent_thought_chunk` (past tense). So `agent_thought_chunk` missed the case and fell into the default branch, which extracts content.text and emits it as EventText. Because it was never tagged EventThinking, `thinking_messages=false` could not suppress it, and the reasoning text leaked into the chat reply. This is the ACP-adapter counterpart to the copilot-adapter fix in #1512. The earlier PR only patched agent/copilot/session.go (assistant.reasoning_delta -> EventThinking), which is unused when type = "acp"; the production paas-ops project uses the generic ACP path, so reasoning still leaked there. Changes: - mapSessionUpdateFallback: add `agent_thought_chunk` to the reasoning discriminator whitelist so it maps to core.EventThinking. - Add TestMapSessionUpdate_agentThoughtChunk regression test asserting `agent_thought_chunk` with content.text -> core.EventThinking. With this, `thinking_messages=false` (e.g. CC_CONNECT_DISPLAY_THINKING_MESSAGES=false) correctly suppresses Copilot reasoning over the ACP path and it no longer appears inline in the reply. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/acp/mapping.go | 2 +- agent/acp/mapping_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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",