feat(agent): add GitHub Copilot CLI backend (#1157)

* feat(agent): add GitHub Copilot CLI backend

Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.

Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides

Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path

Frontend:
- Add Copilot logo SVG and provider switch case

Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): add restart subcommand, make daemon uses it

- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): address review nits 1-5

- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
  back to opts.Model, then "copilot"). First-turn tokens now get correct
  model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
  reasoningText in assistant.message → MessageThinking,
  session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
  tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
  dies before assistant.message, output is non-empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LinYushen
2026-04-16 17:14:56 +08:00
committed by GitHub
parent ac8b08e540
commit cd50c31201
11 changed files with 1215 additions and 11 deletions

View File

@@ -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)"

View File

@@ -88,6 +88,16 @@ function PiLogo({ className }: { className: string }) {
);
}
// GitHub Copilot — official mark, sourced from GitHub brand assets
function CopilotLogo({ className }: { className: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className}>
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.279 1.588-1.88a21 21 0 0 1 2.585-1.382C4.646 4.07 5.892 2.476 7.998 2.476c2.106 0 3.352 1.594 3.825 3.6a21 21 0 0 1 2.585 1.381c.911.602 1.503 1.253 1.588 1.881v1.948c-.125.835-3.436 3.749-7.998 3.749m-.002-1.5c2.724 0 5.32-1.556 6.477-2.564.2-.175.27-.396.27-.541V9.358a2.6 2.6 0 0 0-.761-.959c-.373-.287-.87-.593-1.465-.885a4.4 4.4 0 0 1-.198 1.299 1.592 1.592 0 0 1-.738-.648c.062-.27.1-.57.1-.904 0-1.664-.645-3.285-3.685-3.285s-3.685 1.621-3.685 3.285c0 .334.038.635.1.904a1.592 1.592 0 0 1-.738.648 4.4 4.4 0 0 1-.198-1.299 13 13 0 0 0-1.465.885 2.6 2.6 0 0 0-.76.96v1.071c0 .145.069.366.27.541 1.156 1.008 3.752 2.564 6.476 2.564" />
<path d="M6.27 8.5c0 .701-.504 1.27-1.126 1.27S4.018 9.201 4.018 8.5s.504-1.27 1.126-1.27S6.27 7.799 6.27 8.5m5.726 0c0 .701-.504 1.27-1.126 1.27S9.744 9.201 9.744 8.5s.504-1.27 1.126-1.27 1.126.569 1.126 1.27" />
</svg>
);
}
// Cursor — official brand logo from Cursor brand assets
function CursorLogo({ className }: { className: string }) {
return (
@@ -122,6 +132,8 @@ export function ProviderLogo({
return <HermesLogo className={className} />;
case "pi":
return <PiLogo className={className} />;
case "copilot":
return <CopilotLogo className={className} />;
case "cursor":
return <CursorLogo className={className} />;
default:

View File

@@ -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 {

View File

@@ -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

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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{})

423
server/pkg/agent/copilot.go Normal file
View File

@@ -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 "<prompt>" --output-format json --allow-all --no-ask-user
// [--resume <session-id>] [--model <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
}

View File

@@ -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)
}
}
}

View File

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