Repository: chenhg5/cc-connect (2026-04-11T14:47:17Z)

Issue:
会话锁导致并发消息丢弃：连续发送多条消息只处理第一条
## 问题描述

当用户快速连续发送多条消息（如多张图片）时，cc-connect 只处理第一条，其余消息在会话锁定期间被静默丢弃，session 解锁后也不会补处理。

## 日志证据

```
20:45:17.184 downloaded image  size=244513   ← 第1张，成功下载
20:45:17.184 processing message              ← 第1张，进入处理
20:45:17.195 interactive session started     ← session 被锁
20:45:17.389 message received                ← 第2张消息到达
20:45:17.916 downloaded image  size=266337   ← 第2张，成功下载
20:45:18.003 message received                ← 第3张消息到达
20:45:18.629 downloaded image  size=680155   ← 第3张，成功下载
20:45:47.738 turn complete                   ← 第1张处理完毕
（之后无任何 processing message — 第2、3张被丢弃）
```

## 使用场景

报销场景中员工需要一次性发送多张发票/付款截图。当前只能逐张发送并等待机器人回复后再发下一张，体验很差。

## 期望行为

会话锁定期间收到的消息应该排队等待，session 解锁后依次处理，而不是丢弃。

## 环境

- cc-connect 版本：1.2.1
- 平台：feishu（WebSocket 模式）
- Agent：claudecode


Discussion:
👋 Hi @michael-hyperhit! Thanks for opening your first issue here!

We'll review it as soon as possible. While waiting, please make sure:
- You've provided enough details about the issue
- Any error messages are included in full
- For feature requests, describe your use case

Thanks for your feedback!
## 补充：更核心的问题 — 会话锁导致并发图片消息丢失

经过 debug 日志分析，发现一个更严重的问题：

当用户快速连续发送多张图片时（即使是 `image` 类型），cc-connect **只处理第一张，其余全部丢弃**。

### 日志证据

```
20:45:17.184 downloaded image  size=244513   ← 第1张，成功下载
20:45:17.184 processing message              ← 第1张，进入处理
20:45:17.195 interactive session started     ← Claude Code 会话启动，session 被锁
20:45:17.389 message received                ← 第2张消息到达
20:45:17.916 downloaded image  size=266337   ← 第2张，成功下载
20:45:18.003 message received                ← 第3张消息到达
20:45:18.629 downloaded image  size=680155   ← 第3张，成功下载
20:45:47.738 turn complete                   ← 第1张处理完毕
（之后无任何 processing message — 第2、3张被丢弃）
```

### 问题分析

- 3张图片全部成功接收和下载
- 但会话锁在处理第1张时锁住
- 第2、3张图片下载后没有进入队列，session 解锁后也没有被处理
- 对于报销等需要批量处理图片的场景，这个问题影响很大

### 期望行为

会话锁定期间收到的消息应该排队，session 解锁后依次处理，而不是丢弃。



Patch:
diff --git a/core/engine.go b/core/engine.go
index 8e7a2537c..20428c16a 100644
--- a/core/engine.go
+++ b/core/engine.go
@@ -1509,6 +1509,11 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
 		session = rotated
 	}
 
+	// Ensure an interactiveState entry exists before launching the async
+	// processor so messages arriving during session startup can be queued
+	// instead of dropped (issue #565).
+	e.ensureInteractiveStateForQueueing(interactiveKey, p, msg.ReplyCtx)
+
 	slog.Info("processing message",
 		"platform", msg.Platform,
 		"user", msg.UserName,
@@ -1577,7 +1582,12 @@ func (e *Engine) queueMessageForBusySession(p Platform, msg *Message, interactiv
 	state, hasState := e.interactiveStates[interactiveKey]
 	e.interactiveMu.Unlock()
 
-	if !hasState || state == nil || state.agentSession == nil || !state.agentSession.Alive() {
+	if !hasState || state == nil {
+		return false
+	}
+	// Allow queueing when agentSession is nil (session is starting up,
+	// issue #565). Only reject if the session was established and died.
+	if state.agentSession != nil && !state.agentSession.Alive() {
 		return false
 	}
 
@@ -1614,6 +1624,22 @@ func (e *Engine) queueMessageForBusySession(p Platform, msg *Message, interactiv
 	return true
 }
 
+// ensureInteractiveStateForQueueing creates a placeholder interactiveState
+// entry if none exists. This allows messages arriving while the agent session
+// is still starting up to be queued instead of dropped (issue #565).
+// The placeholder has agentSession==nil; getOrCreateInteractiveStateWith will
+// replace it with a fully initialized state once the agent process is spawned.
+func (e *Engine) ensureInteractiveStateForQueueing(key string, p Platform, replyCtx any) {
+	e.interactiveMu.Lock()
+	defer e.interactiveMu.Unlock()
+	if _, ok := e.interactiveStates[key]; !ok {
+		e.interactiveStates[key] = &interactiveState{
+			platform: p,
+			replyCtx: replyCtx,
+		}
+	}
+}
+
 // drainOrphanedQueue is called when a message was queued but the drain loop
 // has already exited. It processes all pending messages in the state, similar
 // to the drain loop in processInteractiveMessageWith but as a standalone


Test patch:
diff --git a/core/engine_test.go b/core/engine_test.go
index b48efb1b8..91d3ae392 100644
--- a/core/engine_test.go
+++ b/core/engine_test.go
@@ -6093,6 +6093,40 @@ func TestQueueMessage_DeadSession_ReturnsFalse(t *testing.T) {
 	}
 }
 
+// TestQueueMessage_NilAgentSession_DuringStartup verifies that messages can be
+// queued when the interactiveState exists but agentSession is nil (session is
+// still starting up). This is the fix for issue #565.
+func TestQueueMessage_NilAgentSession_DuringStartup(t *testing.T) {
+	p := &stubPlatformEngine{n: "test"}
+	e := newTestEngine()
+
+	key := "test:starting-session"
+	// Simulate the placeholder state created by ensureInteractiveStateForQueueing
+	state := &interactiveState{
+		platform: p,
+		replyCtx: "ctx",
+		// agentSession is nil — session is starting up
+	}
+	e.interactiveMu.Lock()
+	e.interactiveStates[key] = state
+	e.interactiveMu.Unlock()
+
+	msg := &Message{SessionKey: key, Content: "queued during startup", ReplyCtx: "ctx-startup"}
+	ok := e.queueMessageForBusySession(p, msg, key)
+	if !ok {
+		t.Fatal("expected true: messages should be queueable during session startup")
+	}
+
+	state.mu.Lock()
+	if len(state.pendingMessages) != 1 {
+		t.Fatalf("pendingMessages len = %d, want 1", len(state.pendingMessages))
+	}
+	if state.pendingMessages[0].content != "queued during startup" {
+		t.Fatalf("queued content = %q, want %q", state.pendingMessages[0].content, "queued during startup")
+	}
+	state.mu.Unlock()
+}
+
 // --- 2. /compress flow ---
 
 type stubCompressorAgent struct {