mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage - Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy) - Wire daemon/runtime config, docs, and UI provider assets - Retry terminal task reports; add backoff unit tests - Chat: SQL attach user message to task; handler + optimistic cache reconcile - Invalidate chat/task-messages caches on WS reconnect; extract helper + tests Co-authored-by: Orca <help@stably.ai> Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries) Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets. Co-authored-by: Orca <help@stably.ai> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(agent): harden Qoder ACP drain and wire project skills path - Stop streaming to msgCh after reader wait so grace timeout cannot race close - Resolve injected skills to .qoder/skills per Qoder CLI discovery - Update AGENTS.md skill copy and add execenv tests Co-authored-by: Orca <help@stably.ai> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(qoder): add provider logo and wire MCP config into ACP sessions - Add inline SVG QoderLogo component to provider-logo.tsx, replacing the generic Monitor icon placeholder - Add convertMcpConfigForACP helper to convert Claude-style MCP server config (object map) into ACP array format for session/new and session/resume - Add unit tests for convertMcpConfigForACP covering stdio, SSE, empty/nil, and multi-server cases Co-authored-by: Orca <help@stably.ai> * fix(test): capture both return values from InjectRuntimeConfig in Qoder test Co-authored-by: Orca <help@stably.ai> * fix(qoder): preserve remote MCP headers and promote provider errors Addresses review feedback on #2461 (Bohan-J): two runtime-correctness issues in the Qoder ACP backend. 1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP only forwarded url/type, so an authenticated remote MCP server looked configured in Multica but failed inside the Qoder session. Replace it with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro use), which preserves headers as [{name, value}], sorts for deterministic output, and handles remote transport aliases. Fail closed on malformed mcp_config instead of silently dropping servers. 2. Provider failures could report as completed tasks. stderr was wired via io.MultiWriter and the result was only promoted to failed when output was empty, so a terminal upstream error (HTTP 429 / expired token) racing a stopReason=end_turn with text still became "completed". Switch to StderrPipe + an explicit copier, drain it (bounded by the existing grace window, since qodercli can leave a child holding the inherited fds) before the decision, and run the shared promoteACPResultOnProviderError. Tests: replace the convertMcpConfigForACP unit tests with two end-to-end Qoder tests — one asserts the Authorization header reaches the session/new payload as {name, value}, the other asserts a terminal stderr error with non-empty output reports failed. Co-authored-by: Orca <help@stably.ai> * fix(qoder): align ACP session handling Co-authored-by: Orca <help@stably.ai> * fix(agent): guard qoder late output after drain Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
158 lines
4.4 KiB
Go
158 lines
4.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewReturnsClaudeBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"})
|
|
if err != nil {
|
|
t.Fatalf("New(claude) error: %v", err)
|
|
}
|
|
if _, ok := b.(*claudeBackend); !ok {
|
|
t.Fatalf("expected *claudeBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCodexBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"})
|
|
if err != nil {
|
|
t.Fatalf("New(codex) error: %v", err)
|
|
}
|
|
if _, ok := b.(*codexBackend); !ok {
|
|
t.Fatalf("expected *codexBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCodebuddyBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("codebuddy", Config{ExecutablePath: "/nonexistent/codebuddy"})
|
|
if err != nil {
|
|
t.Fatalf("New(codebuddy) error: %v", err)
|
|
}
|
|
if _, ok := b.(*codebuddyBackend); !ok {
|
|
t.Fatalf("expected *codebuddyBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsCopilotBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("copilot", Config{ExecutablePath: "/nonexistent/copilot"})
|
|
if err != nil {
|
|
t.Fatalf("New(copilot) error: %v", err)
|
|
}
|
|
if _, ok := b.(*copilotBackend); !ok {
|
|
t.Fatalf("expected *copilotBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsQoderBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("qoder", Config{ExecutablePath: "/nonexistent/qodercli"})
|
|
if err != nil {
|
|
t.Fatalf("New(qoder) error: %v", err)
|
|
}
|
|
if _, ok := b.(*qoderBackend); !ok {
|
|
t.Fatalf("expected *qoderBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewReturnsAntigravityBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("antigravity", Config{ExecutablePath: "/nonexistent/agy"})
|
|
if err != nil {
|
|
t.Fatalf("New(antigravity) error: %v", err)
|
|
}
|
|
if _, ok := b.(*antigravityBackend); !ok {
|
|
t.Fatalf("expected *antigravityBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsUnknownType(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := New("gpt", Config{})
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown agent type")
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultsLogger(t *testing.T) {
|
|
t.Parallel()
|
|
b, _ := New("claude", Config{})
|
|
cb := b.(*claudeBackend)
|
|
if cb.cfg.Logger == nil {
|
|
t.Fatal("expected non-nil logger")
|
|
}
|
|
}
|
|
|
|
func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := DetectVersion(context.Background(), "/nonexistent/binary")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing binary")
|
|
}
|
|
}
|
|
|
|
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The factory in New() enumerates every supported agent type; LaunchHeader
|
|
// must stay in sync so the UI preview never shows an empty skeleton for a
|
|
// runtime the daemon actually spawns. If a new backend is added, add an
|
|
// entry to launchHeaders in agent.go and extend this list.
|
|
supported := []string{
|
|
"antigravity", "claude", "codebuddy", "codex", "copilot", "cursor", "gemini",
|
|
"hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder",
|
|
}
|
|
for _, t_ := range supported {
|
|
if header := LaunchHeader(t_); header == "" {
|
|
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
|
|
t.Parallel()
|
|
if header := LaunchHeader("made-up-agent"); header != "" {
|
|
t.Errorf("expected empty header for unknown type, got %q", header)
|
|
}
|
|
}
|
|
|
|
func TestRunContextZeroTimeoutHasNoDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
// A zero (or negative) timeout must NOT impose a wall-clock deadline:
|
|
// liveness is delegated to the daemon's inactivity watchdog so an actively
|
|
// streaming long-running session is never killed merely for running long
|
|
// (MUL-3064).
|
|
for _, d := range []time.Duration{0, -time.Second} {
|
|
ctx, cancel := runContext(context.Background(), d)
|
|
if _, ok := ctx.Deadline(); ok {
|
|
cancel()
|
|
t.Fatalf("runContext(%s) imposed a deadline; want none", d)
|
|
}
|
|
cancel()
|
|
if ctx.Err() == nil {
|
|
t.Fatalf("runContext(%s): context should be cancelled after cancel()", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunContextPositiveTimeoutHasDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
// A positive timeout keeps the hard wall-clock deadline (the opt-in
|
|
// absolute cap operators can still set via MULTICA_AGENT_TIMEOUT).
|
|
ctx, cancel := runContext(context.Background(), time.Hour)
|
|
defer cancel()
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
t.Fatal("runContext(1h) should impose a deadline")
|
|
}
|
|
if remaining := time.Until(deadline); remaining <= 0 || remaining > time.Hour+time.Minute {
|
|
t.Fatalf("unexpected deadline remaining: %s", remaining)
|
|
}
|
|
}
|