312 lines
7.3 KiB
Go
312 lines
7.3 KiB
Go
package acp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
// mapSessionUpdate turns one ACP session/update payload into zero or more core events.
|
|
func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event {
|
|
var wrap struct {
|
|
SessionID string `json:"sessionId"`
|
|
Update json.RawMessage `json:"update"`
|
|
}
|
|
if err := json.Unmarshal(params, &wrap); err != nil || len(wrap.Update) == 0 {
|
|
return nil
|
|
}
|
|
sid := wrap.SessionID
|
|
if sid == "" {
|
|
sid = sessionID
|
|
}
|
|
|
|
var head struct {
|
|
SessionUpdate string `json:"sessionUpdate"`
|
|
}
|
|
if err := json.Unmarshal(wrap.Update, &head); err != nil {
|
|
return nil
|
|
}
|
|
|
|
switch head.SessionUpdate {
|
|
case "agent_message_chunk":
|
|
return mapAgentMessageChunk(sid, wrap.Update)
|
|
case "tool_call":
|
|
return mapToolCall(sid, wrap.Update)
|
|
case "tool_call_update":
|
|
return mapToolCallUpdate(sid, wrap.Update)
|
|
case "plan":
|
|
return mapPlan(sid, wrap.Update)
|
|
case "user_message_chunk":
|
|
// History replay during session/load — suppress to avoid echoing user input.
|
|
return nil
|
|
default:
|
|
// Optional vendor / future ACP shapes — best-effort text extraction.
|
|
return mapSessionUpdateFallback(sid, head.SessionUpdate, wrap.Update)
|
|
}
|
|
}
|
|
|
|
func mapAgentMessageChunk(sessionID string, update json.RawMessage) []core.Event {
|
|
var u struct {
|
|
Content struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(update, &u); err != nil {
|
|
return nil
|
|
}
|
|
if u.Content.Text == "" {
|
|
return nil
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventText,
|
|
Content: u.Content.Text,
|
|
SessionID: sessionID,
|
|
}}
|
|
}
|
|
|
|
func mapToolCall(sessionID string, update json.RawMessage) []core.Event {
|
|
var u struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
Title string `json:"title"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
RawInput json.RawMessage `json:"rawInput"`
|
|
}
|
|
if err := json.Unmarshal(update, &u); err != nil {
|
|
return nil
|
|
}
|
|
toolName := u.Title
|
|
if toolName == "" {
|
|
toolName = u.Kind
|
|
}
|
|
if toolName == "" {
|
|
toolName = "tool"
|
|
}
|
|
toolInput := summarizeACPToolInput(u.Kind, u.RawInput)
|
|
if toolInput == "" {
|
|
toolInput = u.Title
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventToolUse,
|
|
ToolName: toolName,
|
|
ToolInput: toolInput,
|
|
SessionID: sessionID,
|
|
}}
|
|
}
|
|
|
|
func mapToolCallUpdate(sessionID string, update json.RawMessage) []core.Event {
|
|
var u struct {
|
|
Title string `json:"title"`
|
|
ToolCallID string `json:"toolCallId"`
|
|
Status string `json:"status"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Content struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(update, &u); err != nil {
|
|
return nil
|
|
}
|
|
toolLabel := u.Title
|
|
if toolLabel == "" {
|
|
toolLabel = u.ToolCallID
|
|
}
|
|
if toolLabel == "" {
|
|
toolLabel = "tool"
|
|
}
|
|
body := extractToolCallContentText(u.Content)
|
|
st := strings.ToLower(strings.TrimSpace(u.Status))
|
|
|
|
switch {
|
|
case st == "completed" || st == "failed":
|
|
if body == "" && st == "completed" {
|
|
return nil
|
|
}
|
|
if st == "failed" && body == "" {
|
|
body = "(failed)"
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventToolResult,
|
|
ToolName: toolLabel,
|
|
Content: truncateRunes(body, 800),
|
|
SessionID: sessionID,
|
|
}}
|
|
case st == "in_progress" || st == "pending":
|
|
// Stream intermediate tool output to IM (ACP allows content while not terminal).
|
|
if body == "" {
|
|
return nil
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventToolResult,
|
|
ToolName: toolLabel,
|
|
Content: truncateRunes(body, 800),
|
|
SessionID: sessionID,
|
|
}}
|
|
default:
|
|
if body != "" {
|
|
return []core.Event{{
|
|
Type: core.EventToolResult,
|
|
ToolName: toolLabel,
|
|
Content: truncateRunes(body, 800),
|
|
SessionID: sessionID,
|
|
}}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractToolCallContentText(blocks []struct {
|
|
Type string `json:"type"`
|
|
Content struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}) string {
|
|
var b strings.Builder
|
|
for _, c := range blocks {
|
|
if c.Content.Text != "" {
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(c.Content.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// mapSessionUpdateFallback handles unknown sessionUpdate values (vendor extensions
|
|
// that still carry human-readable text). Never guesses auth or tool semantics.
|
|
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":
|
|
var u struct {
|
|
Content struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
Text string `json:"text"`
|
|
}
|
|
if json.Unmarshal(update, &u) != nil {
|
|
return nil
|
|
}
|
|
t := u.Content.Text
|
|
if t == "" {
|
|
t = u.Text
|
|
}
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventThinking,
|
|
Content: t,
|
|
SessionID: sessionID,
|
|
}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mapPlan(sessionID string, update json.RawMessage) []core.Event {
|
|
var u struct {
|
|
Entries []struct {
|
|
Content string `json:"content"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status"`
|
|
} `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(update, &u); err != nil || len(u.Entries) == 0 {
|
|
return nil
|
|
}
|
|
var b strings.Builder
|
|
for i, e := range u.Entries {
|
|
if i > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
line := e.Content
|
|
if e.Status != "" {
|
|
line = "[" + e.Status + "] " + line
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
return []core.Event{{
|
|
Type: core.EventThinking,
|
|
Content: b.String(),
|
|
SessionID: sessionID,
|
|
}}
|
|
}
|
|
|
|
func truncateRunes(s string, maxRunes int) string {
|
|
if maxRunes <= 0 {
|
|
return s
|
|
}
|
|
r := []rune(s)
|
|
if len(r) <= maxRunes {
|
|
return s
|
|
}
|
|
return string(r[:maxRunes]) + "..."
|
|
}
|
|
|
|
// permissionOption matches ACP session/request_permission option entries.
|
|
type permissionOption struct {
|
|
OptionID string `json:"optionId"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
func pickPermissionOptionID(allow bool, options []permissionOption) string {
|
|
if len(options) == 0 {
|
|
return ""
|
|
}
|
|
if allow {
|
|
for _, o := range options {
|
|
if strings.Contains(strings.ToLower(o.Kind), "allow") {
|
|
return o.OptionID
|
|
}
|
|
}
|
|
for _, o := range options {
|
|
if strings.Contains(strings.ToLower(o.Name), "allow") {
|
|
return o.OptionID
|
|
}
|
|
}
|
|
return options[0].OptionID
|
|
}
|
|
for _, o := range options {
|
|
if strings.Contains(strings.ToLower(o.Kind), "reject") || strings.Contains(strings.ToLower(o.Kind), "deny") {
|
|
return o.OptionID
|
|
}
|
|
}
|
|
for _, o := range options {
|
|
if strings.Contains(strings.ToLower(o.Name), "reject") || strings.Contains(strings.ToLower(o.Name), "deny") {
|
|
return o.OptionID
|
|
}
|
|
}
|
|
return options[len(options)-1].OptionID
|
|
}
|
|
|
|
func buildPermissionResult(allow bool, optionID string) map[string]any {
|
|
if !allow {
|
|
if optionID == "" {
|
|
return map[string]any{
|
|
"outcome": map[string]any{"outcome": "cancelled"},
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"outcome": map[string]any{
|
|
"outcome": "selected",
|
|
"optionId": optionID,
|
|
},
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"outcome": map[string]any{
|
|
"outcome": "selected",
|
|
"optionId": optionID,
|
|
},
|
|
}
|
|
}
|