初始化仓库
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user