mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 03:03:56 +02:00
feat(agent): implement codebuddyBackend.Execute with stream-json parsing
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildCodebuddyArgs_Basic(t *testing.T) {
|
||||
@@ -138,3 +143,237 @@ func TestBuildCodebuddyArgs_Resume(t *testing.T) {
|
||||
t.Fatalf("expected --resume sess-abc123 in args: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecute_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
|
||||
fakePath := filepath.Join(t.TempDir(), "codebuddy")
|
||||
script := "#!/bin/sh\n" +
|
||||
"cat >/dev/null\n" +
|
||||
`printf '%s\n' '{"type":"system","session_id":"sess-cb-001"}'` + "\n" +
|
||||
`printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello from codebuddy"}]}}'` + "\n" +
|
||||
`printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-cb-001","result":"Hello from codebuddy","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":100,"outputTokens":50,"cacheReadInputTokens":10,"cacheCreationInputTokens":5}}}'` + "\n"
|
||||
writeTestExecutable(t, fakePath, []byte(script))
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := b.Execute(ctx, "say hello", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
// Drain messages.
|
||||
var gotText bool
|
||||
for msg := range session.Messages {
|
||||
if msg.Type == MessageText && msg.Content == "Hello from codebuddy" {
|
||||
gotText = true
|
||||
}
|
||||
}
|
||||
if !gotText {
|
||||
t.Fatal("expected text message 'Hello from codebuddy'")
|
||||
}
|
||||
|
||||
select {
|
||||
case result, ok := <-session.Result:
|
||||
if !ok {
|
||||
t.Fatal("result channel closed without a value")
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if result.Output != "Hello from codebuddy" {
|
||||
t.Fatalf("expected output 'Hello from codebuddy', got %q", result.Output)
|
||||
}
|
||||
if result.SessionID != "sess-cb-001" {
|
||||
t.Fatalf("expected session_id=sess-cb-001, got %q", result.SessionID)
|
||||
}
|
||||
usage, ok := result.Usage["claude-sonnet-4-20250514"]
|
||||
if !ok {
|
||||
t.Fatalf("expected usage for claude-sonnet-4-20250514, got %#v", result.Usage)
|
||||
}
|
||||
if usage.InputTokens != 100 || usage.OutputTokens != 50 || usage.CacheReadTokens != 10 || usage.CacheWriteTokens != 5 {
|
||||
t.Fatalf("unexpected usage: %+v", usage)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecute_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: "/nonexistent/path/codebuddy", Logger: slog.Default()}}
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := b.Execute(ctx, "prompt", ExecOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing executable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "codebuddy executable not found") {
|
||||
t.Fatalf("expected 'codebuddy executable not found' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyExecuteSurfacesStderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script fixture is POSIX-only")
|
||||
}
|
||||
|
||||
fakePath := filepath.Join(t.TempDir(), "codebuddy")
|
||||
script := "#!/bin/sh\n" +
|
||||
"cat >/dev/null\n" +
|
||||
"echo \"FATAL ERROR: segfault in codebuddy runtime\" >&2\n" +
|
||||
"exit 1\n"
|
||||
writeTestExecutable(t, fakePath, []byte(script))
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := b.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
// Drain messages.
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result, ok := <-session.Result:
|
||||
if !ok {
|
||||
t.Fatal("result channel closed without a value")
|
||||
}
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "codebuddy exited with error") {
|
||||
t.Fatalf("expected error to mention exit, got %q", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "segfault in codebuddy runtime") {
|
||||
t.Fatalf("expected error to include stderr content, got %q", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "codebuddy stderr:") {
|
||||
t.Fatalf("expected stderr label in error, got %q", result.Error)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodebuddyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf strings.Builder
|
||||
err := writeCodebuddyInput(&buf, "hello world")
|
||||
if err != nil {
|
||||
t.Fatalf("writeCodebuddyInput: %v", err)
|
||||
}
|
||||
|
||||
data := buf.String()
|
||||
if len(data) == 0 || data[len(data)-1] != '\n' {
|
||||
t.Fatalf("expected newline-terminated payload, got %q", data)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(data)), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if payload["type"] != "user" {
|
||||
t.Fatalf("expected type user, got %v", payload["type"])
|
||||
}
|
||||
|
||||
message, ok := payload["message"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected message object, got %T", payload["message"])
|
||||
}
|
||||
if message["role"] != "user" {
|
||||
t.Fatalf("expected role user, got %v", message["role"])
|
||||
}
|
||||
|
||||
content, ok := message["content"].([]any)
|
||||
if !ok || len(content) != 1 {
|
||||
t.Fatalf("expected one content block, got %v", message["content"])
|
||||
}
|
||||
block, ok := content[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected content block object, got %T", content[0])
|
||||
}
|
||||
if block["type"] != "text" || block["text"] != "hello world" {
|
||||
t.Fatalf("unexpected content block: %v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyHandleAssistantText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
msg := codebuddySDKMessage{
|
||||
Type: "assistant",
|
||||
Message: mustMarshal(t, codebuddyMessageContent{
|
||||
Role: "assistant",
|
||||
Content: []codebuddyContentBlock{
|
||||
{Type: "text", Text: "codebuddy says hi"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
|
||||
|
||||
if output.String() != "codebuddy says hi" {
|
||||
t.Fatalf("expected output 'codebuddy says hi', got %q", output.String())
|
||||
}
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageText || m.Content != "codebuddy says hi" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodebuddyHandleUserToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &codebuddyBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
msg := codebuddySDKMessage{
|
||||
Type: "user",
|
||||
Message: mustMarshal(t, codebuddyMessageContent{
|
||||
Role: "user",
|
||||
Content: []codebuddyContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call-cb-1",
|
||||
Content: mustMarshal(t, "tool output here"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
b.handleUser(msg, ch)
|
||||
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Type != MessageToolResult || m.CallID != "call-cb-1" {
|
||||
t.Fatalf("unexpected message: %+v", m)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected message on channel")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user