Files
dingtalk-workspace-cli/internal/helpers/connect_agent_options_test.go
修雨 d78de010ff Merge remote-tracking branch 'origin/main' into codex/test-coverage-100-v2
# Conflicts:
#	internal/cli/stdin_test.go
#	internal/helpers/atomicwrite_test.go
#	internal/helpers/connect_agent_options_test.go
#	internal/helpers/connect_codex_appserver_test.go
#	internal/helpers/connect_daemon_test.go
#	internal/helpers/connect_lock.go
#	internal/helpers/doc.go
2026-07-15 23:14:16 +08:00

375 lines
14 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 (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// writeExecStub drops a PATH candidate named name into dir so dependency
// discovery resolves without the real CLI installed. These stubs are never
// executed; Windows only needs a PATHEXT-recognized regular file.
func writeExecStub(dir, name string) error {
if runtime.GOOS == "windows" {
name += ".exe"
return os.WriteFile(filepath.Join(dir, name), []byte("test executable stub"), 0o755)
}
return os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
}
func requirePOSIXShell(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("test fixture requires a POSIX shell executable")
}
}
// TestConvSessions covers the per-conversation session contract: first message
// creates (--session-id), follow-ups resume (--resume), reset re-mints.
func TestConvSessions(t *testing.T) {
s := newConvSessions("")
first := s.args("conv-1")
if len(first) != 2 || first[0] != "--session-id" || first[1] == "" {
t.Fatalf("first args = %v, want [--session-id <uuid>]", first)
}
second := s.args("conv-1")
if len(second) != 2 || second[0] != "--resume" || second[1] != first[1] {
t.Fatalf("second args = %v, want [--resume %s]", second, first[1])
}
// A different conversation gets its own session.
other := s.args("conv-2")
if other[0] != "--session-id" || other[1] == first[1] {
t.Fatalf("conv-2 args = %v, want a fresh --session-id distinct from %s", other, first[1])
}
// reset self-heals a broken session: next message starts fresh with a NEW
// uuid (the old one may or may not exist agent-side; a new one is safe).
s.reset("conv-1")
again := s.args("conv-1")
if again[0] != "--session-id" || again[1] == first[1] {
t.Fatalf("post-reset args = %v, want fresh --session-id != %s", again, first[1])
}
}
// TestApplyModelArg covers both shapes: replacing an existing model pin
// (claudecode's built-in haiku) and inserting before a prompt tail that must
// stay trailing.
func TestApplyModelArg(t *testing.T) {
replaced := applyModelArg(
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
"--model", "claude-sonnet-4-6")
want := []string{"claude", "-p", "--model", "claude-sonnet-4-6", "--strict-mcp-config"}
if strings.Join(replaced, " ") != strings.Join(want, " ") {
t.Fatalf("replace: got %v, want %v", replaced, want)
}
inserted := applyModelArg([]string{"gemini", "-p"}, "-m", "gemini-2.5-pro")
wantIns := []string{"gemini", "-m", "gemini-2.5-pro", "-p"}
if strings.Join(inserted, " ") != strings.Join(wantIns, " ") {
t.Fatalf("insert: got %v, want %v", inserted, wantIns)
}
}
// TestForwarderSessionAndModelWiring checks forwarderForChannel applies the
// options: memory only on ccSessions channels, model only via the spec's flag.
func TestForwarderSessionAndModelWiring(t *testing.T) {
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
t.Setenv("DWS_AGENT_CMD", "") // ensure no override
// Use DWS_AGENT_CMD-free resolution; claudecode requires the binary on
// PATH, which CI may lack — fake it via DWS_AGENT_CMD is wrong (disables
// extras by design), so point PATH at a stub.
stub := t.TempDir()
for _, name := range []string{"claude", "qodercli"} {
if err := writeExecStub(stub, name); err != nil {
t.Fatalf("stub %s: %v", name, err)
}
}
t.Setenv("PATH", stub)
fwd, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: true, Model: "claude-sonnet-4-6"})
if err != nil {
t.Fatalf("claudecode forwarder: %v", err)
}
ef := fwd.(*execForwarder)
if ef.sessions == nil {
t.Fatal("claudecode with Memory=true should have sessions enabled")
}
if !strings.Contains(strings.Join(ef.argv, " "), "--model claude-sonnet-4-6") {
t.Fatalf("model not applied: %v", ef.argv)
}
if strings.Contains(strings.Join(ef.argv, " "), "haiku") {
t.Fatalf("built-in haiku pin should be replaced: %v", ef.argv)
}
// qoder family runs a persistent stream-json subprocess and carries the
// addressable Qoder session id inside each JSON user message. DWS persists
// the mapping so a connector restart can resume the same conversation.
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
qf, err := forwarderForChannel("qoder", "qoder-client", connectAgentOptions{Memory: true})
if err != nil {
t.Fatalf("qoder forwarder: %v", err)
}
qsf := qf.(*qoderStreamForwarder)
if qsf.sessions == nil {
t.Fatal("qoder with Memory=true should have sessions")
}
if qsf.sessions.path == "" {
t.Fatal("qoder sessions should persist to disk")
}
if got := strings.Join(qsf.commandArgs(), " "); !strings.Contains(got, "--input-format stream-json") || !strings.Contains(got, "--output-format stream-json") {
t.Fatalf("qoder stream-json args mismatch: argv = %v", qsf.commandArgs())
}
qwf, err := forwarderForChannel("qoderwork", "robot-client", connectAgentOptions{Memory: true})
if err != nil {
t.Fatalf("qoderwork forwarder: %v", err)
}
qwsf := qwf.(*qoderStreamForwarder)
if qwsf.sessions == nil {
t.Fatal("qoderwork with Memory=true should have sessions")
}
if qwsf.sessions.path == "" {
t.Fatal("qoderwork sessions should persist to disk")
}
// Memory off on a supporting channel.
off, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: false})
if err != nil {
t.Fatalf("claudecode memory-off forwarder: %v", err)
}
if off.(*execForwarder).sessions != nil {
t.Fatal("Memory=false must disable sessions")
}
}
func TestBuiltInAgentForwardersDefaultToPureChannelPermissions(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
t.Setenv("DWS_AGENT_CMD", "")
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
stub := t.TempDir()
for _, name := range []string{"claude", "codebuddy", "codex", "opencode", "qodercli"} {
if err := writeExecStub(stub, name); err != nil {
t.Fatalf("stub %s: %v", name, err)
}
}
t.Setenv("PATH", stub)
cases := []struct {
channel string
want []string
}{
{"claudecode", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
{"codebuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
{"workbuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
}
for _, tc := range cases {
t.Run(tc.channel, func(t *testing.T) {
fwd, err := forwarderForChannel(tc.channel, "", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(%q): %v", tc.channel, err)
}
ef, ok := fwd.(*execForwarder)
if !ok {
t.Fatalf("%s forwarder = %T, want *execForwarder", tc.channel, fwd)
}
got := strings.Join(ef.argv, " ")
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Fatalf("%s argv missing %q: %s", tc.channel, want, got)
}
}
if len(ef.streamArgv) > 0 {
streamGot := strings.Join(ef.streamArgv, " ")
for _, want := range tc.want {
if !strings.Contains(streamGot, want) {
t.Fatalf("%s stream argv missing %q: %s", tc.channel, want, streamGot)
}
}
}
})
}
t.Run("gemini", func(t *testing.T) {
t.Setenv("GEMINI_API_KEY", "test-key")
fwd, err := forwarderForChannel("gemini", "gemini-client", connectAgentOptions{Memory: true, Yolo: true, Model: "gemini-test"})
if err != nil {
t.Fatalf("forwarderForChannel(gemini): %v", err)
}
gf, ok := fwd.(*geminiAPIForwarder)
if !ok {
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
}
if gf.model != "gemini-test" {
t.Fatalf("gemini model = %q, want gemini-test", gf.model)
}
})
for _, ch := range []string{"qoder", "qoderwork"} {
t.Run(ch, func(t *testing.T) {
fwd, err := forwarderForChannel(ch, "client-"+ch, connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(%q): %v", ch, err)
}
qf, ok := fwd.(*qoderStreamForwarder)
if !ok {
t.Fatalf("%s forwarder = %T, want *qoderStreamForwarder", ch, fwd)
}
got := strings.Join(qf.commandArgs(), " ")
for _, want := range []string{"--permission-mode", "bypass_permissions", "--dangerously-skip-permissions"} {
if !strings.Contains(got, want) {
t.Fatalf("%s command args missing %q: %s", ch, want, got)
}
}
})
}
t.Run("codex", func(t *testing.T) {
fwd, err := forwarderForChannel("codex", "codex-client", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(codex): %v", err)
}
cf, ok := fwd.(*codexAppServerForwarder)
if !ok {
t.Fatalf("codex forwarder = %T, want *codexAppServerForwarder", fwd)
}
params := cf.threadParams("")
if got := params["approvalPolicy"]; got != "never" {
t.Fatalf("codex approvalPolicy = %v, want never", got)
}
if got := params["sandbox"]; got != "workspace-write" {
t.Fatalf("codex yolo sandbox = %v, want workspace-write", got)
}
})
t.Run("opencode", func(t *testing.T) {
fwd, err := forwarderForChannel("opencode", "opencode-client", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(opencode): %v", err)
}
of, ok := fwd.(*opencodeForwarder)
if !ok {
t.Fatalf("opencode forwarder = %T, want *opencodeForwarder", fwd)
}
env := of.server.commandEnv("pw")
if !strings.Contains(envValue(env, opencodeConfigContentEnv), `"question":false`) {
t.Fatalf("opencode config should disable question tool: %s", envValue(env, opencodeConfigContentEnv))
}
if !strings.Contains(envValue(env, opencodePermissionEnv), `"question":"deny"`) {
t.Fatalf("opencode permission should deny question: %s", envValue(env, opencodePermissionEnv))
}
if !strings.Contains(envValue(env, opencodePermissionEnv), `"bash":"allow"`) {
t.Fatalf("opencode permission should allow gated tools: %s", envValue(env, opencodePermissionEnv))
}
})
}
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
// dry-run preview so callers can see the effective agent tuning.
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "connect",
"--channel", "claudecode",
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
"--agent-model", "claude-sonnet-4-6", "--agent-workdir", "/tmp/kb",
"--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\n%s", err, out.String())
}
for _, sub := range []string{"claude-sonnet-4-6", "/tmp/kb", "per-conversation"} {
if !strings.Contains(out.String(), sub) {
t.Fatalf("dry-run output missing %q:\n%s", sub, out.String())
}
}
// memory=false shows as disabled.
out.Reset()
root = newDevAppTestRoot(&captureRunner{})
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "connect",
"--channel", "claudecode",
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
"--agent-memory=false", "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\n%s", err, out.String())
}
if !strings.Contains(out.String(), `"memory": "disabled"`) {
t.Fatalf("dry-run output missing disabled memory:\n%s", out.String())
}
}
func TestQoderAgentMemoryPayloadIsPerConversation(t *testing.T) {
payload := connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: true})
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
t.Fatalf("qoder memory = %v, want per-conversation-qoder-stream", got)
}
payload = connectAgentOptionsPayload("qoderwork", connectAgentOptions{Memory: true})
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
t.Fatalf("qoderwork memory = %v, want per-conversation-qoder-stream", got)
}
payload = connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: false})
if got := payload["memory"]; got != "disabled" {
t.Fatalf("qoder memory disabled = %v, want disabled", got)
}
}
// TestRobotConnectDryRunShowsCliStatus checks the dependency preflight agents
// rely on: dry-run reports whether the channel CLI is installed, with the
// install hint when missing.
func TestRobotConnectDryRunShowsCliStatus(t *testing.T) {
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
stub := t.TempDir()
if err := writeExecStub(stub, "claude"); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", stub)
run := func(channel string) string {
root := newDevAppTestRoot(&captureRunner{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "connect", "--channel", channel,
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%s): %v\n%s", channel, err, out.String())
}
return out.String()
}
// claude on PATH → installed true with path
if got := run("claudecode"); !strings.Contains(got, `"installed": true`) {
t.Fatalf("claudecode should be installed:\n%s", got)
}
// codex NOT on PATH → installed false + hint
got := run("codex")
if !strings.Contains(got, `"installed": false`) || !strings.Contains(got, "@openai/codex") {
t.Fatalf("codex should be missing with hint:\n%s", got)
}
if !strings.Contains(got, "per-conversation-app-server") {
t.Fatalf("codex dry-run should advertise app-server memory:\n%s", got)
}
}