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).