mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
290 lines
9.8 KiB
Go
290 lines
9.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"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)
|
|
}
|
|
}
|