mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
* MUL-2764 feat(agent): wire mcp_config through ACP runtimes (Hermes / Kimi / Kiro) The MCP config Tab (#3419) already lets admins save mcp_config on an agent, and the daemon plumbs it through to `agent.ExecOptions.McpConfig` for every runtime. Claude and Codex consume it; the three ACP runtimes (Hermes / Kimi / Kiro) ignored the field and hardcoded an empty `mcpServers: []` in their `session/new` requests. Add `buildACPMcpServers` to translate the Claude-style `{"mcpServers": {"<name>": {...}}}` object-of-objects into the array shape ACP requires (`[{name, command, args, env: [{name,value}, ...]}, ...]` for stdio; `[{type, name, url, headers: [...]}, ...]` for http/sse), then pass the translated array on `session/new` (all three) and `session/load` (kiro resume). Malformed JSON fails the launch closed — same contract Codex's `renderCodexMcpServersBlock` uses — so users see a real error instead of silently running with no MCP servers. Individual unclassifiable entries (no command, no url) are skipped with a warning so one bad row can't take MCP down for the rest of the agent. Co-authored-by: multica-agent <github@multica.ai> * MUL-2764 fix(agent): wire mcp_config through ACP resume + gate http/sse on capability Addresses the two blockers Elon raised on #3439: 1. session/resume now carries mcpServers for Hermes and Kimi (Kiro's session/load already did). Per the ACP Session Setup spec the resume path re-attaches MCP servers, and without this a resumed task lost access to MCP tools that a fresh task on the same agent would have had. Pinned with new TestHermesResumeIncludesMcpServers and TestKimiResumeIncludesMcpServers integration tests that inspect the recorded wire request. 2. Added extractACPMcpCapabilities + filterACPMcpServersByCapability so http/sse MCP entries get dropped (with a daemon-log warning naming the entry) when the runtime's initialize response doesn't advertise mcpCapabilities.http / .sse. Sending those entries to a stdio-only runtime is a spec violation and reliably tanks session/new; now they get filtered and the rest of the session still starts. Stdio entries pass through unconditionally. Both backends wire the filter in right after initialize so session/new and session/resume see the same filtered list. Also added TestKiroLoadIncludesMcpServersFromConfig — Elon flagged that no test pinned "non-empty mcp_config actually reaches the wire" for Kimi/Kiro, so the wire assertions go in for all three runtimes. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
259 lines
8.1 KiB
Go
259 lines
8.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewReturnsKimiBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("kimi", Config{ExecutablePath: "/nonexistent/kimi"})
|
|
if err != nil {
|
|
t.Fatalf("New(kimi) error: %v", err)
|
|
}
|
|
if _, ok := b.(*kimiBackend); !ok {
|
|
t.Fatalf("expected *kimiBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestKimiToolNameFromTitle(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
title string
|
|
want string
|
|
}{
|
|
{"Read file: /tmp/foo.go", "read_file"},
|
|
{"read", "read_file"},
|
|
{"Write: /tmp/bar.go", "write_file"},
|
|
{"Edit", "edit_file"},
|
|
{"Patch: /tmp/x", "edit_file"},
|
|
{"Shell: ls -la", "terminal"},
|
|
{"Bash", "terminal"},
|
|
{"Run command: pwd", "terminal"},
|
|
{"Search: foo", "search_files"},
|
|
{"Glob: *.go", "glob"},
|
|
{"Web search: golang acp", "web_search"},
|
|
{"Fetch: https://example.com", "web_fetch"},
|
|
{"Todo Write", "todo_write"},
|
|
// Fallback: snake_case the title.
|
|
{"Custom Thing", "custom_thing"},
|
|
// Empty input returns empty — caller decides how to react.
|
|
{"", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
got := kimiToolNameFromTitle(tt.title)
|
|
if got != tt.want {
|
|
t.Errorf("kimiToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeKimiACPScript returns a POSIX-sh script that impersonates
|
|
// `kimi acp` for a single short ACP session: it acks initialize /
|
|
// session/new and then replies to session/set_model with a JSON-RPC
|
|
// error — the scenario the kimiBackend must propagate as a failed
|
|
// task rather than silently falling back to the default model.
|
|
func fakeKimiACPScript() string {
|
|
return `#!/bin/sh
|
|
# Fake ` + "`kimi`" + ` binary — used by TestKimiBackendSetModelFailureFailsTask
|
|
# and TestKimiBackendPassesYoloFlag.
|
|
#
|
|
# Writes the full argv (one arg per line) to $KIMI_ARGS_FILE if that env
|
|
# var is set, so tests can assert that the daemon invokes us with the
|
|
# right flags (`+"`--yolo acp`"+`, not bare `+"`acp`"+`).
|
|
#
|
|
# Then reads one JSON-RPC request per line from stdin, matches on the
|
|
# method name, and writes back a canned response. Exits after set_model
|
|
# so the kimiBackend cleanup path can run.
|
|
if [ -n "$KIMI_ARGS_FILE" ]; then
|
|
for arg in "$@"; do
|
|
printf '%s\n' "$arg" >> "$KIMI_ARGS_FILE"
|
|
done
|
|
fi
|
|
while IFS= read -r line; do
|
|
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":{}}}\n' "$id"
|
|
;;
|
|
*'"method":"session/new"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id"
|
|
;;
|
|
*'"method":"session/set_model"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
}
|
|
|
|
// TestKimiBackendSetModelFailureFailsTask pins the "don't silently
|
|
// fall back" behaviour that landed in this PR: when kimi rejects the
|
|
// caller-selected model via session/set_model, the task result must
|
|
// report status=failed with a message that names the model and the
|
|
// upstream error — not claim success while actually running on the
|
|
// default model.
|
|
func TestKimiBackendSetModelFailureFailsTask(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "kimi")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKimiACPScript()))
|
|
|
|
backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("new kimi backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Model: "bogus-model",
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
// Drain message stream so the lifecycle goroutine can progress.
|
|
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, `could not switch to model "bogus-model"`) {
|
|
t.Errorf("expected error to name the requested model, got %q", result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "model not available") {
|
|
t.Errorf("expected error to surface upstream message, got %q", result.Error)
|
|
}
|
|
if result.SessionID != "ses_fake" {
|
|
t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
}
|
|
|
|
// TestKimiBackendInvokesACPSubcommand pins the argv for `kimi`. An
|
|
// earlier fix tried passing `--yolo` to bypass per-tool approval
|
|
// prompts, but the `acp` subcommand in kimi-cli takes no options
|
|
// (see cli/__init__.py @cli.command def acp()), so `--yolo` was a
|
|
// no-op and the daemon still hung for 5 min on the first Shell call.
|
|
// The actual bypass is in hermesClient.handleAgentRequest, which
|
|
// auto-approves session/request_permission. This test catches
|
|
// accidental re-introduction of the dead flag.
|
|
func TestKimiBackendInvokesACPSubcommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
argsFile := filepath.Join(tempDir, "argv.txt")
|
|
fakePath := filepath.Join(tempDir, "kimi")
|
|
writeTestExecutable(t, fakePath, []byte(fakeKimiACPScript()))
|
|
|
|
backend, err := New("kimi", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{"KIMI_ARGS_FILE": argsFile},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new kimi backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Set Model so the fake binary exits on set_model and we don't
|
|
// have to wait for the prompt branch. We only care about argv here.
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Model: "bogus-model",
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
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")
|
|
if len(lines) < 1 {
|
|
t.Fatalf("expected at least 1 arg (acp), got %d: %q", len(lines), lines)
|
|
}
|
|
if lines[0] != "acp" {
|
|
t.Errorf("expected first arg to be acp, got %q (full: %q)", lines[0], lines)
|
|
}
|
|
for _, l := range lines {
|
|
switch l {
|
|
case "--yolo", "--auto-approve", "--yes", "-y":
|
|
t.Errorf("kimi acp doesn't accept %q; auto-approval is handled in hermesClient.handleAgentRequest", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKimiResumeIncludesMcpServers pins the same contract as the matching
|
|
// Hermes test: session/resume must carry the managed MCP set so a resumed
|
|
// Kimi task has the same MCP tools as a fresh one.
|
|
func TestKimiResumeIncludesMcpServers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recordPath := filepath.Join(t.TempDir(), "frames.jsonl")
|
|
fakePath := filepath.Join(t.TempDir(), "kimi")
|
|
writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_resume", `{}`)))
|
|
|
|
backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("new kimi backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Timeout: 5 * time.Second,
|
|
ResumeSessionID: "ses_resume",
|
|
McpConfig: json.RawMessage(`{"mcpServers":{"fetch":{"command":"uvx"}}}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
select {
|
|
case <-session.Result:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for result")
|
|
}
|
|
|
|
frame := findRecordedFrame(t, recordPath, "session/resume")
|
|
params := frame["params"].(map[string]any)
|
|
servers, ok := params["mcpServers"].([]any)
|
|
if !ok {
|
|
t.Fatalf("session/resume.mcpServers: got %T, want []any", params["mcpServers"])
|
|
}
|
|
if len(servers) != 1 || servers[0].(map[string]any)["name"] != "fetch" {
|
|
t.Fatalf("session/resume.mcpServers: got %v, want one entry named fetch", servers)
|
|
}
|
|
}
|