Files
dingtalk-workspace-cli/internal/helpers/connect_queue_test.go
修雨 c151756168 feat(connect): Q&A-bot hardening for the stream bridge
Five operational gaps that bite when a connector-backed bot answers
questions in real groups:

- Single-instance lock: one connector per robot per machine (pid file,
  stale-lock takeover). Duplicate Stream connections on one clientId get
  messages load-balanced between them, so the bot answers intermittently
  (verified live).
- Access policy: --allowed-users / --allowed-groups allowlists plus a
  per-sender rate limit (--user-rate-limit, default 20/min). Every
  message is an LLM call; without this anyone who can reach the bot can
  spend on its behalf.
- Per-conversation FIFO queue: same-chat messages run in arrival order
  (follow-ups need the previous turn's --resume session; parallel CLIs
  racing one session corrupt it); different chats stay parallel.
- Picture messages: resolve the callback downloadCode via robot
  messageFiles/download to a local temp file and hand the path to the
  agent CLI — error screenshots are the top Q&A inbound and were
  previously dropped silently. The claudecode persona now permits
  reading attached files.
- --knowledge-dir: lightweight local retrieval (markdown chunking,
  latin-token + CJK-bigram scoring) prepends top-k matching chunks to
  each question while the agent keeps running from the clean scratch
  dir, so replies stay inside DingTalk's AI-assistant response window.

All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS /
DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs.

go build ./... clean; go test ./internal/helpers -race passes with new
tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts
and test/unit fail identically on the unmodified base (pre-existing
fixture issues).
2026-06-11 12:18:25 +08:00

94 lines
2.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"sync"
"testing"
"time"
)
// TestConvQueueSerialSameConversation: tasks of one conversation run in
// arrival order even when earlier tasks are slow.
func TestConvQueueSerialSameConversation(t *testing.T) {
q := newConvQueue()
var mu sync.Mutex
var order []int
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
i := i
wg.Add(1)
q.run("conv-1", func() {
defer wg.Done()
if i == 1 {
time.Sleep(50 * time.Millisecond) // slow head must not be overtaken
}
mu.Lock()
order = append(order, i)
mu.Unlock()
})
}
wg.Wait()
for i, v := range order {
if v != i+1 {
t.Fatalf("order = %v, want 1..5 in sequence", order)
}
}
}
// TestConvQueueParallelAcrossConversations: a slow conversation must not
// block another conversation.
func TestConvQueueParallelAcrossConversations(t *testing.T) {
q := newConvQueue()
slowRelease := make(chan struct{})
slowStarted := make(chan struct{})
q.run("conv-slow", func() {
close(slowStarted)
<-slowRelease
})
<-slowStarted
fastDone := make(chan struct{})
q.run("conv-fast", func() { close(fastDone) })
select {
case <-fastDone:
case <-time.After(2 * time.Second):
t.Fatal("fast conversation blocked behind slow one")
}
close(slowRelease)
}
// TestConvQueueDrainsEntries: once a conversation's queue drains, its chain
// entry is dropped (idle chats hold no memory).
func TestConvQueueDrainsEntries(t *testing.T) {
q := newConvQueue()
var wg sync.WaitGroup
wg.Add(1)
q.run("conv-1", func() { wg.Done() })
wg.Wait()
deadline := time.Now().Add(2 * time.Second)
for {
q.mu.Lock()
n := len(q.tails)
q.mu.Unlock()
if n == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("queue entries not cleaned up: %d left", n)
}
time.Sleep(5 * time.Millisecond)
}
}