Files
multica/server/pkg/agent/traecli_test.go
Xisheng Parker Zhao 4b9ea4aa68 feat(agent): add ByteDance TRAE CLI (traecli) as an ACP backend (#4724)
Adds the official ByteDance TRAE CLI (the `traecli` binary documented at
https://docs.trae.cn/cli — the product paired with the Trae IDE, not the
open-source bytedance/trae-agent) as a built-in agent backend. traecli is
ACP-native, so it is driven over the standard ACP JSON-RPC transport via
`traecli acp serve --yolo`, reusing the shared hermesClient exactly like the
Kiro and Qoder backends.

Validated end-to-end against the real traecli v0.120.42 with a logged-in
account: initialize advertises loadSession:true + mcpCapabilities{http,sse};
session/new returns result.sessionId + models.availableModels (18 models
discovered); session/prompt streams session/update notifications with
sessionUpdate=agent_message_chunk (hermesClient already normalizes this Zed-ACP
wire shape); a real board task ran 14 tool calls and completed in ~47s.

Implementation:
- server/pkg/agent/traecli.go: ACP backend; session/load resume
  (loadSession:true), session/set_model, MCP via ACP mcpServers, --yolo
  bypass-permissions for headless runs, blocked-arg filtering (acp, serve,
  --yolo, --print, --output-format, --permission-mode)
- agent.go: New() + launch header "traecli acp serve"
- models.go: discoverTraecliModels via the shared discoverACPModels
- daemon/config.go: auto-detect the `traecli` binary
  (MULTICA_TRAECLI_PATH / MULTICA_TRAECLI_MODEL)
- daemon.go: inline the runtime brief (traecli reads .trae/rules/, not
  AGENTS.md) and surface the runtime as "Trae" (providerDisplayName)
- execenv: AGENTS.md + .traecli/skills wiring; ~/.traecli/skills local root
- packages/core mcp-support: traecli consumes mcp_config
- frontend: official Trae provider logo
- docs: providers.mdx matrix + section, CLI_AND_DAEMON.md, README

Tests: fake-ACP unit tests matching the real wire format (streaming,
blocked-arg filtering, session/set_model failure, session/load resume) plus a
gated real-binary smoke test (TestTraecliRealACPSmoke) that skips when traecli
is absent or not logged in. Built-in provider only (mirrors qoder): not in
SupportedTypes / RUNTIME_PROFILE_PROTOCOL_FAMILIES, so no migration is needed.

Resolves #4376.
2026-07-01 13:19:06 +08:00

349 lines
12 KiB
Go

package agent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"log/slog"
)
func TestNewReturnsTraecliBackend(t *testing.T) {
t.Parallel()
b, err := New("traecli", Config{ExecutablePath: "/nonexistent/traecli"})
if err != nil {
t.Fatalf("New(traecli) error: %v", err)
}
if _, ok := b.(*traecliBackend); !ok {
t.Fatalf("expected *traecliBackend, got %T", b)
}
}
// fakeTraecliACPScript impersonates the official `traecli acp serve` for unit
// tests. It speaks the SAME wire format the real traecli v0.120.42 emits
// (captured live): method "session/update" with an "update.sessionUpdate"
// discriminator (agent_thought_chunk / agent_message_chunk), session/new
// returning result.sessionId + models.availableModels [{modelId,name,
// description}], and session/prompt returning {stopReason:end_turn}.
func fakeTraecliACPScript() string {
return `#!/bin/sh
if [ -n "$TRAECLI_ARGS_FILE" ]; then
for arg in "$@"; do
printf '%s\n' "$arg" >> "$TRAECLI_ARGS_FILE"
done
fi
while IFS= read -r line; do
if [ -n "$TRAECLI_REQUESTS_FILE" ]; then
printf '%s\n' "$line" >> "$TRAECLI_REQUESTS_FILE"
fi
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true}}}}\n' "$id"
;;
*'"method":"session/new"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"availableModels":[{"modelId":"Doubao-Seed-2.1-Pro","name":"Doubao-Seed-2.1-Pro","description":""},{"modelId":"GLM-5.2","name":"GLM-5.2","description":""}]}}}\n' "$id"
;;
*'"method":"session/load"'*)
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_loaded","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"history replay ignored"}}}}\n'
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
;;
*'"method":"session/set_model"'*)
case "$line" in
*bogus-model*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
exit 0
;;
*)
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
;;
esac
;;
*'"method":"session/prompt"'*)
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking about it"}}}}\n'
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call","toolCallId":"tc-1","name":"Shell","status":"pending","parameters":{"command":"echo hi"}}}}\n'
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-1","status":"completed","name":"Shell","output":"hi\\n"}}}\n'
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"pong"}}}}\n'
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id"
exit 0
;;
esac
done
`
}
func TestTraecliBackendStreamsAndCompletes(t *testing.T) {
t.Parallel()
fakePath := filepath.Join(t.TempDir(), "traecli")
writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript()))
backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new traecli backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "say pong", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
var messages []Message
done := make(chan struct{})
go func() {
defer close(done)
for m := range session.Messages {
messages = append(messages, m)
}
}()
result := <-session.Result
<-done
if result.Status != "completed" {
t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error)
}
if !strings.Contains(result.Output, "pong") {
t.Fatalf("output = %q, want it to contain the assistant message 'pong'", result.Output)
}
if result.SessionID != "ses_new" {
t.Fatalf("session id = %q, want ses_new", result.SessionID)
}
// The agent_message_chunk must surface as MessageText; the tool_call must
// surface as a MessageToolUse normalized to a canonical tool name.
var sawText, sawToolUse bool
for _, m := range messages {
if m.Type == MessageText && strings.Contains(m.Content, "pong") {
sawText = true
}
if m.Type == MessageToolUse && m.Tool == "terminal" {
sawToolUse = true
}
}
if !sawText {
t.Error("expected a MessageText carrying the assistant 'pong'")
}
if !sawToolUse {
t.Errorf("expected the Shell tool_call to normalize to 'terminal'; messages=%+v", messages)
}
}
func TestTraecliBlockedArgsFiltering(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "traecli")
writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript()))
backend, err := New("traecli", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{"TRAECLI_ARGS_FILE": argsFile},
})
if err != nil {
t.Fatalf("new traecli backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "task", ExecOptions{
Timeout: 5 * time.Second,
// Users must not be able to strip ACP mode, switch to print mode,
// override the permission mode, or duplicate the subcommand.
CustomArgs: []string{"acp", "serve", "--yolo", "-p", "--output-format", "json", "--permission-mode", "default", "--add-dir", "/tmp/extra"},
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
wantPrefix := []string{"acp", "serve", "--yolo"}
if len(lines) < len(wantPrefix) {
t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines)
}
for i, want := range wantPrefix {
if lines[i] != want {
t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines)
}
}
// The hardcoded prefix must appear exactly once each.
joined := strings.Join(lines, " ")
for _, once := range []string{"acp", "serve", "--yolo"} {
if c := countTokens(lines, once); c != 1 {
t.Errorf("expected exactly one %q, got %d (full: %q)", once, c, joined)
}
}
for _, blocked := range []string{"-p", "--output-format", "json", "--permission-mode", "default"} {
for _, got := range lines {
if got == blocked {
t.Errorf("blocked custom arg %q survived filtering: %q", blocked, lines)
}
}
}
// An allowed custom arg must survive.
if !strings.Contains(joined, "--add-dir /tmp/extra") {
t.Errorf("expected allowed custom arg --add-dir to survive, got %q", joined)
}
}
func countTokens(lines []string, tok string) int {
n := 0
for _, l := range lines {
if l == tok {
n++
}
}
return n
}
func TestTraecliSetModelFailureFailsTask(t *testing.T) {
t.Parallel()
fakePath := filepath.Join(t.TempDir(), "traecli")
writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript()))
backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new traecli backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "task", ExecOptions{Model: "bogus-model", Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := <-session.Result
if result.Status != "failed" {
t.Fatalf("expected failed on set_model error, got %q", result.Status)
}
if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) {
t.Errorf("expected error to name the model, got %q", result.Error)
}
if !strings.Contains(result.Error, "model not available") {
t.Errorf("expected upstream message surfaced, got %q", result.Error)
}
}
func TestTraecliUsesSessionLoadForResume(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
requestsFile := filepath.Join(tempDir, "requests.jsonl")
fakePath := filepath.Join(tempDir, "traecli")
writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript()))
backend, err := New("traecli", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{"TRAECLI_REQUESTS_FILE": requestsFile},
})
if err != nil {
t.Fatalf("new traecli backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "continue", ExecOptions{
ResumeSessionID: "ses_existing",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := <-session.Result
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error)
}
if result.SessionID != "ses_existing" {
t.Fatalf("session id = %q, want ses_existing", result.SessionID)
}
raw, err := os.ReadFile(requestsFile)
if err != nil {
t.Fatalf("read requests: %v", err)
}
requests := string(raw)
if !strings.Contains(requests, `"method":"session/load"`) {
t.Fatalf("expected session/load on resume, got:\n%s", requests)
}
if strings.Contains(requests, `"method":"session/resume"`) {
t.Fatalf("traecli must use session/load (loadSession:true), not session/resume:\n%s", requests)
}
}
// TestTraecliRealACPSmoke drives the REAL official `traecli acp serve` binary
// end-to-end when it is installed and logged in. It is the live counterpart to
// the fake-ACP tests above: it proves the backend's initialize → session/new →
// session/prompt flow works against the actual binary and the user's account.
//
// Skipped automatically when traecli is not on PATH or the session cannot be
// created (not logged in), so CI — which has neither — stays green. Run locally
// with a logged-in traecli to exercise it.
func TestTraecliRealACPSmoke(t *testing.T) {
if testing.Short() {
t.Skip("skipping real-binary smoke test in -short mode")
}
path, err := exec.LookPath("traecli")
if err != nil {
t.Skip("traecli not on PATH; skipping real-binary smoke test")
}
backend, err := New("traecli", Config{ExecutablePath: path, Logger: slog.Default()})
if err != nil {
t.Fatalf("new traecli backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{
Cwd: t.TempDir(),
Timeout: 80 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
// "session/new" panics on a NOT-logged-in traecli (no models); treat
// that as a skip so the test only fails for real protocol regressions.
if result.Status == "failed" && strings.Contains(result.Error, "session/new") {
t.Skipf("traecli not logged in (session/new failed): %v", result.Error)
}
if result.Status != "completed" {
t.Fatalf("real traecli run did not complete: status=%q error=%q", result.Status, result.Error)
}
if !strings.Contains(strings.ToLower(result.Output), "pong") {
t.Fatalf("expected real traecli output to contain 'pong', got %q", result.Output)
}
if result.SessionID == "" {
t.Error("expected a non-empty session id from real traecli")
}
t.Logf("real traecli smoke OK: session=%s output=%q", result.SessionID, result.Output)
case <-time.After(90 * time.Second):
t.Fatal("timeout waiting for real traecli result")
}
}