Merge pull request #198 from 0xsegfaulted/feat/interrupt-support

feat: queue messages when agent is busy instead of discarding
This commit is contained in:
cg33 authored and GitHub committed 2026-03-18 22:07:07 +08:00
commit be083826cd
3 files changed
+624 -20

No files matched your search

+447 -18
View File
@@ -20,6 +20,7 @@ import (
)
const maxPlatformMessageLen = 4000
const maxQueuedMessages = 5 // cap queued messages to bound memory usage
const (
defaultThinkingMaxLen = 300
@@ -198,19 +199,35 @@ type workspaceInitFlow struct {
channelName string
}
// queuedMessage holds a message that arrived while the session was busy.
// The message is NOT sent to agent stdin at queue time; the event loop
// sends it after the current turn completes to avoid mid-turn interference.
type queuedMessage struct {
platform Platform
replyCtx any
content string
images []ImageAttachment
files []FileAttachment
fromVoice bool
userID string
msgPlatform string // platform name for sender injection
msgSessionKey string // session key for extracting chat ID
}
// interactiveState tracks a running interactive agent session and its permission state.
type interactiveState struct {
agentSession AgentSession
platform Platform
replyCtx any
workspaceDir string
mu sync.Mutex
pending *pendingPermission
approveAll bool // when true, auto-approve all permission requests for this session
quiet bool // when true, suppress thinking and tool progress for this session
fromVoice bool // true if current turn originated from voice transcription
sideText string
deleteMode *deleteModeState
agentSession AgentSession
platform Platform
replyCtx any
workspaceDir string
mu sync.Mutex
pending *pendingPermission
pendingMessages []queuedMessage // messages queued while session was busy
approveAll bool // when true, auto-approve all permission requests for this session
quiet bool // when true, suppress thinking and tool progress for this session
fromVoice bool // true if current turn originated from voice transcription
sideText string
deleteMode *deleteModeState
}
type deleteModeState struct {
@@ -1046,6 +1063,18 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
}
}
}
// Session is busy — try to queue the message for the running turn
// so the agent processes it immediately after the current turn ends.
if e.queueMessageForBusySession(p, msg, interactiveKey) {
// Race guard: the drain loop in processInteractiveMessageWith may
// have just finished (session unlocked) between our TryLock failure
// and the queue append. Re-try TryLock — if it succeeds, no one is
// draining the queue so we must start a processor ourselves.
if session.TryLock() {
go e.drainOrphanedQueue(session, interactiveKey, agent, resolvedWorkspace)
}
return
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))
return
}
@@ -1059,6 +1088,125 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
go e.processInteractiveMessageWith(p, msg, session, agent, interactiveKey, resolvedWorkspace)
}
// queueMessageForBusySession queues a message for later delivery when the
// session is busy. The message is NOT sent to agent stdin at queue time;
// the event loop sends it after the current turn's EventResult is received.
// Returns true if the message was successfully queued, false otherwise.
func (e *Engine) queueMessageForBusySession(p Platform, msg *Message, interactiveKey string) bool {
e.interactiveMu.Lock()
state, hasState := e.interactiveStates[interactiveKey]
e.interactiveMu.Unlock()
if !hasState || state == nil || state.agentSession == nil || !state.agentSession.Alive() {
return false
}
// Only queue metadata — do NOT send to agent stdin yet.
// The agent CLI may treat a mid-turn stdin message as part of the
// current turn, causing the event loop to hang waiting for a second
// EventResult that never arrives. Instead, the event loop sends the
// message after the current turn's EventResult is received.
state.mu.Lock()
if len(state.pendingMessages) >= maxQueuedMessages {
state.mu.Unlock()
return false // fall back to "previous processing" reply
}
state.pendingMessages = append(state.pendingMessages, queuedMessage{
platform: p,
replyCtx: msg.ReplyCtx,
content: msg.Content,
images: msg.Images,
files: msg.Files,
fromVoice: msg.FromVoice,
userID: msg.UserID,
msgPlatform: msg.Platform,
msgSessionKey: msg.SessionKey,
})
queueDepth := len(state.pendingMessages)
state.mu.Unlock()
slog.Info("message queued for busy session",
"session", msg.SessionKey,
"user", msg.UserName,
"queue_depth", queueDepth,
)
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMessageQueued))
return true
}
// 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
// goroutine.
func (e *Engine) drainOrphanedQueue(session *Session, interactiveKey string, agent Agent, workspaceDir string) {
unlocked := false
defer func() {
if !unlocked {
session.Unlock()
}
}()
e.interactiveMu.Lock()
state, hasState := e.interactiveStates[interactiveKey]
e.interactiveMu.Unlock()
if !hasState || state == nil || state.agentSession == nil || !state.agentSession.Alive() {
// State disappeared — notify any queued senders that their messages are lost.
if hasState && state != nil {
e.notifyDroppedQueuedMessages(state, fmt.Errorf("agent session ended"))
}
return
}
for {
state.mu.Lock()
if len(state.pendingMessages) == 0 {
session.Unlock()
unlocked = true
state.mu.Unlock()
return
}
queued := state.pendingMessages[0]
state.pendingMessages = state.pendingMessages[1:]
state.platform = queued.platform
state.replyCtx = queued.replyCtx
state.fromVoice = queued.fromVoice
state.mu.Unlock()
e.i18n.DetectAndSet(queued.content)
queuedPrompt := queued.content
if e.injectSender && queued.userID != "" {
chatID := extractChannelID(queued.msgSessionKey)
queuedPrompt = fmt.Sprintf("[cc-connect sender_id=%s platform=%s chat_id=%s]\n%s", queued.userID, queued.msgPlatform, chatID, queued.content)
}
if state.agentSession == nil || !state.agentSession.Alive() {
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), "agent session ended"))
e.notifyDroppedQueuedMessages(state, fmt.Errorf("agent session ended"))
return
}
drainEvents(state.agentSession.Events())
if err := state.agentSession.Send(queuedPrompt, queued.images, queued.files); err != nil {
slog.Error("failed to send orphaned queued message", "error", err, "session", interactiveKey)
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
e.notifyDroppedQueuedMessages(state, err)
return
}
session.AddHistory("user", queued.content)
var stopTyping func()
if ti, ok := queued.platform.(TypingIndicator); ok {
stopTyping = ti.StartTyping(e.ctx, queued.replyCtx)
}
slog.Info("processing orphaned queued message", "session", interactiveKey)
e.processInteractiveEvents(state, session, interactiveKey, "", time.Now(), stopTyping)
}
}
// ──────────────────────────────────────────────────────────────
// Voice message handling
// ──────────────────────────────────────────────────────────────
@@ -1322,7 +1470,16 @@ func (e *Engine) processInteractiveMessage(p Platform, msg *Message, session *Se
// It accepts an explicit agent, interactiveKey (for the interactiveStates map),
// and workspaceDir so that multi-workspace mode can route to per-workspace agents.
func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session *Session, agent Agent, interactiveKey string, workspaceDir string) {
defer session.Unlock()
// session.Unlock() is NOT deferred here — it is called explicitly in
// the drain loop below while holding state.mu to close the race window
// between "queue is empty" and "session unlocked". A deferred fallback
// ensures the lock is released on early-return paths.
unlocked := false
defer func() {
if !unlocked {
session.Unlock()
}
}()
if e.ctx.Err() != nil {
return
@@ -1358,12 +1515,16 @@ func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session
return
}
// Start typing indicator if platform supports it
// Start typing indicator if platform supports it.
// Ownership is transferred to processInteractiveEvents which manages
// stopping/restarting it across queued message turns.
var stopTyping func()
if ti, ok := p.(TypingIndicator); ok {
stopTyping = ti.StartTyping(e.ctx, msg.ReplyCtx)
}
defer func() {
// Stop typing if ownership was NOT transferred to processInteractiveEvents
// (i.e. an early return before that call).
if stopTyping != nil {
stopTyping()
}
@@ -1390,6 +1551,13 @@ func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session
slog.Error("failed to send prompt", "error", err)
if !state.agentSession.Alive() {
// Preserve queued messages before cleanup so we can migrate
// them to the replacement state after restart.
state.mu.Lock()
savedQueue := state.pendingMessages
state.pendingMessages = nil
state.mu.Unlock()
e.cleanupInteractiveState(interactiveKey, state)
e.send(p, msg.ReplyCtx, e.i18n.T(MsgSessionRestarting))
@@ -1399,6 +1567,12 @@ func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session
state.workspaceDir = workspaceDir
state.mu.Unlock()
}
// Restore queued messages on the new state.
if len(savedQueue) > 0 {
state.mu.Lock()
state.pendingMessages = append(state.pendingMessages, savedQueue...)
state.mu.Unlock()
}
if state.agentSession == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), "failed to restart agent session"))
return
@@ -1421,7 +1595,64 @@ func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session
slog.Warn("slow agent send", "elapsed", elapsed, "session", msg.SessionKey, "content_len", len(msg.Content))
}
e.processInteractiveEvents(state, session, interactiveKey, msg.MessageID, turnStart)
e.processInteractiveEvents(state, session, interactiveKey, msg.MessageID, turnStart, stopTyping)
stopTyping = nil // ownership transferred; prevent defer from double-stopping
// Guard against a narrow race: a message may have been queued between
// processInteractiveEvents observing an empty queue and returning here
// (session is still locked, so handleMessage's TryLock fails and routes
// the message to queueMessageForBusySession). Drain any such orphans.
//
// When the queue is empty we unlock the session while still holding
// state.mu so that any message arriving after this point will find
// TryLock() succeeds and goes through the normal handleMessage path.
for {
state.mu.Lock()
if len(state.pendingMessages) == 0 {
session.Unlock()
unlocked = true
state.mu.Unlock()
return
}
queued := state.pendingMessages[0]
state.pendingMessages = state.pendingMessages[1:]
state.platform = queued.platform
state.replyCtx = queued.replyCtx
state.fromVoice = queued.fromVoice
state.mu.Unlock()
e.i18n.DetectAndSet(queued.content)
queuedPrompt := queued.content
if e.injectSender && queued.userID != "" {
chatID := extractChannelID(queued.msgSessionKey)
queuedPrompt = fmt.Sprintf("[cc-connect sender_id=%s platform=%s chat_id=%s]\n%s", queued.userID, queued.msgPlatform, chatID, queued.content)
}
// Abort if the agent process has exited — notify remaining queued senders.
if state.agentSession == nil || !state.agentSession.Alive() {
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), "agent session ended"))
e.notifyDroppedQueuedMessages(state, fmt.Errorf("agent session ended"))
return
}
// Drain stale events before starting a new turn (same as processInteractiveMessageWith).
drainEvents(state.agentSession.Events())
if err := state.agentSession.Send(queuedPrompt, queued.images, queued.files); err != nil {
slog.Error("failed to send orphaned queued message", "error", err, "session", interactiveKey)
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
e.notifyDroppedQueuedMessages(state, err)
return
}
session.AddHistory("user", queued.content)
var newStopTyping func()
if ti, ok := queued.platform.(TypingIndicator); ok {
newStopTyping = ti.StartTyping(e.ctx, queued.replyCtx)
}
e.processInteractiveEvents(state, session, interactiveKey, msg.MessageID, time.Now(), newStopTyping)
}
}
// getOrCreateWorkspaceAgent returns (or creates) a per-workspace agent and session manager.
@@ -1603,6 +1834,11 @@ func (e *Engine) cleanupInteractiveState(sessionKey string, expected ...*interac
delete(e.interactiveStates, sessionKey)
e.interactiveMu.Unlock()
// Notify senders of any queued messages that will never be processed.
if ok && state != nil {
e.notifyDroppedQueuedMessages(state, fmt.Errorf("session reset"))
}
if ok && state != nil && state.agentSession != nil {
slog.Debug("cleanupInteractiveState: closing agent session", "session", sessionKey)
closeStart := time.Now()
@@ -1626,13 +1862,22 @@ func (e *Engine) cleanupInteractiveState(sessionKey string, expected ...*interac
const defaultEventIdleTimeout = 2 * time.Hour
func (e *Engine) processInteractiveEvents(state *interactiveState, session *Session, sessionKey string, msgID string, turnStart time.Time) {
func (e *Engine) processInteractiveEvents(state *interactiveState, session *Session, sessionKey string, msgID string, turnStart time.Time, stopTypingFn func()) {
var textParts []string
var segmentStart int // index into textParts: text before this has been sent/displayed
toolCount := 0
waitStart := time.Now()
firstEventLogged := false
// stopTyping tracks the current turn's typing indicator so it can be
// stopped when a queued message starts a new turn.
stopTyping := stopTypingFn
defer func() {
if stopTyping != nil {
stopTyping()
}
}()
state.mu.Lock()
sp := newStreamPreview(e.streamPreview, state.platform, state.replyCtx, e.ctx)
state.mu.Unlock()
@@ -1941,6 +2186,98 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
}
}
// Check for queued messages — if present, continue the event loop
// for the next turn instead of returning.
state.mu.Lock()
if len(state.pendingMessages) > 0 {
queued := state.pendingMessages[0]
state.pendingMessages = state.pendingMessages[1:]
remainingQueue := len(state.pendingMessages)
state.platform = queued.platform
state.replyCtx = queued.replyCtx
state.fromVoice = queued.fromVoice
state.mu.Unlock()
// Stop the previous turn's typing indicator
if stopTyping != nil {
stopTyping()
stopTyping = nil
}
// Start a new typing indicator for the queued message's context
if ti, ok := queued.platform.(TypingIndicator); ok {
stopTyping = ti.StartTyping(e.ctx, queued.replyCtx)
}
// Drain stale events before starting the next turn. Between
// EventResult and Send(), the only buffered events would be
// stale leftovers (e.g. a deferred EventError from cmd.Wait()).
drainEvents(state.agentSession.Events())
// Build prompt content for the queued message
queuedPrompt := queued.content
if e.injectSender && queued.userID != "" {
chatID := extractChannelID(queued.msgSessionKey)
queuedPrompt = fmt.Sprintf("[cc-connect sender_id=%s platform=%s chat_id=%s]\n%s", queued.userID, queued.msgPlatform, chatID, queued.content)
}
// NOW send the queued message to agent stdin (not at queue time).
if err := state.agentSession.Send(queuedPrompt, queued.images, queued.files); err != nil {
slog.Error("failed to send queued message to agent", "error", err, "session", sessionKey)
// Send failed — stop typing, notify user, drain remaining queue.
// We intentionally do NOT attempt to restart the session here:
// the restart path introduces complex state management issues
// (stale session pointers, FIFO ordering, workspace agent
// recreation races). The user's next message will trigger a
// normal session restart via processInteractiveMessageWith.
if stopTyping != nil {
stopTyping()
stopTyping = nil
}
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
state.mu.Lock()
remaining := state.pendingMessages
state.pendingMessages = nil
state.mu.Unlock()
for _, q := range remaining {
e.send(q.platform, q.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
}
return
}
// Detect language now (deferred from queue time to avoid
// flipping locale while the previous turn is still running).
e.i18n.DetectAndSet(queued.content)
// Reset per-turn state for the next turn
textParts = nil
segmentStart = 0
toolCount = 0
turnStart = time.Now()
firstEventLogged = false
waitStart = time.Now()
sp = newStreamPreview(e.streamPreview, queued.platform, queued.replyCtx, e.ctx)
session.AddHistory("user", queued.content)
if idleTimer != nil {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(e.eventIdleTimeout)
}
slog.Info("processing queued message",
"session", sessionKey,
"remaining_queue", remainingQueue,
)
continue
}
state.mu.Unlock()
return
case EventError:
@@ -1949,6 +2286,12 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
slog.Error("agent error", "error", event.Error)
e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgError), event.Error))
}
// Only drop queued messages if the agent session is dead.
// Some agents (e.g. Codex) emit EventError for per-turn failures
// while keeping the session alive for subsequent turns.
if state.agentSession == nil || !state.agentSession.Alive() {
e.notifyDroppedQueuedMessages(state, event.Error)
}
return
}
}
@@ -1956,6 +2299,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
channelClosed:
// Channel closed - process exited unexpectedly
slog.Warn("agent process exited", "session_key", sessionKey)
e.notifyDroppedQueuedMessages(state, fmt.Errorf("agent process exited"))
e.cleanupInteractiveState(sessionKey, state)
if len(textParts) > 0 {
@@ -1987,6 +2331,20 @@ channelClosed:
}
}
// notifyDroppedQueuedMessages drains pendingMessages from the state and
// sends an error notification to each queued message's sender. Called when
// the event loop exits abnormally (EventError, channel closed) and queued
// messages can no longer be delivered to the agent.
func (e *Engine) notifyDroppedQueuedMessages(state *interactiveState, reason error) {
state.mu.Lock()
remaining := state.pendingMessages
state.pendingMessages = nil
state.mu.Unlock()
for _, q := range remaining {
e.send(q.platform, q.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), reason))
}
}
// ──────────────────────────────────────────────────────────────
// Command handling
// ──────────────────────────────────────────────────────────────
@@ -4131,7 +4489,15 @@ func (e *Engine) cmdCompress(p Platform, msg *Message) {
e.send(p, msg.ReplyCtx, e.i18n.T(MsgCompressing))
go func() {
defer session.Unlock()
// session.Unlock() is called inside drainQueuedMessagesAfterCompress
// while holding state.mu to close the race window. Deferred fallback
// ensures the lock is released on early-return paths.
compressUnlocked := false
defer func() {
if !compressUnlocked {
session.Unlock()
}
}()
state.mu.Lock()
state.platform = p
@@ -4149,14 +4515,15 @@ func (e *Engine) cmdCompress(p Platform, msg *Message) {
return
}
e.processCompressEvents(state, msg.SessionKey, p, msg.ReplyCtx)
e.processCompressEvents(state, session, msg.SessionKey, p, msg.ReplyCtx, &compressUnlocked)
}()
}
// processCompressEvents drains agent events after a compress command.
// Unlike processInteractiveEvents it does NOT record history and treats
// an empty result as success rather than "(empty response)".
func (e *Engine) processCompressEvents(state *interactiveState, sessionKey string, p Platform, replyCtx any) {
func (e *Engine) processCompressEvents(state *interactiveState, session *Session, sessionKey string, p Platform, replyCtx any, unlocked *bool) {
var textParts []string
events := state.agentSession.Events()
@@ -4181,11 +4548,13 @@ func (e *Engine) processCompressEvents(state *interactiveState, sessionKey strin
} else {
e.reply(p, replyCtx, e.i18n.T(MsgCompressDone))
}
e.notifyDroppedQueuedMessages(state, fmt.Errorf("agent process exited during compress"))
return
}
case <-idleCh:
e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgError), "compress timed out"))
e.cleanupInteractiveState(sessionKey, state)
e.notifyDroppedQueuedMessages(state, fmt.Errorf("compress timed out"))
return
case <-e.ctx.Done():
return
@@ -4216,11 +4585,22 @@ func (e *Engine) processCompressEvents(state *interactiveState, sessionKey strin
} else {
e.reply(p, replyCtx, e.i18n.T(MsgCompressDone))
}
// After compress succeeds, process any queued messages instead of dropping them.
e.drainQueuedMessagesAfterCompress(state, session, sessionKey, unlocked)
return
case EventError:
if event.Error != nil {
e.reply(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgError), event.Error))
}
// Only drop queued messages if the agent is dead; some agents
// emit per-turn EventError while staying alive.
if !state.agentSession.Alive() {
e.notifyDroppedQueuedMessages(state, event.Error)
} else {
// Agent survived — try to process queued messages.
e.drainQueuedMessagesAfterCompress(state, session, sessionKey, unlocked)
}
return
case EventPermissionRequest:
_ = state.agentSession.RespondPermission(event.RequestID, PermissionResult{
@@ -4231,6 +4611,55 @@ func (e *Engine) processCompressEvents(state *interactiveState, sessionKey strin
}
}
// drainQueuedMessagesAfterCompress processes any messages that were queued
// during a /compress operation. It sends each one to the agent and runs the
// full interactive event loop for it.
func (e *Engine) drainQueuedMessagesAfterCompress(state *interactiveState, session *Session, sessionKey string, unlocked *bool) {
for {
state.mu.Lock()
if len(state.pendingMessages) == 0 {
// Unlock session while holding state.mu to close the race window.
session.Unlock()
*unlocked = true
state.mu.Unlock()
return
}
queued := state.pendingMessages[0]
state.pendingMessages = state.pendingMessages[1:]
state.platform = queued.platform
state.replyCtx = queued.replyCtx
state.fromVoice = queued.fromVoice
state.mu.Unlock()
// Drain stale events from the previous turn (compress or prior queued
// turn) to avoid feeding leftover EventError/exit events into this turn.
drainEvents(state.agentSession.Events())
// Build prompt
queuedPrompt := queued.content
if e.injectSender && queued.userID != "" {
chatID := extractChannelID(queued.msgSessionKey)
queuedPrompt = fmt.Sprintf("[cc-connect sender_id=%s platform=%s chat_id=%s]\n%s", queued.userID, queued.msgPlatform, chatID, queued.content)
}
if err := state.agentSession.Send(queuedPrompt, queued.images, queued.files); err != nil {
slog.Error("failed to send queued message after compress", "error", err, "session", sessionKey)
e.send(queued.platform, queued.replyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
e.notifyDroppedQueuedMessages(state, err)
return
}
session.AddHistory("user", queued.content)
slog.Info("processing queued message after compress",
"session", sessionKey,
)
// Run the full event loop for this queued turn.
e.processInteractiveEvents(state, session, sessionKey, "", time.Now(), nil)
}
}
func (e *Engine) cmdAllow(p Platform, msg *Message, args []string) {
if len(args) == 0 {
if auth, ok := e.agent.(ToolAuthorizer); ok {
+169 -2
View File
@@ -369,7 +369,7 @@ func TestProcessInteractiveEvents_SuppressesDuplicateSideChannelText(t *testing.
}
agentSession.events <- Event{Type: EventResult, Content: sideText, Done: true}
e.processInteractiveEvents(state, session, sessionKey, "m1", time.Now())
e.processInteractiveEvents(state, session, sessionKey, "m1", time.Now(), nil)
if got := p.getSent(); len(got) != 1 || got[0] != sideText {
t.Fatalf("sent text = %#v, want one side-channel message", got)
@@ -399,7 +399,7 @@ func TestProcessInteractiveEvents_DoesNotSuppressDifferentFinalText(t *testing.T
finalText := "文件已发出,另外我也把使用方法整理好了。"
agentSession.events <- Event{Type: EventResult, Content: finalText, Done: true}
e.processInteractiveEvents(state, session, sessionKey, "m1", time.Now())
e.processInteractiveEvents(state, session, sessionKey, "m1", time.Now(), nil)
if got := p.getSent(); len(got) != 2 || got[0] == got[1] {
t.Fatalf("sent text = %#v, want side-channel and final reply", got)
@@ -3457,6 +3457,173 @@ func TestDrainEventsOpenChannel(t *testing.T) {
}
}
// --- Message queuing tests ---
// queuingAgentSession records Send calls and emits events via a controllable channel.
type queuingAgentSession struct {
controllableAgentSession
sendCalls []string
sendMu sync.Mutex
}
func newQueuingSession(id string) *queuingAgentSession {
return &queuingAgentSession{
controllableAgentSession: controllableAgentSession{
sessionID: id,
alive: true,
events: make(chan Event, 16),
closed: make(chan struct{}),
},
}
}
func (s *queuingAgentSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
s.sendMu.Lock()
s.sendCalls = append(s.sendCalls, prompt)
s.sendMu.Unlock()
return nil
}
func TestQueueMessageForBusySession_FIFODequeue(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
sess := newQueuingSession("qs1")
agent := &controllableAgent{nextSession: sess}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
// Set up an interactive state as if a turn is in progress.
state := &interactiveState{
agentSession: sess,
platform: p,
replyCtx: "ctx1",
}
e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()
// Queue two messages while the session is "busy".
msg1 := &Message{SessionKey: key, Content: "msg1", ReplyCtx: "ctx-msg1"}
msg2 := &Message{SessionKey: key, Content: "msg2", ReplyCtx: "ctx-msg2"}
ok1 := e.queueMessageForBusySession(p, msg1, key)
ok2 := e.queueMessageForBusySession(p, msg2, key)
if !ok1 || !ok2 {
t.Fatal("expected both messages to be queued successfully")
}
// Since deferred-send, messages are NOT sent to agent stdin at queue
// time — only metadata is stored. Verify no Send calls occurred.
sess.sendMu.Lock()
if len(sess.sendCalls) != 0 {
t.Fatalf("sendCalls = %v, want [] (deferred send)", sess.sendCalls)
}
sess.sendMu.Unlock()
// Verify pending messages queue has correct FIFO order.
state.mu.Lock()
if len(state.pendingMessages) != 2 {
t.Fatalf("pendingMessages len = %d, want 2", len(state.pendingMessages))
}
if state.pendingMessages[0].content != "msg1" || state.pendingMessages[1].content != "msg2" {
t.Fatalf("pendingMessages = [%s, %s], want [msg1, msg2]",
state.pendingMessages[0].content, state.pendingMessages[1].content)
}
state.mu.Unlock()
}
func TestProcessInteractiveEvents_DrainsQueuedMessages(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
sess := newQueuingSession("qs2")
agent := &controllableAgent{nextSession: sess}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
session := e.sessions.GetOrCreateActive(key)
// Pre-populate the interactive state with one queued message.
state := &interactiveState{
agentSession: sess,
platform: p,
replyCtx: "ctx-turn1",
pendingMessages: []queuedMessage{
{platform: p, replyCtx: "ctx-turn2", content: "queued-msg"},
},
}
e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()
// Simulate the agent completing turn 1 then turn 2.
// Turn 2 events are pushed only after Send() is called for the queued
// message, matching real-world timing where the agent doesn't produce
// events for a turn until it receives the prompt on stdin.
go func() {
// Turn 1 result
sess.events <- Event{Type: EventText, Content: "response1"}
sess.events <- Event{Type: EventResult, Content: "response1", Done: true}
// Wait for the queued message's Send() call before pushing turn 2 events.
sess.sendMu.Lock()
for len(sess.sendCalls) == 0 {
sess.sendMu.Unlock()
time.Sleep(5 * time.Millisecond)
sess.sendMu.Lock()
}
sess.sendMu.Unlock()
// Turn 2 result (for the queued message)
sess.events <- Event{Type: EventText, Content: "response2"}
sess.events <- Event{Type: EventResult, Content: "response2", Done: true}
}()
session.AddHistory("user", "initial-msg")
// processInteractiveEvents should handle both turns.
done := make(chan struct{})
go func() {
e.processInteractiveEvents(state, session, key, "msg1", time.Now(), nil)
close(done)
}()
select {
case <-done:
// ok
case <-time.After(5 * time.Second):
t.Fatal("processInteractiveEvents did not complete in time")
}
// Verify queue is empty after processing.
state.mu.Lock()
remaining := len(state.pendingMessages)
state.mu.Unlock()
if remaining != 0 {
t.Fatalf("pendingMessages after processing = %d, want 0", remaining)
}
// Verify both turns recorded in session history.
history := session.GetHistory(100)
var assistantMsgs []string
for _, h := range history {
if h.Role == "assistant" {
assistantMsgs = append(assistantMsgs, h.Content)
}
}
if len(assistantMsgs) != 2 {
t.Fatalf("assistant history entries = %d, want 2", len(assistantMsgs))
}
// Verify the queued message was also added to history.
var userMsgs []string
for _, h := range history {
if h.Role == "user" {
userMsgs = append(userMsgs, h.Content)
}
}
if len(userMsgs) < 2 {
t.Fatalf("user history entries = %d, want >= 2", len(userMsgs))
}
}
// ── executeCardAction interactiveKey tests ───────────────────
func TestExecuteCardAction_QuietUsesInteractiveKey(t *testing.T) {
+8
View File
@@ -125,6 +125,7 @@ const (
MsgExecutionStopped MsgKey = "execution_stopped"
MsgNoExecution MsgKey = "no_execution"
MsgPreviousProcessing MsgKey = "previous_processing"
MsgMessageQueued MsgKey = "message_queued"
MsgNoToolsAllowed MsgKey = "no_tools_allowed"
MsgCurrentTools MsgKey = "current_tools"
MsgCurrentSession MsgKey = "current_session"
@@ -528,6 +529,13 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "⏳ 前のリクエストを処理中です。`/btw <メッセージ>` で現在のターンにコンテキストを追加できます。",
LangSpanish: "⏳ La solicitud anterior aún se está procesando. Use `/btw <mensaje>` para agregar contexto al turno actual.",
},
MsgMessageQueued: {
LangEnglish: "📬 Message received — will process after the current task finishes.",
LangChinese: "📬 消息已收到,将在当前任务完成后处理。",
LangTraditionalChinese: "📬 訊息已收到,將在目前任務完成後處理。",
LangJapanese: "📬 メッセージを受信しました。現在のタスク完了後に処理します。",
LangSpanish: "📬 Mensaje recibido — se procesará después de que termine la tarea actual.",
},
MsgNoToolsAllowed: {
LangEnglish: "No tools pre-allowed.\nUsage: `/allow <tool_name>`\nExample: `/allow Bash`",
LangChinese: "尚未预授权任何工具。\n用法: `/allow <工具名>`\n示例: `/allow Bash`",