mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(agent): stop launching CodeBuddy with MCP disabled (MUL-5846) `--strict-mcp-config` means "only use MCP servers from --mcp-config". buildCodebuddyArgs passed it unconditionally, so every CodeBuddy agent without a Multica-managed mcp_config was launched in strict mode with no config at all — zero MCP servers, and the user's own ~/.codebuddy.json silently ignored. claude.go already gates the flag on hasManagedMcpConfig; do the same here, and gate the --mcp-config temp file on the same three-state check so a JSON `null` no longer counts as a managed set. Also split CodeBuddy off Claude in runtime_mcp.go. CodeBuddy is a Claude Code fork but keeps its own config file and plugin root (~/.codebuddy.json, ~/.workbuddy/), so reading ~/.claude.json leaked Claude's servers into CodeBuddy launches while dropping CodeBuddy's own. Add kimi to the runtime MCP inventory (<KIMI_CODE_HOME>/mcp.json), which previously reported this runtime as not supporting MCP. Inventory only: `kimi acp` already merges that file with the ephemeral `mcpServers` array the ACP backend sends in session/new, and a duplicate name spawns the server twice, so the task-local merge stays a passthrough for kimi. Verified against CodeBuddy 2.x and kimi-code 0.33.0 with a local stdio MCP probe server: before, the strict launch line answered NO_MCP_TOOL and never spawned the server; after, it returns the probe's payload. Co-authored-by: multica-agent <[email protected]> * fix(daemon): resolve CodeBuddy's real MCP config path (MUL-5846) Review follow-up. The previous commit pointed CodeBuddy at ~/.codebuddy.json, which is only the last fallback in its chain. Verified against the installed CodeBuddy CLI: - the config dir is $CODEBUDDY_CONFIG_DIR, defaulting to ~/.codebuddy - user-scope MCP is the FIRST existing of <configDir>/.mcp.json -> <configDir>/mcp.json -> ~/.codebuddy.json (a fallback chain, not a merge); `codebuddy mcp add --scope user` writes the first - those files are JSONC: comments and trailing commas parse fine - plugins live under <configDir>/plugins, not ~/.workbuddy This is not just a missing inventory row. When an agent has a managed mcp_config, codebuddy.go turns on --strict-mcp-config, so the merged document the daemon writes is the entire world CodeBuddy sees. Reading the wrong file meant "add one cloud MCP server" silently dropped every server the user had configured locally, which is exactly what mergeRuntimeAndAgentMcpConfig exists to prevent. stripJSONC rewrites comments to spaces and drops trailing commas without touching string literals, so a `//` or a comma inside a command argument survives. Also adds the session/new counterpart to TestKimiResumeIncludesMcpServers: Kimi takes MCP over ACP rather than as a CLI flag, so a bare `kimi acp` launch line is not evidence the servers were dropped. Regressions added: path precedence, CODEBUDDY_CONFIG_DIR, JSONC (including comment-like text inside strings), managed + local merge, same-name override, explicit empty set, JSON null passthrough. Co-authored-by: multica-agent <[email protected]> * fix(agent): let CodeBuddy merge its own MCP scopes natively (MUL-5846) Round-2 review follow-up. Pointing the daemon at CodeBuddy's user-scope file only fixed one of three scopes: --strict-mcp-config drops user, project AND local config, so an agent with a managed mcp_config still lost every server the user had configured locally. Measured against CodeBuddy 2.x with one real MCP server registered per scope, using process spawns as the oracle (the model's own tool list is not trustworthy here — it happily names servers that failed to connect): --mcp-config only ....... managed + user + local --mcp-config + strict ... managed only strict only ............. nothing at all So stop passing --strict-mcp-config entirely and let CodeBuddy build the union itself. A managed entry already wins a same-name collision natively (verified), which is exactly what mergeRuntimeAndAgentMcpConfig promises, so codebuddy also drops out of the daemon-side merge and becomes a passthrough like kimi. This removes the class of bugs the previous approach kept generating: the daemon no longer has to reproduce CodeBuddy's config-root resolution, scope precedence, or JSONC parsing on the launch path, and the review's second must-fix goes away with it — agent custom_env CODEBUDDY_CONFIG_DIR is now read by the CLI itself, so the daemon resolving a different directory can no longer desync the launch. Project-scope servers also keep their native approval gate instead of being rewritten into daemon-generated CLI config. (Measured: a project-scope server sits at "Needs approval" and never starts headless, with or without our flags.) codebuddyUserMcpConfigPath and the JSONC reader stay, now used only by the read-only runtime inventory the Agent > MCP tab renders. stripJSONC also tightened per the review: only the last comma before a closer is dropped, so `[1,,,]` stays invalid instead of being silently repaired, and output is now exactly the same length as input so parse-error offsets line up. Co-authored-by: multica-agent <[email protected]> * test(agent): pin kimi MCP delivery against the real CLI (MUL-5846) Users reported that MCP configured in Multica never reaches kimi, citing the bare `kimi acp` launch line as evidence. That line carries no MCP flags because the CLI has none — `kimi acp --help` lists only --login and --help — so kimi takes MCP over ACP session/new instead, and only an end-to-end run against the real binary can settle the claim. Drives this package's own kimi backend with a Multica-shaped agent.mcp_config. The oracle is the MCP server process itself: a stdio fixture that appends to a log when spawned and returns a sentinel from its one tool, so neither a hand-written ACP fixture nor the model's own account of its tools can fake a pass. Verified against kimi-code 0.33.0: server spawned, tools/list served, tools/call answered, sentinel in the task output. Behind the agentintegration tag and MULTICA_RUN_REAL_AGENT_SMOKE, so the default suites and CI are unaffected. Co-authored-by: multica-agent <[email protected]> * fix(daemon): reject unterminated JSONC block comments (MUL-5846) Review nits. stripJSONC blanked an unterminated `/*` all the way to EOF, so `{"mcpServers":{}} /* oops` became valid JSON plus whitespace, and the `/*/` near-miss let the opener's own `*` double as the closer's. Either could make the Agent > MCP tab list servers out of a file CodeBuddy itself rejects — verified: the CLI answers "No MCP servers configured" for both. It now consumes the opener before scanning and returns an error when no closer is found. Only the inventory is affected; the launch path stopped depending on this reader last commit. Also refreshes the launch-path comment, which still described the managed file as a "controlled set" gated by a strict-mode three-state check that buildCodebuddyArgs no longer performs. The flag is gone; --mcp-config now adds to CodeBuddy's own user/project/local scopes. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
523 lines
15 KiB
Go
523 lines
15 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuildCodebuddyArgs_Basic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{
|
|
Model: "claude-sonnet-4-20250514",
|
|
MaxTurns: 25,
|
|
SystemPrompt: "You are an agent.",
|
|
}, slog.Default())
|
|
|
|
expected := []string{
|
|
"-p",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--permission-mode", "bypassPermissions",
|
|
"--disallowedTools", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
|
|
"--model", "claude-sonnet-4-20250514",
|
|
"--max-turns", "25",
|
|
"--append-system-prompt", "You are an agent.",
|
|
}
|
|
|
|
if len(args) != len(expected) {
|
|
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
|
|
}
|
|
for i, want := range expected {
|
|
if args[i] != want {
|
|
t.Fatalf("args[%d] = %q, want %q\nfull args: %v", i, args[i], want, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --strict-mcp-config must never be passed, with or without a managed config.
|
|
// It means "only use servers from --mcp-config" and drops CodeBuddy's user,
|
|
// project and local scopes; measured against the real CLI, strict + a managed
|
|
// config loaded ONLY the managed server, and strict alone loaded nothing at
|
|
// all. The union is what mergeRuntimeAndAgentMcpConfig promises (MUL-5846).
|
|
func TestBuildCodebuddyArgsNeverPassesStrictMCP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, mcpConfig := range []json.RawMessage{
|
|
nil,
|
|
json.RawMessage("null"),
|
|
json.RawMessage(`{}`),
|
|
json.RawMessage(`{"mcpServers":{"paper":{"command":"paper"}}}`),
|
|
} {
|
|
args := buildCodebuddyArgs(ExecOptions{McpConfig: mcpConfig}, slog.Default())
|
|
for _, arg := range args {
|
|
if arg == "--strict-mcp-config" {
|
|
t.Fatalf("mcp_config %q must not disable CodeBuddy's own MCP scopes, got %v", string(mcpConfig), args)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_DisallowsInteractiveTools(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The daemon runs CodeBuddy headless, so every tool that waits on a human
|
|
// confirmation stalls the turn instead of ending it (GitHub #6012).
|
|
// CodeBuddy matches each --disallowedTools entry against the tool name
|
|
// exactly, so each tool must arrive as its own argv value.
|
|
args := buildCodebuddyArgs(ExecOptions{}, slog.Default())
|
|
|
|
idx := -1
|
|
for i, a := range args {
|
|
if a == "--disallowedTools" {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
t.Fatalf("expected --disallowedTools in args: %v", args)
|
|
}
|
|
|
|
for offset, want := range []string{"AskUserQuestion", "EnterPlanMode", "ExitPlanMode"} {
|
|
got := ""
|
|
if idx+1+offset < len(args) {
|
|
got = args[idx+1+offset]
|
|
}
|
|
if got != want {
|
|
t.Fatalf("disallowed tool %d = %q, want %q\nfull args: %v", offset, got, want, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_InjectsEffort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{
|
|
ThinkingLevel: "high",
|
|
}, slog.Default())
|
|
|
|
found := false
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == "--effort" && args[i+1] == "high" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected --effort high in args: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_OmitsEffortWhenEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{}, slog.Default())
|
|
|
|
for _, a := range args {
|
|
if a == "--effort" {
|
|
t.Fatalf("--effort should not appear when ThinkingLevel is empty: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_BlocksUserEffortOverride(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{
|
|
ThinkingLevel: "medium",
|
|
CustomArgs: []string{"--effort", "max"},
|
|
}, slog.Default())
|
|
|
|
// Should have exactly one --effort (the daemon-injected one).
|
|
count := 0
|
|
for i, a := range args {
|
|
if a == "--effort" {
|
|
count++
|
|
if i+1 < len(args) && args[i+1] != "medium" {
|
|
t.Fatalf("expected --effort medium, got --effort %s", args[i+1])
|
|
}
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly 1 --effort, got %d in: %v", count, args)
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_ExtraArgsBeforeCustomArgs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{
|
|
ExtraArgs: []string{"--output-format", "text", "--max-budget-usd", "1.00"},
|
|
CustomArgs: []string{"--max-budget-usd", "2.00", "--permission-mode", "plan"},
|
|
}, slog.Default())
|
|
|
|
joined := strings.Join(args, " ")
|
|
// Blocked flags should be filtered from both layers.
|
|
if strings.Contains(joined, "--output-format text") || strings.Contains(joined, "--permission-mode plan") {
|
|
t.Fatalf("blocked args should be filtered from both layers: %v", args)
|
|
}
|
|
|
|
extraIdx, customIdx := -1, -1
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == "--max-budget-usd" && args[i+1] == "1.00" {
|
|
extraIdx = i
|
|
}
|
|
if args[i] == "--max-budget-usd" && args[i+1] == "2.00" {
|
|
customIdx = i
|
|
}
|
|
}
|
|
if extraIdx == -1 || customIdx == -1 || extraIdx > customIdx {
|
|
t.Fatalf("expected extra args before custom args, got %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildCodebuddyArgs_Resume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildCodebuddyArgs(ExecOptions{
|
|
ResumeSessionID: "sess-abc123",
|
|
}, slog.Default())
|
|
|
|
found := false
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == "--resume" && args[i+1] == "sess-abc123" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
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" +
|
|
"IFS= read -r _\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" +
|
|
"IFS= read -r _\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)
|
|
|
|
msg := codebuddySDKMessage{
|
|
Type: "assistant",
|
|
Message: mustMarshal(t, codebuddyMessageContent{
|
|
Role: "assistant",
|
|
Content: []codebuddyContentBlock{
|
|
{Type: "text", Text: "codebuddy says hi"},
|
|
},
|
|
}),
|
|
}
|
|
|
|
turn := b.handleAssistant(msg, ch, make(map[string]TokenUsage))
|
|
output, tools := turn.text, turn.toolUses
|
|
|
|
if output != "codebuddy says hi" {
|
|
t.Fatalf("expected output 'codebuddy says hi', got %q", output)
|
|
}
|
|
if tools != 0 {
|
|
t.Fatalf("expected no tool uses, got %d", tools)
|
|
}
|
|
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 TestIsKnownThinkingValue_Codebuddy(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
value string
|
|
want bool
|
|
}{
|
|
{"", true},
|
|
{"minimal", true},
|
|
{"low", true},
|
|
{"medium", true},
|
|
{"high", true},
|
|
{"xhigh", true},
|
|
// CodeBuddy 2.130.0 advertises `max`; the gate used to reject it.
|
|
{"max", true},
|
|
{"none", false},
|
|
// ACP advertises `enabled` as a session toggle, but `--effort enabled`
|
|
// is not a valid command line, so the gate must not accept it.
|
|
{"enabled", false},
|
|
}
|
|
for _, tc := range cases {
|
|
got := IsKnownThinkingValue("codebuddy", tc.value)
|
|
if got != tc.want {
|
|
t.Errorf("IsKnownThinkingValue(codebuddy, %q) = %v, want %v", tc.value, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestCodebuddyHandleControlRequestApprovesInCodebuddyShape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &codebuddyBackend{cfg: Config{Logger: slog.Default()}}
|
|
|
|
var written bytes.Buffer
|
|
|
|
msg := codebuddySDKMessage{
|
|
Type: "control_request",
|
|
RequestID: "perm_1730000000000_1",
|
|
Request: mustMarshal(t, codebuddyControlRequestPayload{
|
|
Subtype: "can_use_tool",
|
|
ToolName: "Bash",
|
|
Input: mustMarshal(t, map[string]any{"command": "ls"}),
|
|
}),
|
|
}
|
|
|
|
b.handleControlRequest(msg, &written)
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp["type"] != "control_response" {
|
|
t.Fatalf("expected type control_response, got %v", resp["type"])
|
|
}
|
|
respInner, ok := resp["response"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected response object, got %v", resp["response"])
|
|
}
|
|
if respInner["subtype"] != "success" {
|
|
t.Fatalf("expected subtype success, got %v", respInner["subtype"])
|
|
}
|
|
if respInner["request_id"] != "perm_1730000000000_1" {
|
|
t.Fatalf("expected the request_id to be echoed back, got %v", respInner["request_id"])
|
|
}
|
|
|
|
innerResp, ok := respInner["response"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected inner response object, got %v", respInner["response"])
|
|
}
|
|
// CodeBuddy reads `allowed`; a missing key is read as a denial, which
|
|
// leaves the CLI waiting on a confirmation the daemon can never deliver.
|
|
if innerResp["allowed"] != true {
|
|
t.Fatalf("expected allowed=true, got %v", innerResp["allowed"])
|
|
}
|
|
if innerResp["behavior"] != "allow" {
|
|
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
|
|
}
|
|
updatedInput, ok := innerResp["updatedInput"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected updatedInput object, got %v", innerResp["updatedInput"])
|
|
}
|
|
if updatedInput["command"] != "ls" {
|
|
t.Fatalf("expected the original tool input to be preserved, got %v", updatedInput["command"])
|
|
}
|
|
}
|