Skip to content

Commit c3a32f0

Browse files
committed
fix(acp): improve vendor compatibility for session/update parsing
Add support for alternative JSON formats in ACP session/update messages to handle different vendor implementations (OpenClaw, OpenCode, etc.). Changes: - Add debug logging to capture raw session/update JSON for troubleshooting - Support multiple text field formats in mapAgentMessageChunk: - Standard ACP: content.text with type field - Alternative: content.text without type - Alternative: top-level text field - Alternative: content as string - Enhance fallback text extraction for unknown sessionUpdate types - Add tests for all alternative formats Fixes #432 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6cd7fae commit c3a32f0

3 files changed

Lines changed: 239 additions & 9 deletions

File tree

agent/acp/mapping.go

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package acp
22

33
import (
44
"encoding/json"
5+
"log/slog"
56
"strings"
67

78
"github.com/chenhg5/cc-connect/core"
@@ -14,6 +15,7 @@ func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event {
1415
Update json.RawMessage `json:"update"`
1516
}
1617
if err := json.Unmarshal(params, &wrap); err != nil || len(wrap.Update) == 0 {
18+
slog.Debug("acp: mapSessionUpdate: failed to parse wrap", "error", err, "params", string(params))
1719
return nil
1820
}
1921
sid := wrap.SessionID
@@ -25,6 +27,7 @@ func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event {
2527
SessionUpdate string `json:"sessionUpdate"`
2628
}
2729
if err := json.Unmarshal(wrap.Update, &head); err != nil {
30+
slog.Debug("acp: mapSessionUpdate: failed to parse head", "error", err, "update", string(wrap.Update))
2831
return nil
2932
}
3033

@@ -46,24 +49,69 @@ func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event {
4649
}
4750
}
4851

52+
// mapAgentMessageChunk handles agent_message_chunk and user_message_chunk updates.
53+
// It supports multiple JSON formats for broader vendor compatibility:
54+
// - Standard ACP: {"content": {"type": "text", "text": "..."}}
55+
// - Alternative: {"content": {"text": "..."}} (type omitted)
56+
// - Alternative: {"text": "..."} (top-level text field)
57+
// - Alternative: {"content": "..."} (content as string)
4958
func mapAgentMessageChunk(sessionID string, update json.RawMessage) []core.Event {
59+
// Try standard ACP format first
5060
var u struct {
5161
Content struct {
5262
Type string `json:"type"`
5363
Text string `json:"text"`
5464
} `json:"content"`
5565
}
56-
if err := json.Unmarshal(update, &u); err != nil {
57-
return nil
66+
if err := json.Unmarshal(update, &u); err == nil && u.Content.Text != "" {
67+
return []core.Event{{
68+
Type: core.EventText,
69+
Content: u.Content.Text,
70+
SessionID: sessionID,
71+
}}
5872
}
59-
if u.Content.Text == "" {
60-
return nil
73+
74+
// Try alternative format: content.text without type field
75+
var alt1 struct {
76+
Content struct {
77+
Text string `json:"text"`
78+
} `json:"content"`
6179
}
62-
return []core.Event{{
63-
Type: core.EventText,
64-
Content: u.Content.Text,
65-
SessionID: sessionID,
66-
}}
80+
if err := json.Unmarshal(update, &alt1); err == nil && alt1.Content.Text != "" {
81+
return []core.Event{{
82+
Type: core.EventText,
83+
Content: alt1.Content.Text,
84+
SessionID: sessionID,
85+
}}
86+
}
87+
88+
// Try alternative format: top-level text field
89+
var alt2 struct {
90+
Text string `json:"text"`
91+
}
92+
if err := json.Unmarshal(update, &alt2); err == nil && alt2.Text != "" {
93+
return []core.Event{{
94+
Type: core.EventText,
95+
Content: alt2.Text,
96+
SessionID: sessionID,
97+
}}
98+
}
99+
100+
// Try alternative format: content as string
101+
var alt3 struct {
102+
Content string `json:"content"`
103+
}
104+
if err := json.Unmarshal(update, &alt3); err == nil && alt3.Content != "" {
105+
return []core.Event{{
106+
Type: core.EventText,
107+
Content: alt3.Content,
108+
SessionID: sessionID,
109+
}}
110+
}
111+
112+
// Log unknown format for debugging
113+
slog.Debug("acp: mapAgentMessageChunk: unknown format", "update", string(update))
114+
return nil
67115
}
68116

69117
func mapToolCall(sessionID string, update json.RawMessage) []core.Event {
@@ -202,7 +250,62 @@ func mapSessionUpdateFallback(sessionID string, kind string, update json.RawMess
202250
Content: t,
203251
SessionID: sessionID,
204252
}}
253+
case "message", "message_chunk", "text", "response":
254+
// Common vendor extensions for text output
255+
var u struct {
256+
Content struct {
257+
Text string `json:"text"`
258+
} `json:"content"`
259+
Text string `json:"text"`
260+
Message string `json:"message"`
261+
}
262+
if json.Unmarshal(update, &u) != nil {
263+
return nil
264+
}
265+
t := u.Content.Text
266+
if t == "" {
267+
t = u.Text
268+
}
269+
if t == "" {
270+
t = u.Message
271+
}
272+
if t == "" {
273+
return nil
274+
}
275+
return []core.Event{{
276+
Type: core.EventText,
277+
Content: t,
278+
SessionID: sessionID,
279+
}}
280+
default:
281+
// Last resort: try to extract any text-like field from the JSON
282+
var generic struct {
283+
Content struct {
284+
Text string `json:"text"`
285+
} `json:"content"`
286+
Text string `json:"text"`
287+
Message string `json:"message"`
288+
}
289+
if err := json.Unmarshal(update, &generic); err != nil {
290+
return nil
291+
}
292+
t := generic.Content.Text
293+
if t == "" {
294+
t = generic.Text
295+
}
296+
if t == "" {
297+
t = generic.Message
298+
}
299+
if t != "" {
300+
slog.Debug("acp: mapSessionUpdateFallback: extracted text from unknown format", "kind", kind, "text_len", len(t))
301+
return []core.Event{{
302+
Type: core.EventText,
303+
Content: t,
304+
SessionID: sessionID,
305+
}}
306+
}
205307
}
308+
slog.Debug("acp: mapSessionUpdateFallback: unrecognized format", "kind", kind, "update", string(update))
206309
return nil
207310
}
208311

agent/acp/mapping_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,131 @@ func TestMapSessionUpdate_agentMessageChunk(t *testing.T) {
2121
}
2222
}
2323

24+
func TestMapSessionUpdate_agentMessageChunk_AlternativeFormats(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
params string
28+
want string
29+
}{
30+
{
31+
name: "standard format with type",
32+
params: `{
33+
"sessionId": "s1",
34+
"update": {
35+
"sessionUpdate": "agent_message_chunk",
36+
"content": {"type": "text", "text": "hello world"}
37+
}
38+
}`,
39+
want: "hello world",
40+
},
41+
{
42+
name: "content.text without type",
43+
params: `{
44+
"sessionId": "s1",
45+
"update": {
46+
"sessionUpdate": "agent_message_chunk",
47+
"content": {"text": "alt format 1"}
48+
}
49+
}`,
50+
want: "alt format 1",
51+
},
52+
{
53+
name: "top-level text field",
54+
params: `{
55+
"sessionId": "s1",
56+
"update": {
57+
"sessionUpdate": "agent_message_chunk",
58+
"text": "alt format 2"
59+
}
60+
}`,
61+
want: "alt format 2",
62+
},
63+
{
64+
name: "content as string",
65+
params: `{
66+
"sessionId": "s1",
67+
"update": {
68+
"sessionUpdate": "agent_message_chunk",
69+
"content": "alt format 3"
70+
}
71+
}`,
72+
want: "alt format 3",
73+
},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
evs := mapSessionUpdate("", json.RawMessage(tt.params))
79+
if len(evs) != 1 {
80+
t.Fatalf("expected 1 event, got %d", len(evs))
81+
}
82+
if evs[0].Type != core.EventText {
83+
t.Fatalf("expected EventText, got %v", evs[0].Type)
84+
}
85+
if evs[0].Content != tt.want {
86+
t.Fatalf("content = %q, want %q", evs[0].Content, tt.want)
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestMapSessionUpdate_FallbackTextExtraction(t *testing.T) {
93+
tests := []struct {
94+
name string
95+
params string
96+
want string
97+
}{
98+
{
99+
name: "message_chunk vendor extension",
100+
params: `{
101+
"sessionId": "s1",
102+
"update": {
103+
"sessionUpdate": "message_chunk",
104+
"text": "vendor text"
105+
}
106+
}`,
107+
want: "vendor text",
108+
},
109+
{
110+
name: "response vendor extension",
111+
params: `{
112+
"sessionId": "s1",
113+
"update": {
114+
"sessionUpdate": "response",
115+
"message": "vendor message"
116+
}
117+
}`,
118+
want: "vendor message",
119+
},
120+
{
121+
name: "unknown kind with content.text",
122+
params: `{
123+
"sessionId": "s1",
124+
"update": {
125+
"sessionUpdate": "custom_output",
126+
"content": {"text": "fallback text"}
127+
}
128+
}`,
129+
want: "fallback text",
130+
},
131+
}
132+
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
evs := mapSessionUpdate("", json.RawMessage(tt.params))
136+
if len(evs) != 1 {
137+
t.Fatalf("expected 1 event, got %d", len(evs))
138+
}
139+
if evs[0].Type != core.EventText {
140+
t.Fatalf("expected EventText, got %v", evs[0].Type)
141+
}
142+
if evs[0].Content != tt.want {
143+
t.Fatalf("content = %q, want %q", evs[0].Content, tt.want)
144+
}
145+
})
146+
}
147+
}
148+
24149
func TestMapSessionUpdate_toolCallUpdate_inProgress(t *testing.T) {
25150
params := json.RawMessage(`{
26151
"sessionId": "s1",

agent/acp/session.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ func (s *acpSession) onNotification(method string, params json.RawMessage) {
218218
return
219219
}
220220
sid := s.currentACPSessionID()
221+
// Debug log to capture raw session/update JSON for troubleshooting vendor compatibility
222+
slog.Debug("acp: session/update", "session_id", sid, "params", string(params))
221223
for _, ev := range mapSessionUpdate(sid, params) {
222224
s.emit(ev)
223225
}

0 commit comments

Comments
 (0)