Files
dingtalk-workspace-cli/internal/helpers/devapp_connect_test.go
T
修雨 995d490a76 feat(devapp): give robot connect session memory, model and workdir tuning
Users reported the linked bot feels dumb. Root cause: the forwarder was
hard-pinned to a stateless one-shot haiku run from an empty directory. Expose
the trade-offs as command-set flags instead of hardcoding them:

- --agent-memory (default on): per-conversation session resume. First message
  of a DingTalk conversation mints a UUID and passes `--session-id`,
  follow-ups pass `--resume`, so multi-turn context survives. Only on CLIs
  with addressable sessions (claudecode/codebuddy/workbuddy — verified against
  their --help); qodercli has --resume but no --session-id, so the qoder
  family stays stateless. Broken sessions self-heal: on forward error the
  conversation mapping is dropped and the next message starts fresh.
- --agent-model / DWS_AGENT_MODEL: override the channel CLI's model via each
  spec's model flag (replaces claudecode's built-in haiku pin in place).
- --agent-workdir / DWS_AGENT_WORKDIR: run the agent from a knowledge
  directory for context, instead of the clean temp dir (which stays the
  default to keep cold-start inside DingTalk's reply window).

A DWS_AGENT_CMD override disables flag splicing entirely — that argv is
user-owned and we cannot know which flags it accepts. Dry-run now previews
the effective agent tuning (model/workdir/memory) per channel.
2026-06-10 11:24:54 +08:00

265 lines
9.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 (
"os"
"path/filepath"
"testing"
)
// clearChannelEnv clears every env var that participates in channel detection
// (empty == treated as unset), so the test host's own QODER_CLI etc. cannot
// leak into a case. t.Setenv restores them when the case ends.
func clearChannelEnv(t *testing.T) {
for _, k := range []string{
"DWS_AGENT_CHANNEL", "DINGTALK_AGENT", "OPENCLAW", "OPENCLAW_GATEWAY",
"HERMES_AGENT", "HERMES", "QODER_CLI", "QODERCLI_INTEGRATION_MODE",
"DWS_CONNECT_CMD", "DWS_AGENT_CMD",
"WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE",
} {
t.Setenv(k, "")
}
}
func TestResolveConnectChannel(t *testing.T) {
cases := []struct {
name string
flag string
env map[string]string
wantChannel string
wantDetectedBy string
}{
{"explicit flag wins", "openclaw", map[string]string{"DWS_AGENT_CHANNEL": "qoder", "QODER_CLI": "1"}, "openclaw", "flag:--channel"},
{"env overrides signal", "auto", map[string]string{"DWS_AGENT_CHANNEL": "qoderwork", "QODER_CLI": "1"}, "qoderwork", "env:DWS_AGENT_CHANNEL"},
{"signal openclaw(DINGTALK_AGENT)", "auto", map[string]string{"DINGTALK_AGENT": "DING_DWS_CLAW"}, "openclaw", "signal:DINGTALK_AGENT"},
{"signal openclaw(OPENCLAW)", "", map[string]string{"OPENCLAW": "1"}, "openclaw", "signal:OPENCLAW"},
{"signal qoder family", "", map[string]string{"QODER_CLI": "1"}, "qoder", "signal:QODER_CLI"},
{"signal qoderwork(INTEGRATION_MODE)", "auto", map[string]string{"QODERCLI_INTEGRATION_MODE": "qoder_work"}, "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"},
{"qoderwork precedes qoder/claudecode", "auto", map[string]string{"QODERCLI_INTEGRATION_MODE": "qoder_work", "QODER_CLI": "1", "CLAUDECODE": "1"}, "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"},
{"signal hermes", "auto", map[string]string{"HERMES_AGENT": "1"}, "hermes", "signal:HERMES"},
{"signal workbuddy(WORKBUDDY_CONFIG_DIR)", "auto", map[string]string{"WORKBUDDY_CONFIG_DIR": "/Users/x/.workbuddy"}, "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"},
{"signal workbuddy(WORKBUDDY_APP_NAME)", "auto", map[string]string{"WORKBUDDY_APP_NAME": "WorkBuddy"}, "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"},
{"signal claudecode", "auto", map[string]string{"CLAUDECODE": "1"}, "claudecode", "signal:CLAUDECODE"},
{"qoder fork precedes claudecode", "auto", map[string]string{"QODER_CLI": "1", "CLAUDECODE": "1"}, "qoder", "signal:QODER_CLI"},
{"undetected", "auto", nil, "", "undetected"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
clearChannelEnv(t)
for k, v := range tc.env {
t.Setenv(k, v)
}
ch, by := resolveConnectChannel(tc.flag)
if ch != tc.wantChannel || by != tc.wantDetectedBy {
t.Fatalf("resolveConnectChannel(%q) = (%q,%q), want (%q,%q)", tc.flag, ch, by, tc.wantChannel, tc.wantDetectedBy)
}
})
}
}
func TestConnectChannelsKnown(t *testing.T) {
for _, ch := range []string{"openclaw", "qoder", "qoderwork", "hermes", "workbuddy", "claudecode", "codebuddy"} {
if _, ok := connectChannels[ch]; !ok {
t.Errorf("channel %q should be in connectChannels", ch)
}
}
if _, ok := connectChannels["weird"]; ok {
t.Error("unknown channel should not be in connectChannels")
}
}
func TestBuildConnectPlanMethod(t *testing.T) {
want := map[string]string{
"openclaw": "openclaw-connector",
"qoder": "stream-bridge",
"qoderwork": "stream-bridge",
"hermes": "official-channel",
"workbuddy": "stream-bridge",
"claudecode": "stream-bridge",
"codebuddy": "stream-bridge",
"weird": "unknown",
}
for ch, m := range want {
got, _ := buildConnectPlan(ch, "cid", "rc")["method"].(string)
if got != m {
t.Errorf("buildConnectPlan(%q).method = %q, want %q", ch, got, m)
}
}
}
func TestConnectExternalCommand(t *testing.T) {
t.Run("DWS_CONNECT_CMD override (applies to all channels)", func(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_CONNECT_CMD", "my-bridge --flag x")
want := []string{"my-bridge", "--flag", "x"}
for _, ch := range []string{"qoder", "workbuddy", "openclaw", "hermes"} {
if got := connectExternalCommand(ch); !equalStringSlice(got, want) {
t.Fatalf("channel %q: got %v, want %v", ch, got, want)
}
}
})
t.Run("stream-bridge channels go Go-native, no external command", func(t *testing.T) {
clearChannelEnv(t)
for _, ch := range []string{"qoder", "qoderwork", "claudecode", "codebuddy", "workbuddy"} {
if got := connectExternalCommand(ch); got != nil {
t.Errorf("stream-bridge channel %q should return nil (Go-native), got %v", ch, got)
}
if !isStreamBridgeChannel(ch) {
t.Errorf("channel %q should be recognised as stream-bridge", ch)
}
}
})
t.Run("openclaw uses external gateway", func(t *testing.T) {
clearChannelEnv(t)
if got := connectExternalCommand("openclaw"); len(got) == 0 || got[0] != "openclaw" {
t.Errorf("openclaw default should be openclaw ..., got %v", got)
}
if isStreamBridgeChannel("openclaw") {
t.Error("openclaw should not be a stream-bridge channel")
}
})
t.Run("hermes has no built-in command", func(t *testing.T) {
clearChannelEnv(t)
if got := connectExternalCommand("hermes"); got != nil {
t.Errorf("hermes with no built-in command should return nil, got %v", got)
}
})
}
func TestForwarderForChannel(t *testing.T) {
clearChannelEnv(t)
// Every stream-bridge channel now yields an execForwarder (local CLI product,
// no bridge/session). Use DWS_AGENT_CMD so the test does not depend on the
// agent binaries being installed on the test machine.
t.Setenv("DWS_AGENT_CMD", "fake-cli --flag")
for ch := range agentSpecs {
fwd, err := forwarderForChannel(ch, connectAgentOptions{})
if err != nil {
t.Fatalf("forwarderForChannel(%q) err = %v", ch, err)
}
ef, ok := fwd.(*execForwarder)
if !ok {
t.Errorf("channel %q should yield *execForwarder, got %T", ch, fwd)
continue
}
if !equalStringSlice(ef.argv, []string{"fake-cli", "--flag"}) {
t.Errorf("channel %q DWS_AGENT_CMD not applied: argv = %v", ch, ef.argv)
}
}
// Non-agent channel has no forwarder.
if _, err := forwarderForChannel("openclaw", connectAgentOptions{}); err == nil {
t.Error("openclaw should not yield a forwarder")
}
}
func TestAgentSpecsCoverMainstreamAgents(t *testing.T) {
// The mainstream agents the bot must support, all present as channels.
for _, ch := range []string{
"claudecode", "codex", "gemini", "opencode",
"qoder", "qoderwork", "codebuddy", "workbuddy",
} {
spec, ok := agentSpecs[ch]
if !ok {
t.Errorf("agentSpecs missing channel %q", ch)
continue
}
if len(spec.bins) == 0 {
t.Errorf("channel %q has no bins", ch)
}
if spec.hint == "" {
t.Errorf("channel %q has no install hint", ch)
}
if _, ok := connectChannels[ch]; !ok {
t.Errorf("channel %q not in connectChannels", ch)
}
if !isStreamBridgeChannel(ch) {
t.Errorf("channel %q should be stream-bridge", ch)
}
}
}
func TestMsgDedup(t *testing.T) {
d := newMsgDedup(3)
if !d.first("a") {
t.Fatal("first a should be new")
}
if d.first("a") {
t.Fatal("second a should be a duplicate")
}
if !d.first("b") || !d.first("c") {
t.Fatal("b and c should be new")
}
// seen now holds {a,b,c} == limit; the next new id triggers a reset.
if !d.first("x") {
t.Fatal("x should be new (and trigger reset at limit)")
}
// After the reset, a was evicted, so it is treated as new again.
if !d.first("a") {
t.Fatal("a should be new again after reset")
}
}
func TestLocateBinary(t *testing.T) {
// On PATH: sh exists on every unix test host.
if _, ok := locateBinary([]string{"sh"}, nil); !ok {
t.Error("sh should be found on PATH")
}
// Miss: nonexistent name + non-matching glob.
if _, ok := locateBinary([]string{"definitely-not-a-real-binary-xyz"}, []string{"/no/such/path/*/nope"}); ok {
t.Error("should not find a nonexistent binary")
}
// Glob hit: a real file matched by a wildcard dir.
dir := t.TempDir()
sub := filepath.Join(dir, "archX")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(sub, "mycli")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, ok := locateBinary([]string{"no-such"}, []string{filepath.Join(dir, "*", "mycli")})
if !ok || got != bin {
t.Errorf("glob locate = (%q,%v), want (%q,true)", got, ok, bin)
}
}
func TestResolveExecAgentOverride(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_AGENT_CMD", "my-agent --foo bar")
argv, env, err := resolveExecAgent("qoder")
if err != nil {
t.Fatalf("err = %v", err)
}
if !equalStringSlice(argv, []string{"my-agent", "--foo", "bar"}) {
t.Errorf("override argv = %v", argv)
}
if env != nil {
t.Errorf("override env should be nil, got %v", env)
}
}
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}