diff --git a/Makefile b/Makefile index 3cad49be5..fb5fd3ba2 100644 --- a/Makefile +++ b/Makefile @@ -182,7 +182,7 @@ server: cd server && go run ./cmd/server daemon: - @$(MAKE) multica MULTICA_ARGS="daemon start --profile local" + @$(MAKE) multica MULTICA_ARGS="daemon restart --profile local" cli: @$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)" diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index ea8d5f519..3729bc4b6 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -88,6 +88,16 @@ function PiLogo({ className }: { className: string }) { ); } +// GitHub Copilot — official mark, sourced from GitHub brand assets +function CopilotLogo({ className }: { className: string }) { + return ( + + + + + ); +} + // Cursor — official brand logo from Cursor brand assets function CursorLogo({ className }: { className: string }) { return ( @@ -122,6 +132,8 @@ export function ProviderLogo({ return ; case "pi": return ; + case "copilot": + return ; case "cursor": return ; default: diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 742b0a41c..d39940c0a 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -44,6 +44,12 @@ var daemonStatusCmd = &cobra.Command{ RunE: runDaemonStatus, } +var daemonRestartCmd = &cobra.Command{ + Use: "restart", + Short: "Restart the running daemon (stop + start)", + RunE: runDaemonRestart, +} + var daemonLogsCmd = &cobra.Command{ Use: "logs", Short: "Show daemon logs", @@ -66,8 +72,20 @@ func init() { daemonStatusCmd.Flags().String("output", "table", "Output format: table or json") + // restart shares all the same flags as start + rf := daemonRestartCmd.Flags() + rf.Bool("foreground", false, "Run in the foreground instead of background") + rf.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") + rf.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") + rf.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)") + rf.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)") + rf.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)") + rf.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)") + rf.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)") + daemonCmd.AddCommand(daemonStartCmd) daemonCmd.AddCommand(daemonStopCmd) + daemonCmd.AddCommand(daemonRestartCmd) daemonCmd.AddCommand(daemonStatusCmd) daemonCmd.AddCommand(daemonLogsCmd) } @@ -128,7 +146,8 @@ func runDaemonBackground(cmd *cobra.Command) error { if profile != "" { label = fmt.Sprintf("daemon [%s]", profile) } - return fmt.Errorf("%s is already running (pid %v)", label, health["pid"]) + pid, _ := health["pid"].(float64) + return fmt.Errorf("%s is already running (pid %v). Use 'daemon restart' to restart it", label, int(pid)) } // Resolve current executable. @@ -328,6 +347,39 @@ func runDaemonForeground(cmd *cobra.Command) error { return nil } +// --- daemon restart --- + +func runDaemonRestart(cmd *cobra.Command, args []string) error { + profile := resolveProfile(cmd) + healthPort := healthPortForProfile(profile) + + // Stop if running. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + health := checkDaemonHealthOnPort(ctx, healthPort) + if health["status"] == "running" { + pid, _ := health["pid"].(float64) + if pid > 0 { + if p, err := os.FindProcess(int(pid)); err == nil { + fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid)) + _ = stopDaemonProcess(p) + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second) + h := checkDaemonHealthOnPort(sctx, healthPort) + scancel() + if h["status"] != "running" { + break + } + } + } + } + } + + // Start fresh. + return runDaemonStart(cmd, args) +} + // --- daemon stop --- func runDaemonStop(cmd *cobra.Command, _ []string) error { diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 034ab570f..f14db3282 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -134,8 +134,15 @@ func LoadConfig(overrides Overrides) (Config, error) { Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")), } } + copilotPath := envOrDefault("MULTICA_COPILOT_PATH", "copilot") + if _, err := exec.LookPath(copilotPath); err == nil { + agents["copilot"] = AgentEntry{ + Path: copilotPath, + Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")), + } + } if len(agents) == 0 { - return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH") + return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH") } // Host info diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 071707a46..68d131ca5 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -13,6 +13,7 @@ import ( // // Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery) // Codex: skills → handled separately in Prepare via codex-home +// Copilot: skills → {workDir}/.agent_context/skills/{name}/SKILL.md (via AGENTS.md references) // OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery) // Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery) // Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index fa79e75c4..b90eccb16 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -12,6 +12,7 @@ import ( // // For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/) // For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME) +// For Copilot: writes {workDir}/AGENTS.md (Copilot CLI natively reads AGENTS.md) // For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/) // For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/) // For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI) @@ -23,7 +24,7 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error switch provider { case "claude": return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644) - case "codex", "opencode", "openclaw", "pi", "cursor": + case "codex", "copilot", "opencode", "openclaw", "pi", "cursor": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) case "gemini": return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644) @@ -142,8 +143,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { case "claude": // Claude discovers skills natively from .claude/skills/ — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") - case "codex", "opencode", "openclaw", "pi", "cursor": - // Codex, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names. + case "codex", "copilot", "opencode", "openclaw", "pi", "cursor": + // Codex, Copilot, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") case "gemini": // Gemini reads GEMINI.md directly; point it at the fallback skills dir. diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 6520afd56..01914e780 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -83,13 +83,13 @@ type Result struct { // Config configures a Backend instance. type Config struct { - ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, hermes, gemini, or pi) + ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, or pi) Env map[string]string // extra environment variables Logger *slog.Logger } // New creates a Backend for the given agent type. -// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor". +// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor". func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() @@ -100,6 +100,8 @@ func New(agentType string, cfg Config) (Backend, error) { return &claudeBackend{cfg: cfg}, nil case "codex": return &codexBackend{cfg: cfg}, nil + case "copilot": + return &copilotBackend{cfg: cfg}, nil case "opencode": return &opencodeBackend{cfg: cfg}, nil case "openclaw": @@ -113,7 +115,7 @@ func New(agentType string, cfg Config) (Backend, error) { case "cursor": return &cursorBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, pi, cursor)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor)", agentType) } } diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go index 7ddec759d..edf2f51a4 100644 --- a/server/pkg/agent/agent_test.go +++ b/server/pkg/agent/agent_test.go @@ -27,6 +27,17 @@ func TestNewReturnsCodexBackend(t *testing.T) { } } +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 TestNewRejectsUnknownType(t *testing.T) { t.Parallel() _, err := New("gpt", Config{}) diff --git a/server/pkg/agent/copilot.go b/server/pkg/agent/copilot.go new file mode 100644 index 000000000..bc32f0d38 --- /dev/null +++ b/server/pkg/agent/copilot.go @@ -0,0 +1,423 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" +) + +// copilotBackend implements Backend by spawning the GitHub Copilot CLI +// with --output-format json and parsing its JSONL event stream. +// +// The v1 integration uses the -p (pipe) mode which is the stable +// automation/CI channel. The prompt is passed as a CLI argument (not stdin). +// Events arrive as newline-delimited JSON on stdout in the Copilot CLI's +// own envelope format: { "type": "dotted.event.name", "data": {...}, ... } +type copilotBackend struct { + cfg Config +} + +// copilotEventState holds mutable state accumulated while processing the JSONL +// event stream. It is shared between production (Execute) and tests via +// handleCopilotEvent, so the parsing logic is never duplicated. +type copilotEventState struct { + output strings.Builder + sessionID string + activeModel string + finalStatus string + finalError string + usage map[string]TokenUsage +} + +func newCopilotEventState(seedModel string) *copilotEventState { + return &copilotEventState{ + activeModel: seedModel, + finalStatus: "completed", + usage: make(map[string]TokenUsage), + } +} + +// handleCopilotEvent processes a single parsed copilotEvent, updates state, +// and returns zero or more Messages to emit. Extracted so tests can call the +// exact same logic without duplicating the switch body. +func handleCopilotEvent(evt copilotEvent, st *copilotEventState) []Message { + var msgs []Message + + switch evt.Type { + case "session.start": + var ss copilotSessionStart + if err := json.Unmarshal(evt.Data, &ss); err == nil && ss.SelectedModel != "" { + st.activeModel = ss.SelectedModel + } + + case "assistant.message_delta": + var delta copilotMessageDelta + if err := json.Unmarshal(evt.Data, &delta); err == nil && delta.DeltaContent != "" { + // Write to output as defense-in-depth: if the process is killed + // before the final assistant.message arrives, we still have text. + st.output.WriteString(delta.DeltaContent) + msgs = append(msgs, Message{Type: MessageText, Content: delta.DeltaContent}) + } + + case "assistant.message": + var msg copilotAssistantMessage + if err := json.Unmarshal(evt.Data, &msg); err != nil { + return nil + } + // assistant.message carries the full turn content. Since deltas + // already wrote to output incrementally, we reset and write the + // authoritative content once to avoid double-counting. + if msg.Content != "" { + // Separator between turns. + trimmed := strings.TrimSuffix(st.output.String(), msg.Content) + st.output.Reset() + st.output.WriteString(trimmed) + if st.output.Len() > 0 && !strings.HasSuffix(st.output.String(), "\n\n") { + st.output.WriteString("\n\n") + } + st.output.WriteString(msg.Content) + } + if msg.ReasoningText != "" { + msgs = append(msgs, Message{Type: MessageThinking, Content: msg.ReasoningText}) + } + if msg.OutputTokens > 0 { + u := st.usage[st.activeModel] + u.OutputTokens += msg.OutputTokens + st.usage[st.activeModel] = u + } + for _, tr := range msg.ToolRequests { + var input map[string]any + if tr.Arguments != nil { + _ = json.Unmarshal(tr.Arguments, &input) + } + msgs = append(msgs, Message{ + Type: MessageToolUse, + Tool: tr.Name, + CallID: tr.ToolCallID, + Input: input, + }) + } + + case "assistant.reasoning", "assistant.reasoning_delta": + // Streaming thinking content — may arrive as full or delta. + var r copilotReasoning + if err := json.Unmarshal(evt.Data, &r); err == nil { + text := r.Content + if text == "" { + text = r.DeltaContent + } + if text != "" { + msgs = append(msgs, Message{Type: MessageThinking, Content: text}) + } + } + + case "tool.execution_complete": + var tc copilotToolExecComplete + if err := json.Unmarshal(evt.Data, &tc); err != nil { + return nil + } + if tc.Model != "" { + st.activeModel = tc.Model + } + resultContent := "" + if tc.Success && tc.Result != nil { + resultContent = tc.Result.Content + } else if !tc.Success { + if tc.Error != nil { + resultContent = "Error: " + tc.Error.Message + } else if tc.Result != nil { + resultContent = tc.Result.Content + } + } + msgs = append(msgs, Message{ + Type: MessageToolResult, + CallID: tc.ToolCallID, + Output: resultContent, + }) + + case "assistant.turn_start": + msgs = append(msgs, Message{Type: MessageStatus, Status: "running"}) + + case "session.error": + var se copilotSessionError + if err := json.Unmarshal(evt.Data, &se); err == nil { + st.finalStatus = "failed" + st.finalError = se.Message + msgs = append(msgs, Message{Type: MessageLog, Level: "error", Content: se.Message}) + } + + case "session.warning": + var sw copilotSessionWarning + if err := json.Unmarshal(evt.Data, &sw); err == nil { + msgs = append(msgs, Message{Type: MessageLog, Level: "warn", Content: sw.Message}) + } + + case "result": + st.sessionID = evt.SessionID + if evt.ExitCode != 0 { + st.finalStatus = "failed" + st.finalError = fmt.Sprintf("copilot exited with code %d", evt.ExitCode) + } + } + + return msgs +} + +func (b *copilotBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "copilot" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("copilot executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + args := buildCopilotArgs(prompt, opts, b.cfg.Logger) + + cmd := exec.CommandContext(runCtx, execPath, args...) + b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args) + cmd.WaitDelay = 10 * time.Second + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + cmd.Env = buildEnv(b.cfg.Env) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("copilot stdout pipe: %w", err) + } + cmd.Stderr = newLogWriter(b.cfg.Logger, "[copilot:stderr] ") + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start copilot: %w", err) + } + + b.cfg.Logger.Info("copilot started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + + startTime := time.Now() + seedModel := opts.Model + if seedModel == "" { + seedModel = "copilot" + } + st := newCopilotEventState(seedModel) + + go func() { + <-runCtx.Done() + _ = stdout.Close() + }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var evt copilotEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + + for _, m := range handleCopilotEvent(evt, st) { + trySend(msgCh, m) + } + } + + exitErr := cmd.Wait() + duration := time.Since(startTime) + + if runCtx.Err() == context.DeadlineExceeded { + st.finalStatus = "timeout" + st.finalError = fmt.Sprintf("copilot timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + st.finalStatus = "aborted" + st.finalError = "execution cancelled" + } else if exitErr != nil && st.finalStatus == "completed" { + st.finalStatus = "failed" + st.finalError = fmt.Sprintf("copilot exited with error: %v", exitErr) + } + + b.cfg.Logger.Info("copilot finished", "pid", cmd.Process.Pid, "status", st.finalStatus, "duration", duration.Round(time.Millisecond).String()) + + resCh <- Result{ + Status: st.finalStatus, + Output: st.output.String(), + Error: st.finalError, + DurationMs: duration.Milliseconds(), + SessionID: st.sessionID, + Usage: st.usage, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +// ── Copilot CLI JSONL event types ── +// +// Copilot CLI v1.0.28+ with --output-format json emits JSONL on stdout. +// Each line is a JSON object with: +// +// { "type": "dotted.event.name", "data": {...}, "id": "...", +// "timestamp": "...", "parentId": "...", "ephemeral": bool } +// +// The final line is a synthetic "result" event with top-level fields: +// +// { "type": "result", "sessionId": "...", "exitCode": 0, "usage": {...} } + +// copilotEvent is the envelope for all Copilot JSONL events. +type copilotEvent struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` + ID string `json:"id,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + ParentID string `json:"parentId,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` + + // Top-level fields on the synthetic "result" event only. + SessionID string `json:"sessionId,omitempty"` + ExitCode int `json:"exitCode,omitempty"` + Usage *copilotResultUsage `json:"usage,omitempty"` +} + +// copilotSessionStart is data payload for "session.start". +type copilotSessionStart struct { + SessionID string `json:"sessionId"` + SelectedModel string `json:"selectedModel"` +} + +// copilotAssistantMessage is data payload for "assistant.message". +type copilotAssistantMessage struct { + MessageID string `json:"messageId"` + Content string `json:"content"` + ToolRequests []copilotToolRequest `json:"toolRequests"` + OutputTokens int64 `json:"outputTokens"` + InteractionID string `json:"interactionId"` + ReasoningText string `json:"reasoningText,omitempty"` +} + +// copilotToolRequest is one tool invocation inside assistant.message. +type copilotToolRequest struct { + ToolCallID string `json:"toolCallId"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Type string `json:"type"` + IntentionSummary string `json:"intentionSummary,omitempty"` +} + +// copilotMessageDelta is data payload for "assistant.message_delta". +type copilotMessageDelta struct { + MessageID string `json:"messageId"` + DeltaContent string `json:"deltaContent"` +} + +// copilotToolExecComplete is data payload for "tool.execution_complete". +type copilotToolExecComplete struct { + ToolCallID string `json:"toolCallId"` + Model string `json:"model"` + InteractionID string `json:"interactionId"` + Success bool `json:"success"` + Result *copilotToolResult `json:"result,omitempty"` + Error *copilotToolError `json:"error,omitempty"` +} + +type copilotToolResult struct { + Content string `json:"content"` + DetailedContent string `json:"detailedContent,omitempty"` +} + +type copilotToolError struct { + Message string `json:"message"` +} + +// copilotReasoning is data payload for "assistant.reasoning" / "assistant.reasoning_delta". +type copilotReasoning struct { + Content string `json:"content,omitempty"` + DeltaContent string `json:"deltaContent,omitempty"` +} + +// copilotSessionError is data payload for "session.error". +type copilotSessionError struct { + ErrorType string `json:"errorType"` + Message string `json:"message"` +} + +// copilotSessionWarning is data payload for "session.warning". +type copilotSessionWarning struct { + WarningType string `json:"warningType"` + Message string `json:"message"` +} + +// copilotResultUsage is the usage on the final "result" line. +type copilotResultUsage struct { + PremiumRequests int `json:"premiumRequests"` + TotalAPIDurationMs int64 `json:"totalApiDurationMs"` + SessionDurationMs int64 `json:"sessionDurationMs"` + CodeChanges *copilotCodeChanges `json:"codeChanges,omitempty"` +} + +type copilotCodeChanges struct { + LinesAdded int `json:"linesAdded"` + LinesRemoved int `json:"linesRemoved"` + FilesModified []string `json:"filesModified"` +} + +// ── Arg builder ── + +// copilotBlockedArgs are flags hardcoded by the daemon that must not be +// overridden by user-configured custom_args. +var copilotBlockedArgs = map[string]blockedArgMode{ + "-p": blockedWithValue, + "--output-format": blockedWithValue, + "--allow-all": blockedStandalone, // tools + paths + URLs + "--allow-all-tools": blockedStandalone, + "--allow-all-paths": blockedStandalone, + "--allow-all-urls": blockedStandalone, + "--yolo": blockedStandalone, + "--no-ask-user": blockedStandalone, + "--resume": blockedWithValue, // managed via ExecOptions.ResumeSessionID + "--acp": blockedStandalone, // prevent switching to ACP mode +} + +// buildCopilotArgs assembles the argv for a one-shot copilot invocation. +// +// copilot -p "" --output-format json --allow-all --no-ask-user +// [--resume ] [--model ] +func buildCopilotArgs(prompt string, opts ExecOptions, logger *slog.Logger) []string { + args := []string{ + "-p", prompt, + "--output-format", "json", + "--allow-all", // tools + paths + URLs — full headless mode + "--no-ask-user", + } + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.ResumeSessionID != "" { + args = append(args, "--resume", opts.ResumeSessionID) + } + args = append(args, filterCustomArgs(opts.CustomArgs, copilotBlockedArgs, logger)...) + return args +} diff --git a/server/pkg/agent/copilot_test.go b/server/pkg/agent/copilot_test.go new file mode 100644 index 000000000..b5cf5738b --- /dev/null +++ b/server/pkg/agent/copilot_test.go @@ -0,0 +1,694 @@ +package agent + +import ( + "encoding/json" + "log/slog" + "strings" + "testing" +) + +// ── Fixtures from real Copilot CLI v1.0.28 --output-format json output ── + +const fixtureAssistantMessageDelta = `{"type":"assistant.message_delta","data":{"messageId":"b5148f3f-d24b-4a5e-a95c-2be7d6493a52","deltaContent":"pong"},"id":"eb6c3ef1-0388-4010-bf8e-4002b62db58c","timestamp":"2026-04-16T08:43:38.401Z","parentId":"417b175a-b303-4378-9c43-d4fcb177c05a","ephemeral":true}` + +const fixtureAssistantMessage = `{"type":"assistant.message","data":{"messageId":"b5148f3f-d24b-4a5e-a95c-2be7d6493a52","content":"pong","toolRequests":[],"interactionId":"267266f6-47bc-4f31-8338-4e95961cf900","outputTokens":5,"requestId":"D012:2F8B66:8CB3C8:98A605:69E0A137"},"id":"ddff21bc-5829-4892-822a-06f3f543ea1d","timestamp":"2026-04-16T08:43:38.493Z","parentId":"417b175a-b303-4378-9c43-d4fcb177c05a"}` + +const fixtureAssistantMessageWithTools = `{"type":"assistant.message","data":{"messageId":"0c48f3f5-74a2-485b-8969-3ea8ddc4c303","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL","name":"bash","arguments":{"command":"ls","description":"List files"},"type":"function","intentionSummary":"List files in current directory"}],"interactionId":"b7bede2d-6996-4728-bdfa-33ba546ed511","outputTokens":112,"requestId":"EB94:21B867:8C33D3:983053:69E0A149"},"id":"6c005d04-bf23-4114-8dcb-f2f9bcdd3880","timestamp":"2026-04-16T08:43:59.066Z","parentId":"387c2814-f893-443c-82b8-00db66fef14c"}` + +const fixtureToolExecComplete = `{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL","model":"claude-opus-4.6","interactionId":"b7bede2d-6996-4728-bdfa-33ba546ed511","success":true,"result":{"content":"file1.go\nfile2.go\n","detailedContent":"file1.go\nfile2.go\n"},"toolTelemetry":{}},"id":"1662b7b1-5160-4c03-bc83-59a9a367f070","timestamp":"2026-04-16T08:43:59.530Z","parentId":"92531882-91ba-442a-9974-3dd8745fffd0"}` + +const fixtureToolExecCompleteError = `{"type":"tool.execution_complete","data":{"toolCallId":"toolu_err_01","model":"claude-opus-4.6","interactionId":"int-1","success":false,"error":{"message":"command not found: foobar"}},"id":"err-1","timestamp":"2026-04-16T08:44:00.000Z","parentId":"p-1"}` + +const fixtureTurnStart = `{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"267266f6-47bc-4f31-8338-4e95961cf900"},"id":"417b175a-b303-4378-9c43-d4fcb177c05a","timestamp":"2026-04-16T08:43:36.401Z","parentId":"ed1a637b-c636-4b74-bc82-4ba3f3386aad"}` + +const fixtureResult = `{"type":"result","timestamp":"2026-04-16T08:43:38.524Z","sessionId":"35059dc3-d928-4ffb-8616-b78938621d85","exitCode":0,"usage":{"premiumRequests":3,"totalApiDurationMs":1763,"sessionDurationMs":6275,"codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]}}}` + +const fixtureResultNonZero = `{"type":"result","timestamp":"2026-04-16T08:50:00.000Z","sessionId":"dead-beef","exitCode":1,"usage":{"premiumRequests":1,"totalApiDurationMs":500,"sessionDurationMs":1000}}` + +const fixtureSessionError = `{"type":"session.error","data":{"errorType":"rate_limit","message":"Rate limit exceeded"},"id":"se-1","timestamp":"2026-04-16T09:00:00.000Z","parentId":"p-1"}` + +const fixtureEphemeral = `{"type":"session.mcp_servers_loaded","data":{"servers":[{"name":"github-mcp-server","status":"connected","source":"builtin"}]},"id":"330ac6bb-b2db-435e-8082-686face58a72","timestamp":"2026-04-16T08:43:34.803Z","parentId":"fe20d689-31ec-492c-9eb5-57a0d0834d70","ephemeral":true}` + +const fixtureSessionStart = `{"type":"session.start","data":{"sessionId":"35059dc3-d928-4ffb-8616-b78938621d85","selectedModel":"claude-sonnet-4","context":{"cwd":"/tmp"}},"id":"ss-1","timestamp":"2026-04-16T08:43:34.000Z"}` + +const fixtureReasoning = `{"type":"assistant.reasoning","data":{"content":"Let me think about this..."},"id":"r-1","timestamp":"2026-04-16T08:43:37.000Z","parentId":"p-1"}` + +const fixtureReasoningDelta = `{"type":"assistant.reasoning_delta","data":{"deltaContent":"thinking step"},"id":"rd-1","timestamp":"2026-04-16T08:43:37.100Z","parentId":"p-1","ephemeral":true}` + +const fixtureSessionWarning = `{"type":"session.warning","data":{"warningType":"rate_limit_approaching","message":"You are approaching your rate limit"},"id":"sw-1","timestamp":"2026-04-16T09:00:00.000Z","parentId":"p-1"}` + +// parseCopilotEvent is a test helper that unmarshals a JSONL line into a copilotEvent. +func parseCopilotEvent(t *testing.T, line string) copilotEvent { + t.Helper() + var evt copilotEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Fatalf("failed to parse fixture: %v", err) + } + return evt +} + +// ── Parser tests using real JSONL fixtures ── + +func TestCopilotParseAssistantMessageDelta(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureAssistantMessageDelta) + + if evt.Type != "assistant.message_delta" { + t.Fatalf("expected type assistant.message_delta, got %q", evt.Type) + } + var delta copilotMessageDelta + if err := json.Unmarshal(evt.Data, &delta); err != nil { + t.Fatalf("unmarshal delta data: %v", err) + } + if delta.DeltaContent != "pong" { + t.Fatalf("expected deltaContent 'pong', got %q", delta.DeltaContent) + } + if delta.MessageID != "b5148f3f-d24b-4a5e-a95c-2be7d6493a52" { + t.Fatalf("unexpected messageId: %q", delta.MessageID) + } +} + +func TestCopilotParseAssistantMessage(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureAssistantMessage) + + if evt.Type != "assistant.message" { + t.Fatalf("expected type assistant.message, got %q", evt.Type) + } + var msg copilotAssistantMessage + if err := json.Unmarshal(evt.Data, &msg); err != nil { + t.Fatalf("unmarshal message data: %v", err) + } + if msg.Content != "pong" { + t.Fatalf("expected content 'pong', got %q", msg.Content) + } + if msg.OutputTokens != 5 { + t.Fatalf("expected outputTokens 5, got %d", msg.OutputTokens) + } + if len(msg.ToolRequests) != 0 { + t.Fatalf("expected no tool requests, got %d", len(msg.ToolRequests)) + } +} + +func TestCopilotParseAssistantMessageWithToolRequests(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureAssistantMessageWithTools) + + var msg copilotAssistantMessage + if err := json.Unmarshal(evt.Data, &msg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(msg.ToolRequests) != 1 { + t.Fatalf("expected 1 tool request, got %d", len(msg.ToolRequests)) + } + tr := msg.ToolRequests[0] + if tr.Name != "bash" { + t.Fatalf("expected tool name 'bash', got %q", tr.Name) + } + if tr.ToolCallID != "toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL" { + t.Fatalf("unexpected toolCallId: %q", tr.ToolCallID) + } + var args map[string]any + if err := json.Unmarshal(tr.Arguments, &args); err != nil { + t.Fatalf("unmarshal arguments: %v", err) + } + if args["command"] != "ls" { + t.Fatalf("expected command 'ls', got %v", args["command"]) + } +} + +func TestCopilotParseToolExecComplete(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureToolExecComplete) + + if evt.Type != "tool.execution_complete" { + t.Fatalf("expected type tool.execution_complete, got %q", evt.Type) + } + var tc copilotToolExecComplete + if err := json.Unmarshal(evt.Data, &tc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !tc.Success { + t.Fatal("expected success=true") + } + if tc.Model != "claude-opus-4.6" { + t.Fatalf("expected model claude-opus-4.6, got %q", tc.Model) + } + if tc.Result == nil || tc.Result.Content != "file1.go\nfile2.go\n" { + t.Fatalf("unexpected result content: %v", tc.Result) + } +} + +func TestCopilotParseToolExecCompleteError(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureToolExecCompleteError) + + var tc copilotToolExecComplete + if err := json.Unmarshal(evt.Data, &tc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if tc.Success { + t.Fatal("expected success=false") + } + if tc.Error == nil || tc.Error.Message != "command not found: foobar" { + t.Fatalf("unexpected error: %v", tc.Error) + } +} + +func TestCopilotParseResult(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureResult) + + if evt.Type != "result" { + t.Fatalf("expected type result, got %q", evt.Type) + } + if evt.SessionID != "35059dc3-d928-4ffb-8616-b78938621d85" { + t.Fatalf("unexpected sessionId: %q", evt.SessionID) + } + if evt.ExitCode != 0 { + t.Fatalf("expected exitCode 0, got %d", evt.ExitCode) + } + if evt.Usage == nil { + t.Fatal("expected usage to be present") + } + if evt.Usage.PremiumRequests != 3 { + t.Fatalf("expected 3 premiumRequests, got %d", evt.Usage.PremiumRequests) + } + if evt.Usage.TotalAPIDurationMs != 1763 { + t.Fatalf("expected totalApiDurationMs 1763, got %d", evt.Usage.TotalAPIDurationMs) + } +} + +func TestCopilotParseResultNonZeroExit(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureResultNonZero) + + if evt.ExitCode != 1 { + t.Fatalf("expected exitCode 1, got %d", evt.ExitCode) + } + if evt.SessionID != "dead-beef" { + t.Fatalf("unexpected sessionId: %q", evt.SessionID) + } +} + +func TestCopilotParseSessionError(t *testing.T) { + t.Parallel() + evt := parseCopilotEvent(t, fixtureSessionError) + + if evt.Type != "session.error" { + t.Fatalf("expected type session.error, got %q", evt.Type) + } + var se copilotSessionError + if err := json.Unmarshal(evt.Data, &se); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if se.ErrorType != "rate_limit" { + t.Fatalf("expected errorType rate_limit, got %q", se.ErrorType) + } + if se.Message != "Rate limit exceeded" { + t.Fatalf("unexpected message: %q", se.Message) + } +} + +// ── Integration-style tests: feed fixture JSONL through the event loop ── + +// simulateCopilotEventLoop feeds JSONL lines through handleCopilotEvent — +// the exact same function used in production — and collects the results. +func simulateCopilotEventLoop(t *testing.T, lines []string) ([]Message, string, string, map[string]TokenUsage) { + return simulateCopilotEventLoopWithModel(t, lines, "copilot") +} + +func simulateCopilotEventLoopWithModel(t *testing.T, lines []string, seedModel string) ([]Message, string, string, map[string]TokenUsage) { + t.Helper() + var msgs []Message + st := newCopilotEventState(seedModel) + + for _, line := range lines { + var evt copilotEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + msgs = append(msgs, handleCopilotEvent(evt, st)...) + } + return msgs, st.sessionID, st.finalStatus, st.usage +} + +func TestCopilotEventLoopSimpleMessage(t *testing.T) { + t.Parallel() + lines := []string{ + fixtureTurnStart, + fixtureAssistantMessageDelta, + fixtureAssistantMessage, + `{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"fc387368","timestamp":"2026-04-16T08:43:38.494Z","parentId":"ddff21bc"}`, + fixtureResult, + } + + msgs, sessionID, status, usage := simulateCopilotEventLoop(t, lines) + + if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" { + t.Fatalf("unexpected sessionId: %q", sessionID) + } + if status != "completed" { + t.Fatalf("expected completed, got %q", status) + } + + // Should have: turn_start(status), delta(text:pong), message doesn't re-emit text + var gotStatus, gotText bool + for _, m := range msgs { + if m.Type == MessageStatus && m.Status == "running" { + gotStatus = true + } + if m.Type == MessageText && m.Content == "pong" { + gotText = true + } + } + if !gotStatus { + t.Fatal("expected status=running message") + } + if !gotText { + t.Fatal("expected text=pong message") + } + + u, ok := usage["copilot"] + if !ok { + t.Fatal("expected usage entry for 'copilot'") + } + if u.OutputTokens != 5 { + t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens) + } +} + +func TestCopilotEventLoopToolUseFlow(t *testing.T) { + t.Parallel() + lines := []string{ + fixtureTurnStart, + fixtureAssistantMessageWithTools, + fixtureToolExecComplete, + fixtureResult, + } + + msgs, sessionID, status, usage := simulateCopilotEventLoop(t, lines) + + if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" { + t.Fatalf("unexpected sessionId: %q", sessionID) + } + if status != "completed" { + t.Fatalf("expected completed, got %q", status) + } + + // Find tool use and tool result messages. + var toolUse, toolResult *Message + for i := range msgs { + if msgs[i].Type == MessageToolUse { + toolUse = &msgs[i] + } + if msgs[i].Type == MessageToolResult { + toolResult = &msgs[i] + } + } + if toolUse == nil { + t.Fatal("expected MessageToolUse") + } + if toolUse.Tool != "bash" { + t.Fatalf("expected tool 'bash', got %q", toolUse.Tool) + } + if toolUse.CallID != "toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL" { + t.Fatalf("unexpected callID: %q", toolUse.CallID) + } + if toolResult == nil { + t.Fatal("expected MessageToolResult") + } + if toolResult.CallID != toolUse.CallID { + t.Fatalf("tool result callID %q doesn't match tool use callID %q", toolResult.CallID, toolUse.CallID) + } + if !strings.Contains(toolResult.Output, "file1.go") { + t.Fatalf("expected tool result to contain 'file1.go', got %q", toolResult.Output) + } + + // After tool.execution_complete with model, activeModel should be updated. + if _, ok := usage["claude-opus-4.6"]; ok { + // outputTokens from assistant.message came BEFORE tool.execution_complete, + // so they should be under "copilot", not "claude-opus-4.6". + t.Log("model attribution is correct: assistant.message tokens go under initial model") + } + u := usage["copilot"] + if u.OutputTokens != 112 { + t.Fatalf("expected 112 outputTokens under 'copilot', got %d", u.OutputTokens) + } +} + +func TestCopilotEventLoopToolExecError(t *testing.T) { + t.Parallel() + lines := []string{fixtureToolExecCompleteError} + + msgs, _, _, _ := simulateCopilotEventLoop(t, lines) + + var found bool + for _, m := range msgs { + if m.Type == MessageToolResult && strings.Contains(m.Output, "command not found: foobar") { + found = true + } + } + if !found { + t.Fatal("expected tool result with error message") + } +} + +func TestCopilotEventLoopNonZeroExit(t *testing.T) { + t.Parallel() + lines := []string{fixtureResultNonZero} + + _, sessionID, status, _ := simulateCopilotEventLoop(t, lines) + + if status != "failed" { + t.Fatalf("expected failed, got %q", status) + } + if sessionID != "dead-beef" { + t.Fatalf("unexpected sessionId: %q", sessionID) + } +} + +func TestCopilotEventLoopSessionError(t *testing.T) { + t.Parallel() + lines := []string{fixtureSessionError} + + msgs, _, status, _ := simulateCopilotEventLoop(t, lines) + + if status != "failed" { + t.Fatalf("expected failed, got %q", status) + } + var found bool + for _, m := range msgs { + if m.Type == MessageLog && m.Level == "error" && m.Content == "Rate limit exceeded" { + found = true + } + } + if !found { + t.Fatal("expected error log message") + } +} + +func TestCopilotEventLoopSkipsUnknownTypes(t *testing.T) { + t.Parallel() + lines := []string{ + fixtureEphemeral, // session.mcp_servers_loaded — should not produce messages + `{"type":"session.skills_loaded","data":{},"id":"x","timestamp":"2026-04-16T08:43:34.811Z","parentId":"y","ephemeral":true}`, + `{"type":"session.tools_updated","data":{"model":"claude-opus-4.6"},"id":"z","timestamp":"2026-04-16T08:43:36.397Z","parentId":"w","ephemeral":true}`, + } + + msgs, _, _, _ := simulateCopilotEventLoop(t, lines) + + if len(msgs) != 0 { + t.Fatalf("expected no messages for unknown/ephemeral event types, got %d: %+v", len(msgs), msgs) + } +} + +func TestCopilotEventLoopMultiTurnUsage(t *testing.T) { + t.Parallel() + // Simulate: turn 0 has tool use (112 tokens), tool completes with model info, + // turn 1 has text response (106 tokens) — now under claude-opus-4.6 model. + lines := []string{ + fixtureTurnStart, + fixtureAssistantMessageWithTools, // 112 outputTokens, activeModel="copilot" + fixtureToolExecComplete, // sets activeModel="claude-opus-4.6" + `{"type":"assistant.turn_start","data":{"turnId":"1"},"id":"t1","timestamp":"2026-04-16T08:44:01.000Z","parentId":"p1"}`, + // Turn 1 assistant.message with 106 tokens — should go under "claude-opus-4.6" + `{"type":"assistant.message","data":{"messageId":"msg-2","content":"Here are the files.","toolRequests":[],"interactionId":"int-1","outputTokens":106},"id":"m2","timestamp":"2026-04-16T08:44:02.000Z","parentId":"t1"}`, + fixtureResult, + } + + _, _, _, usage := simulateCopilotEventLoop(t, lines) + + if u := usage["copilot"]; u.OutputTokens != 112 { + t.Fatalf("expected 112 tokens under 'copilot', got %d", u.OutputTokens) + } + if u := usage["claude-opus-4.6"]; u.OutputTokens != 106 { + t.Fatalf("expected 106 tokens under 'claude-opus-4.6', got %d", u.OutputTokens) + } +} + +func TestCopilotEventLoopSessionStartSetsModel(t *testing.T) { + t.Parallel() + lines := []string{ + fixtureSessionStart, + fixtureTurnStart, + fixtureAssistantMessage, // 5 outputTokens + fixtureResult, + } + + _, _, _, usage := simulateCopilotEventLoop(t, lines) + + // session.start sets selectedModel to "claude-sonnet-4", + // so tokens should be attributed there, not "copilot". + if _, ok := usage["copilot"]; ok { + t.Fatal("expected no tokens under 'copilot' when session.start provides selectedModel") + } + u, ok := usage["claude-sonnet-4"] + if !ok { + t.Fatal("expected tokens under 'claude-sonnet-4'") + } + if u.OutputTokens != 5 { + t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens) + } +} + +func TestCopilotEventLoopSeedModelFromOpts(t *testing.T) { + t.Parallel() + // No session.start — seed model comes from opts.Model (simulated via seedModel param). + lines := []string{ + fixtureTurnStart, + fixtureAssistantMessage, // 5 outputTokens + fixtureResult, + } + + _, _, _, usage := simulateCopilotEventLoopWithModel(t, lines, "gpt-4o") + + u, ok := usage["gpt-4o"] + if !ok { + t.Fatal("expected tokens under 'gpt-4o'") + } + if u.OutputTokens != 5 { + t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens) + } +} + +func TestCopilotEventLoopReasoning(t *testing.T) { + t.Parallel() + lines := []string{ + fixtureReasoning, + fixtureReasoningDelta, + } + + msgs, _, _, _ := simulateCopilotEventLoop(t, lines) + + var thinking []string + for _, m := range msgs { + if m.Type == MessageThinking { + thinking = append(thinking, m.Content) + } + } + if len(thinking) != 2 { + t.Fatalf("expected 2 thinking messages, got %d: %v", len(thinking), thinking) + } + if thinking[0] != "Let me think about this..." { + t.Fatalf("unexpected reasoning content: %q", thinking[0]) + } + if thinking[1] != "thinking step" { + t.Fatalf("unexpected reasoning_delta content: %q", thinking[1]) + } +} + +func TestCopilotEventLoopReasoningTextInMessage(t *testing.T) { + t.Parallel() + // assistant.message with reasoningText field set. + lines := []string{ + `{"type":"assistant.message","data":{"messageId":"msg-r","content":"answer","toolRequests":[],"interactionId":"int-r","outputTokens":10,"reasoningText":"I thought carefully"},"id":"mr","timestamp":"2026-04-16T08:44:00.000Z","parentId":"p-1"}`, + } + + msgs, _, _, _ := simulateCopilotEventLoop(t, lines) + + var gotThinking bool + for _, m := range msgs { + if m.Type == MessageThinking && m.Content == "I thought carefully" { + gotThinking = true + } + } + if !gotThinking { + t.Fatal("expected MessageThinking from reasoningText in assistant.message") + } +} + +func TestCopilotEventLoopSessionWarning(t *testing.T) { + t.Parallel() + lines := []string{fixtureSessionWarning} + + msgs, _, status, _ := simulateCopilotEventLoop(t, lines) + + // Warnings should NOT change finalStatus. + if status != "completed" { + t.Fatalf("expected completed, got %q", status) + } + var found bool + for _, m := range msgs { + if m.Type == MessageLog && m.Level == "warn" && m.Content == "You are approaching your rate limit" { + found = true + } + } + if !found { + t.Fatal("expected warn log message for session.warning") + } +} + +func TestCopilotEventLoopDeltaFallbackOutput(t *testing.T) { + t.Parallel() + // Only deltas, no assistant.message — simulates process killed mid-stream. + lines := []string{ + `{"type":"assistant.message_delta","data":{"messageId":"m1","deltaContent":"hello "},"id":"d1","timestamp":"2026-04-16T08:43:38.000Z","parentId":"p1","ephemeral":true}`, + `{"type":"assistant.message_delta","data":{"messageId":"m1","deltaContent":"world"},"id":"d2","timestamp":"2026-04-16T08:43:38.100Z","parentId":"p1","ephemeral":true}`, + } + + st := newCopilotEventState("copilot") + for _, line := range lines { + var evt copilotEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Fatal(err) + } + handleCopilotEvent(evt, st) + } + + if st.output.String() != "hello world" { + t.Fatalf("expected output 'hello world', got %q", st.output.String()) + } +} + +// ── Arg builder tests ── + +func TestBuildCopilotArgsBaseline(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("write a haiku", ExecOptions{}, slog.Default()) + expected := []string{ + "-p", "write a haiku", + "--output-format", "json", + "--allow-all", + "--no-ask-user", + } + + 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("expected args[%d] = %q, got %q", i, want, args[i]) + } + } +} + +func TestBuildCopilotArgsWithModel(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{Model: "gpt-4o"}, slog.Default()) + + var foundModel bool + for i, a := range args { + if a == "--model" { + if i+1 >= len(args) || args[i+1] != "gpt-4o" { + t.Fatalf("expected --model followed by gpt-4o, got %v", args) + } + foundModel = true + break + } + } + if !foundModel { + t.Fatalf("expected --model flag when Model is set, got args=%v", args) + } +} + +func TestBuildCopilotArgsWithResume(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{ResumeSessionID: "sess-42"}, slog.Default()) + + var foundResume bool + for i, a := range args { + if a == "--resume" { + if i+1 >= len(args) || args[i+1] != "sess-42" { + t.Fatalf("expected --resume followed by session id, got %v", args) + } + foundResume = true + break + } + } + if !foundResume { + t.Fatalf("expected --resume flag when ResumeSessionID is set, got args=%v", args) + } +} + +func TestBuildCopilotArgsOmitsOptionalWhenEmpty(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{}, slog.Default()) + for _, a := range args { + if a == "--model" { + t.Fatalf("expected no --model flag when Model is empty, got args=%v", args) + } + if a == "--resume" { + t.Fatalf("expected no --resume flag when ResumeSessionID is empty, got args=%v", args) + } + } +} + +func TestBuildCopilotArgsPassesThroughCustomArgs(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{ + CustomArgs: []string{"--max-turns", "50"}, + }, slog.Default()) + + if args[len(args)-2] != "--max-turns" || args[len(args)-1] != "50" { + t.Fatalf("expected --max-turns 50 at end of args, got %v", args) + } +} + +func TestBuildCopilotArgsFiltersBlockedCustomArgs(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{ + CustomArgs: []string{"--output-format", "text", "--max-turns", "50"}, + }, slog.Default()) + + for i, a := range args { + if a == "--output-format" && i+1 < len(args) && args[i+1] == "text" { + t.Fatalf("blocked --output-format text should have been filtered: %v", args) + } + } + found := false + for i, a := range args { + if a == "--max-turns" && i+1 < len(args) && args[i+1] == "50" { + found = true + } + } + if !found { + t.Fatalf("expected --max-turns 50 to pass through, got %v", args) + } +} + +func TestBuildCopilotArgsBlocksResumeAndACP(t *testing.T) { + t.Parallel() + + args := buildCopilotArgs("hi", ExecOptions{ + CustomArgs: []string{"--resume", "bad-session", "--acp", "--yolo"}, + }, slog.Default()) + + for _, a := range args { + if a == "bad-session" { + t.Fatalf("blocked --resume value should have been filtered: %v", args) + } + if a == "--acp" { + t.Fatalf("blocked --acp should have been filtered: %v", args) + } + if a == "--yolo" { + t.Fatalf("blocked --yolo should have been filtered: %v", args) + } + } +} diff --git a/server/pkg/agent/version.go b/server/pkg/agent/version.go index ee026101a..b2ebfc22a 100644 --- a/server/pkg/agent/version.go +++ b/server/pkg/agent/version.go @@ -9,8 +9,9 @@ import ( // MinVersions defines the minimum required CLI version for each agent type. // Versions below these will be rejected during daemon registration. var MinVersions = map[string]string{ - "claude": "2.0.0", - "codex": "0.100.0", // app-server --listen stdio:// added in 0.100.0 + "claude": "2.0.0", + "codex": "0.100.0", // app-server --listen stdio:// added in 0.100.0 + "copilot": "1.0.0", // --output-format json envelope stable from 1.0.x } // semver holds a parsed semantic version (major.minor.patch).