mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
2
Makefile
2
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)"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
423
server/pkg/agent/copilot.go
Normal 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
|
||||
}
|
||||
694
server/pkg/agent/copilot_test.go
Normal file
694
server/pkg/agent/copilot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user