Files
chenhg5-cc-connect/core/progress_compact.go

560 lines
16 KiB
Go

package core
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
progressStyleLegacy = "legacy"
progressStyleCompact = "compact"
progressStyleCard = "card"
// ProgressCardPayloadPrefix marks a structured payload for card-style progress.
ProgressCardPayloadPrefix = "__cc_connect_progress_card_v1__:"
// Keep a margin below platform hard limit for markdown wrappers/code fences.
compactProgressMaxChars = maxPlatformMessageLen - 200
// Bound each platform progress-card API call so a hung upstream request
// does not block the whole turn forever.
compactProgressAPITimeout = 15 * time.Second
)
type ProgressCardState string
const (
ProgressCardStateRunning ProgressCardState = "running"
ProgressCardStateCompleted ProgressCardState = "completed"
ProgressCardStateFailed ProgressCardState = "failed"
)
type ProgressCardEntryKind string
const (
ProgressEntryInfo ProgressCardEntryKind = "info"
ProgressEntryThinking ProgressCardEntryKind = "thinking"
ProgressEntryToolUse ProgressCardEntryKind = "tool_use"
ProgressEntryToolResult ProgressCardEntryKind = "tool_result"
ProgressEntryError ProgressCardEntryKind = "error"
)
type ProgressCardEntry struct {
Kind ProgressCardEntryKind `json:"kind"`
Text string `json:"text"`
Tool string `json:"tool,omitempty"`
Status string `json:"status,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
Success *bool `json:"success,omitempty"`
}
// ProgressCardPayload carries structured progress entries for platforms that
// render custom progress cards.
type ProgressCardPayload struct {
Version int `json:"version,omitempty"`
Agent string `json:"agent,omitempty"`
Lang string `json:"lang,omitempty"`
State ProgressCardState `json:"state,omitempty"`
Entries []string `json:"entries,omitempty"` // legacy fallback
Items []ProgressCardEntry `json:"items,omitempty"` // ordered typed events
Truncated bool `json:"truncated"`
}
// BuildProgressCardPayload encodes progress entries into a transport string.
// This legacy builder keeps compatibility with old callers that only send text.
func BuildProgressCardPayload(entries []string, truncated bool) string {
cleaned := make([]string, 0, len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry != "" {
cleaned = append(cleaned, entry)
}
}
if len(cleaned) == 0 {
return ""
}
payload := ProgressCardPayload{
Entries: cleaned,
Truncated: truncated,
}
b, err := json.Marshal(payload)
if err != nil {
return ""
}
return ProgressCardPayloadPrefix + string(b)
}
// BuildProgressCardPayloadV2 encodes ordered typed progress events.
func BuildProgressCardPayloadV2(items []ProgressCardEntry, truncated bool, agent string, lang Language, state ProgressCardState) string {
cleaned := make([]ProgressCardEntry, 0, len(items))
for _, item := range items {
text := strings.TrimSpace(item.Text)
if text == "" {
continue
}
kind := item.Kind
if kind == "" {
kind = ProgressEntryInfo
}
cleaned = append(cleaned, ProgressCardEntry{
Kind: kind,
Text: text,
Tool: strings.TrimSpace(item.Tool),
Status: strings.TrimSpace(item.Status),
ExitCode: item.ExitCode,
Success: item.Success,
})
}
if len(cleaned) == 0 {
return ""
}
if state == "" {
state = ProgressCardStateRunning
}
payload := ProgressCardPayload{
Version: 2,
Agent: strings.TrimSpace(agent),
Lang: string(lang),
State: state,
Items: cleaned,
Truncated: truncated,
}
b, err := json.Marshal(payload)
if err != nil {
return ""
}
return ProgressCardPayloadPrefix + string(b)
}
// ParseProgressCardPayload decodes a structured progress payload.
func ParseProgressCardPayload(content string) (*ProgressCardPayload, bool) {
if !strings.HasPrefix(content, ProgressCardPayloadPrefix) {
return nil, false
}
raw := strings.TrimPrefix(content, ProgressCardPayloadPrefix)
var payload ProgressCardPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, false
}
legacy := make([]string, 0, len(payload.Entries))
for _, entry := range payload.Entries {
entry = strings.TrimSpace(entry)
if entry != "" {
legacy = append(legacy, entry)
}
}
items := make([]ProgressCardEntry, 0, len(payload.Items))
for _, item := range payload.Items {
item.Text = strings.TrimSpace(item.Text)
item.Tool = strings.TrimSpace(item.Tool)
item.Status = strings.TrimSpace(item.Status)
if item.Text == "" {
continue
}
if item.Kind == "" {
item.Kind = ProgressEntryInfo
}
items = append(items, item)
}
if len(items) == 0 && len(legacy) > 0 {
for _, entry := range legacy {
items = append(items, ProgressCardEntry{
Kind: inferLegacyEntryKind(entry),
Text: entry,
})
}
}
if len(items) == 0 && len(legacy) == 0 {
return nil, false
}
if payload.State == "" {
payload.State = ProgressCardStateRunning
}
payload.Items = items
payload.Entries = legacy
if len(payload.Entries) == 0 && len(payload.Items) > 0 {
payload.Entries = make([]string, 0, len(payload.Items))
for _, item := range payload.Items {
payload.Entries = append(payload.Entries, item.Text)
}
}
return &payload, true
}
func inferLegacyEntryKind(entry string) ProgressCardEntryKind {
switch {
case strings.HasPrefix(entry, "💭"):
return ProgressEntryThinking
case strings.HasPrefix(entry, "🔧"), strings.Contains(entry, "**Tool #"):
return ProgressEntryToolUse
case strings.HasPrefix(entry, "🧾"):
return ProgressEntryToolResult
case strings.HasPrefix(entry, "❌"):
return ProgressEntryError
default:
return ProgressEntryInfo
}
}
// compactProgressWriter coalesces intermediate progress (thinking/tool-use)
// into one editable message for platforms that support message updates.
type compactProgressWriter struct {
ctx context.Context
platform Platform
replyCtx any
transform func(string) string
starter PreviewStarter
updater MessageUpdater
handle any
enabled bool
failed bool
style string
usePayload bool
content string
entries []string
items []ProgressCardEntry
state ProgressCardState
agentName string
lang Language
truncated bool
lastSent string
maxEntries int
// Throttle message edits to avoid platform rate limits (e.g. Discord ~5 edits/5s).
minUpdateInterval time.Duration
lastUpdateAt time.Time
}
func normalizeProgressStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "", progressStyleLegacy:
return progressStyleLegacy
case progressStyleCompact:
return progressStyleCompact
case progressStyleCard:
return progressStyleCard
default:
return progressStyleLegacy
}
}
func progressStyleForPlatform(p Platform) string {
ps := progressStyleLegacy
if sp, ok := p.(ProgressStyleProvider); ok {
ps = normalizeProgressStyle(sp.ProgressStyle())
}
return ps
}
type progressStyleHintProvider interface {
progressStyleHint() string
}
type progressCardPayloadHintProvider interface {
supportsProgressCardPayloadHint() bool
}
func progressStyleForTarget(p Platform, replyCtx any) string {
if hint, ok := replyCtx.(progressStyleHintProvider); ok {
return normalizeProgressStyle(hint.progressStyleHint())
}
return progressStyleForPlatform(p)
}
func progressCardPayloadForTarget(p Platform, replyCtx any) bool {
if hint, ok := replyCtx.(progressCardPayloadHintProvider); ok {
return hint.supportsProgressCardPayloadHint()
}
if cap, ok := p.(ProgressCardPayloadSupport); ok {
return cap.SupportsProgressCardPayload()
}
return false
}
// SuppressStandaloneToolResultEvent is true when a platform opts into progress
// styling (ProgressStyleProvider) but uses legacy mode. In that case tool_use
// lines are still shown, but a separate chat message for EventToolResult is
// skipped to avoid duplicate noise (e.g. Codex structured tool results on Feishu).
// Platforms without ProgressStyleProvider keep showing standalone tool results.
func SuppressStandaloneToolResultEvent(p Platform) bool {
_, ok := p.(ProgressStyleProvider)
if !ok {
return false
}
return progressStyleForPlatform(p) == progressStyleLegacy
}
func newCompactProgressWriter(ctx context.Context, p Platform, replyCtx any, agentName string, lang Language, transform func(string) string) *compactProgressWriter {
w := &compactProgressWriter{
ctx: ctx,
platform: p,
replyCtx: replyCtx,
transform: transform,
style: progressStyleForTarget(p, replyCtx),
state: ProgressCardStateRunning,
agentName: normalizeProgressAgentLabel(agentName),
lang: lang,
maxEntries: 10,
}
if throttler, ok := p.(ProgressUpdateThrottler); ok {
w.minUpdateInterval = throttler.ProgressUpdateInterval()
}
if w.style != progressStyleCompact && w.style != progressStyleCard {
slog.Debug("progress writer disabled: unsupported style", "platform", p.Name(), "style", w.style)
return w
}
updater, ok := p.(MessageUpdater)
if !ok {
slog.Debug("progress writer disabled: platform has no MessageUpdater", "platform", p.Name(), "style", w.style)
return w
}
w.enabled = true
w.updater = updater
if starter, ok := p.(PreviewStarter); ok {
w.starter = starter
}
if w.style == progressStyleCard {
if progressCardPayloadForTarget(p, replyCtx) {
w.usePayload = true
}
}
slog.Debug("progress writer enabled", "platform", p.Name(), "style", w.style, "use_payload", w.usePayload)
return w
}
func normalizeProgressAgentLabel(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "", "agent":
return "Agent"
case "codex":
return "Codex"
case "claudecode", "claude-code", "cc":
return "CC"
case "gemini":
return "Gemini"
case "cursor":
return "Cursor"
case "qoder":
return "Qoder"
case "iflow":
return "iFlow"
case "opencode":
return "OpenCode"
case "pi":
return "PI"
default:
n := strings.TrimSpace(name)
if n == "" {
return "Agent"
}
return strings.ToUpper(n[:1]) + n[1:]
}
}
// Append appends one progress item and updates the in-place message.
// Returns true when compact rendering handled this item; false means caller
// should fallback to legacy per-event send.
func (w *compactProgressWriter) Append(item string) bool {
return w.AppendEvent(ProgressEntryInfo, item, "", item)
}
// AppendEvent appends one typed progress event and updates the in-place message.
// fallback is used for compact/plain rendering when style-specific rendering is not available.
func (w *compactProgressWriter) AppendEvent(kind ProgressCardEntryKind, text string, tool string, fallback string) bool {
return w.AppendStructured(ProgressCardEntry{
Kind: kind,
Text: text,
Tool: tool,
}, fallback)
}
// AppendStructured appends one structured progress event and updates the in-place message.
func (w *compactProgressWriter) AppendStructured(item ProgressCardEntry, fallback string) bool {
if !w.enabled || w.failed {
return false
}
text := strings.TrimSpace(item.Text)
fallback = strings.TrimSpace(fallback)
if text == "" && fallback == "" {
return true
}
if text == "" {
text = fallback
}
if fallback == "" {
fallback = text
}
switch item.Kind {
case ProgressEntryThinking, ProgressEntryError, ProgressEntryInfo:
if w.transform != nil {
text = w.transform(text)
fallback = w.transform(fallback)
}
}
kind := item.Kind
if kind == "" {
kind = ProgressEntryInfo
}
item.Kind = kind
item.Text = text
item.Tool = strings.TrimSpace(item.Tool)
item.Status = strings.TrimSpace(item.Status)
switch w.style {
case progressStyleCard:
w.items = append(w.items, item)
w.entries = append(w.entries, fallback)
truncated := false
if w.maxEntries > 0 && len(w.items) > w.maxEntries {
w.items = w.items[len(w.items)-w.maxEntries:]
if len(w.entries) > w.maxEntries {
w.entries = w.entries[len(w.entries)-w.maxEntries:]
}
truncated = true
} else if w.maxEntries > 0 && len(w.entries) > w.maxEntries {
w.entries = w.entries[len(w.entries)-w.maxEntries:]
truncated = true
}
w.truncated = truncated
if w.usePayload {
w.content = BuildProgressCardPayloadV2(w.items, w.truncated, w.agentName, w.lang, w.state)
if w.content == "" {
slog.Warn("progress writer: failed to build structured payload", "platform", w.platform.Name())
w.failed = true
return false
}
} else {
w.content = renderCardProgressMarkdownFallback(w.entries, truncated)
w.content = trimCompactProgressText(w.content, compactProgressMaxChars)
}
default:
if w.content == "" {
w.content = fallback
} else {
w.content += "\n\n" + fallback
}
w.content = trimCompactProgressText(w.content, compactProgressMaxChars)
}
if w.content == w.lastSent {
return true
}
if w.handle == nil {
if w.starter != nil {
callCtx, cancel := w.withAPITimeout()
handle, err := w.starter.SendPreviewStart(callCtx, w.replyCtx, w.content)
cancel()
if err != nil || handle == nil {
slog.Warn("progress writer: SendPreviewStart failed", "platform", w.platform.Name(), "style", w.style, "error", err, "handle_nil", handle == nil)
w.failed = true
return false
}
w.handle = handle
w.lastSent = w.content
w.lastUpdateAt = time.Now()
return true
}
callCtx, cancel := w.withAPITimeout()
err := w.platform.Send(callCtx, w.replyCtx, w.content)
cancel()
if err != nil {
slog.Warn("progress writer: initial Send failed", "platform", w.platform.Name(), "style", w.style, "error", err)
w.failed = true
return false
}
w.handle = w.replyCtx
w.lastSent = w.content
w.lastUpdateAt = time.Now()
return true
}
if w.minUpdateInterval > 0 && time.Since(w.lastUpdateAt) < w.minUpdateInterval {
return true
}
callCtx, cancel := w.withAPITimeout()
err := w.updater.UpdateMessage(callCtx, w.handle, w.content)
cancel()
if err != nil {
slog.Warn("progress writer: UpdateMessage failed", "platform", w.platform.Name(), "style", w.style, "error", err)
w.failed = true
return false
}
w.lastSent = w.content
w.lastUpdateAt = time.Now()
return true
}
// Finalize updates card progress state (running/completed/failed) without
// appending a new progress entry.
func (w *compactProgressWriter) Finalize(state ProgressCardState) bool {
if !w.enabled || w.failed || w.style != progressStyleCard || !w.usePayload || w.handle == nil {
return false
}
if state == "" {
state = ProgressCardStateCompleted
}
if w.state == state {
return true
}
w.state = state
w.content = BuildProgressCardPayloadV2(w.items, w.truncated, w.agentName, w.lang, w.state)
if w.content == "" || w.content == w.lastSent {
return w.content != ""
}
callCtx, cancel := w.withAPITimeout()
err := w.updater.UpdateMessage(callCtx, w.handle, w.content)
cancel()
if err != nil {
slog.Warn("progress writer: Finalize UpdateMessage failed", "platform", w.platform.Name(), "style", w.style, "error", err)
w.failed = true
return false
}
w.lastSent = w.content
return true
}
func (w *compactProgressWriter) withAPITimeout() (context.Context, context.CancelFunc) {
if _, hasDeadline := w.ctx.Deadline(); hasDeadline {
return w.ctx, func() {}
}
return context.WithTimeout(w.ctx, compactProgressAPITimeout)
}
func renderCardProgressMarkdownFallback(entries []string, truncated bool) string {
var b strings.Builder
b.WriteString("⏳ **Progress**\n")
if truncated {
b.WriteString("_Showing latest updates only._\n")
}
for i, entry := range entries {
b.WriteString("\n")
b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". ")
b.WriteString(strings.ReplaceAll(entry, "\n", "\n "))
}
return b.String()
}
func trimCompactProgressText(s string, maxRunes int) string {
if maxRunes <= 0 {
return s
}
s = strings.TrimPrefix(s, "…\n")
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
rs := []rune(s)
tail := strings.TrimLeft(string(rs[len(rs)-maxRunes:]), "\n")
return "…\n" + tail
}