fix: three bug fixes for #1184, #1139, and #1176

fix(core): multiSelect AskUserQuestion on card platforms (closes #1184)
- For multiSelect questions, render options as a numbered text list
  instead of instant-resolve buttons (buttons resolved on first click,
  preventing multi-selection)
- Add new i18n key MsgAskQuestionNoteMulti with per-language hint to
  reply with comma-separated numbers (e.g. 1,3)
- Single-select questions keep the existing button UX unchanged

fix(config): allow cc-connect web with agent-only config (closes #1139)
- Add LoadPermissive() / validatePermissive() that skip the
  "at least one platform" requirement
- runWeb now uses LoadPermissive so the web admin UI starts even
  before any platforms are configured, enabling first-time setup

fix(weixin): fail fast on ret=-2 when context_token cannot be refreshed (closes #1176)
- When sendMessage returns ret=-2 and the stored token is the same as
  the current one (no inbound message has refreshed it), stop retrying
  immediately instead of burning 3 retries on the same stale token
- Return a clear actionable error: "user must send a new message to
  refresh the session token"
- Applied to both text (weixin.go) and media (media_outbound.go) paths

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ClaudeandCursor committed 2026-06-04 17:01:01 +08:00
1 parent 92add53514
commit eb86866420
6 files changed
+100 -28

No files matched your search

+3 -1
View File
@@ -19,7 +19,9 @@ func runWeb(args []string) {
os.Exit(1)
}
cfg, err := config.Load(configPath)
// Use LoadPermissive so `cc-connect web` works even before any platforms
// are configured (e.g. during initial setup via the Web Admin UI).
cfg, err := config.LoadPermissive(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
+37 -8
View File
@@ -470,20 +470,18 @@ type LogConfig struct {
Level string `toml:"level"`
}
func Load(path string) (*Config, error) {
// load parses, env-resolves, and wires providers in the config file but does
// NOT validate — callers must call validate() or validatePermissive() themselves.
func load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
cfg := &Config{
Log: LogConfig{Level: "info"},
}
cfg := &Config{Log: LogConfig{Level: "info"}}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
resolveEnvInConfig(cfg)
if cfg.DataDir == "" {
if home, err := os.UserHomeDir(); err == nil {
cfg.DataDir = filepath.Join(home, ".cc-connect")
@@ -495,9 +493,29 @@ func Load(path string) (*Config, error) {
if cfg.AttachmentSend == "" {
cfg.AttachmentSend = "on"
}
cfg.ResolveProviderRefs()
return cfg, nil
}
// LoadPermissive loads the config file and performs all validation except the
// "at least one platform per project" check. Use this for commands (like
// `cc-connect web`) that should work even before platforms are configured.
func LoadPermissive(path string) (*Config, error) {
cfg, err := load(path)
if err != nil {
return nil, err
}
if err := cfg.validatePermissive(); err != nil {
return nil, err
}
return cfg, nil
}
func Load(path string) (*Config, error) {
cfg, err := load(path)
if err != nil {
return nil, err
}
if err := cfg.validate(); err != nil {
return nil, err
}
@@ -777,7 +795,18 @@ func EffectiveCardMode(cfg *Config, proj *ProjectConfig) string {
return "legacy"
}
// validatePermissive is like validate but skips the "at least one platform"
// requirement so that commands like `cc-connect web` can operate on agent-only
// configs before platforms have been set up.
func (c *Config) validatePermissive() error {
return c.validateInternal(true)
}
func (c *Config) validate() error {
return c.validateInternal(false)
}
func (c *Config) validateInternal(permissive bool) error {
if err := validateDisplayConfig("display", &c.Display); err != nil {
return err
}
@@ -805,7 +834,7 @@ func (c *Config) validate() error {
if proj.Agent.Type == "" {
return fmt.Errorf("config: %s.agent.type is required", prefix)
}
if len(proj.Platforms) == 0 {
if len(proj.Platforms) == 0 && !permissive {
return fmt.Errorf("config: %s needs at least one [[projects.platforms]]", prefix)
}
for j, p := range proj.Platforms {
+14 -1
View File
@@ -9630,8 +9630,20 @@ func (e *Engine) sendAskQuestionPrompt(p Platform, replyCtx any, questions []Use
cb := NewCard().Title(e.i18n.T(MsgAskQuestionTitle)+titleSuffix, "blue")
body := "**" + q.Question + "**"
if q.MultiSelect {
body += e.i18n.T(MsgAskQuestionMulti)
// For multiSelect, buttons would resolve on the first click and prevent
// selecting multiple options. Render options as a numbered text list
// instead, and instruct the user to reply with comma-separated numbers.
body += e.i18n.T(MsgAskQuestionMulti) + "\n\n"
for i, opt := range q.Options {
body += fmt.Sprintf("%d. **%s**", i+1, opt.Label)
if opt.Description != "" {
body += " — " + opt.Description
}
body += "\n"
}
cb.Markdown(body)
cb.Note(e.i18n.T(MsgAskQuestionNoteMulti))
} else {
cb.Markdown(body)
for i, opt := range q.Options {
desc := opt.Label
@@ -9645,6 +9657,7 @@ func (e *Engine) sendAskQuestionPrompt(p Platform, replyCtx any, questions []Use
})
}
cb.Note(e.i18n.T(MsgAskQuestionNote))
}
e.sendWithCard(p, replyCtx, cb.Build())
return
}
+8
View File
@@ -392,6 +392,7 @@ const (
MsgAskQuestionTitle MsgKey = "ask_question_title"
MsgAskQuestionNote MsgKey = "ask_question_note"
MsgAskQuestionNoteMulti MsgKey = "ask_question_note_multi"
MsgAskQuestionMulti MsgKey = "ask_question_multi"
MsgAskQuestionPrompt MsgKey = "ask_question_prompt"
MsgAskQuestionAnswered MsgKey = "ask_question_answered"
@@ -2518,6 +2519,13 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "ボタンが反応しない場合は、番号(例: 1)で返信するか、直接回答を入力してください",
LangSpanish: "Si los botones no responden, responda con el número de opción (ej. 1) o escriba su respuesta",
},
MsgAskQuestionNoteMulti: {
LangEnglish: "Reply with comma-separated option numbers (e.g. 1,3) or type your answer",
LangChinese: "请回复逗号分隔的选项编号(如 1,3)或直接输入你的回答",
LangTraditionalChinese: "請回覆逗號分隔的選項編號(如 1,3)或直接輸入你的回答",
LangJapanese: "カンマ区切りの番号(例: 1,3)で返信するか、直接回答を入力してください",
LangSpanish: "Responda con los números de opción separados por comas (ej. 1,3) o escriba su respuesta",
},
MsgAskQuestionMulti: {
LangEnglish: " (multiple selections allowed, separate with commas)",
LangChinese: "(可多选,用逗号分隔)",
+14 -9
View File
@@ -151,22 +151,27 @@ func (p *Platform) sendSingleItemWithRetry(ctx context.Context, rc *replyContext
return nil
}
lastErr = err
// Check if error is ret=-2 (API declined) - retry with fresh token
// Check if error is ret=-2 (API declined) - attempt token refresh
if strings.Contains(err.Error(), "ret=-2") {
slog.Warn("weixin: sendMessage ret=-2 for media, retrying",
freshToken := p.getContextToken(rc.peerUserID)
if freshToken == "" || freshToken == rc.contextToken {
slog.Warn("weixin: sendMessage ret=-2 for media, no fresh context_token — "+
"user must send a new message to refresh session token",
"attempt", attempt+1, "peer", rc.peerUserID)
// Add delay before retry
return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+
"user must send a new message to peer %q to refresh the session token: %w",
rc.peerUserID, lastErr)
}
slog.Warn("weixin: sendMessage ret=-2 for media, retrying with fresh context_token",
"attempt", attempt+1, "peer", rc.peerUserID)
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID)
// Brief delay before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinSendRetryDelay):
}
// Refresh context_token from stored tokens
freshToken := p.getContextToken(rc.peerUserID)
if freshToken != "" && freshToken != rc.contextToken {
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID)
}
continue
}
// For other errors, don't retry
+24 -9
View File
@@ -655,7 +655,10 @@ func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string)
}
// sendChunkWithRetry sends a single chunk with retry mechanism.
// When sendMessage returns ret=-2, it retries with a fresh context_token.
// When sendMessage returns ret=-2, it tries to refresh the context_token from
// storage (which is updated by every inbound message) before retrying.
// If the stored token is the same as the current one (no refresh possible),
// it fails fast rather than burning retries on a stale token.
// chunkIdx and totalChunks are 1-based indices used for logging context.
func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error {
var lastErr error
@@ -666,29 +669,41 @@ func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chu
return nil
}
lastErr = err
// Check if error is ret=-2 (API declined) - retry with fresh token
// Check if error is ret=-2 (API declined) - attempt token refresh
if strings.Contains(err.Error(), "ret=-2") {
preview := []rune(chunk)
if len(preview) > 50 {
preview = preview[:50]
}
// Refresh context_token from stored tokens (may have been updated by a
// concurrent inbound message while we were waiting).
freshToken := p.getContextToken(rc.peerUserID)
if freshToken == "" || freshToken == rc.contextToken {
// No fresh token available — further retries would use the same stale
// token and all fail. Fail fast with an actionable error.
slog.Warn("weixin: sendMessage ret=-2, no fresh context_token available — "+
"user must send a new message to refresh the session token",
"attempt", attempt+1, "peer", rc.peerUserID,
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
"chunk_runes", utf8.RuneCountInString(chunk),
"preview", string(preview))
return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+
"user must send a new message to peer %q to refresh the session token: %w",
rc.peerUserID, lastErr)
}
slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token",
"attempt", attempt+1, "peer", rc.peerUserID,
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
"chunk_runes", utf8.RuneCountInString(chunk),
"preview", string(preview))
// Add delay before retry
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
// Brief delay before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinSendRetryDelay):
}
// Refresh context_token from stored tokens (may have been updated by new incoming message)
freshToken := p.getContextToken(rc.peerUserID)
if freshToken != "" && freshToken != rc.contextToken {
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
}
continue
}
// For other errors, don't retry