mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(agent): deliver the copilot prompt on stdin; name E2BIG start failures (MUL-8105) Copilot inlined the whole task prompt as -p <prompt>, so a thread above Linux's 131,072-byte MAX_ARG_STRLEN died at fork/exec with E2BIG (#9362). The prompt now rides stdin, mirroring the OpenCode move in #6552. startOwnedProcessTree also wraps oversized-command-line failures in ErrArgvLimitExceeded with an actionable remedy. Co-authored-by: multica-agent <[email protected]> * docs(agent): update copilotBackend comment for stdin prompt delivery (MUL-8105) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
705 lines
25 KiB
Go
705 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// copilotBackend implements Backend by spawning the GitHub Copilot CLI
|
|
// with --output-format json and parsing its JSONL event stream.
|
|
//
|
|
// The integration runs Copilot non-interactively with the prompt piped on
|
|
// stdin and no -p, never as a CLI argument: an argv prompt dies at fork/exec
|
|
// once it clears the OS argument size limit (#9362; see buildCopilotArgs).
|
|
// 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 holds the latest COMPLETE assistant turn, not every turn joined:
|
|
// Result.Output is "final user-facing output selected by the backend"
|
|
// (agent.go), and a tool-using run's earlier turns are narration around the
|
|
// work, not the deliverable (GH #6006). Every turn still reaches the
|
|
// transcript through the emitted MessageText.
|
|
output strings.Builder
|
|
// pendingDelta buffers the turn currently streaming. It is cleared when that
|
|
// turn's authoritative assistant.message lands, so it is non-empty only when
|
|
// the process died mid-turn — the one case where a partial beats the
|
|
// previous turn's complete text.
|
|
pendingDelta strings.Builder
|
|
sessionID string
|
|
activeModel string
|
|
finalStatus string
|
|
finalError string
|
|
// sawResult records that the terminal "result" event arrived, i.e. the CLI
|
|
// consumed the whole stdin prompt and ran to completion.
|
|
sawResult bool
|
|
|
|
// Token usage arrives on up to three different events, so each source is
|
|
// accumulated separately and resolved once at the end by resolveUsage.
|
|
//
|
|
// session.shutdown per-model session totals, full breakdown
|
|
// assistant.usage per model call, full breakdown
|
|
// assistant.message outputTokens only — legacy, older CLIs
|
|
//
|
|
// They must never be summed together: all three describe the same tokens.
|
|
callUsage map[string]TokenUsage
|
|
msgUsage map[string]TokenUsage
|
|
shutdownUsage map[string]TokenUsage
|
|
// resumed marks a run that continued an existing Copilot session, which is
|
|
// what disqualifies the session.shutdown totals — see resolveUsage.
|
|
resumed bool
|
|
}
|
|
|
|
// resolveUsage picks the single best usage source this run produced, most
|
|
// complete first.
|
|
//
|
|
// On a fresh session, session.shutdown is the CLI's own final accounting for
|
|
// the whole session — which here IS this run — so it is the most complete
|
|
// source available and cannot be short-changed by an individual model call
|
|
// that failed to report. It is unusable on a resumed run: the CLI restores its
|
|
// accumulators from a checkpoint, so it also carries every earlier turn's
|
|
// tokens, and reporting that on a follow-up would bill the same tokens once per
|
|
// turn. Under-reporting beats double-billing, so a resumed run falls through.
|
|
//
|
|
// assistant.usage is per model call, so it measures exactly this run either
|
|
// way. assistant.message is last: it only ever carries output tokens, so
|
|
// letting it win over a source with the full breakdown would silently drop the
|
|
// input and cache tiers.
|
|
func (st *copilotEventState) resolveUsage() map[string]TokenUsage {
|
|
if !st.resumed && hasTokens(st.shutdownUsage) {
|
|
return st.shutdownUsage
|
|
}
|
|
if hasTokens(st.callUsage) {
|
|
return st.callUsage
|
|
}
|
|
if hasTokens(st.msgUsage) {
|
|
return st.msgUsage
|
|
}
|
|
return map[string]TokenUsage{}
|
|
}
|
|
|
|
// hasTokens reports whether a source carries any real numbers. Presence alone
|
|
// is not enough: every token field on assistant.usage is optional upstream, so
|
|
// a usage event that names only a model would otherwise mark that source
|
|
// "populated" and shadow one that actually has tokens.
|
|
func hasTokens(usage map[string]TokenUsage) bool {
|
|
for _, u := range usage {
|
|
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addUsage folds one usage record into dst under model, ignoring records that
|
|
// carry no tokens at all so an empty one never creates an entry.
|
|
//
|
|
// Copilot reports cached tokens INSIDE inputTokens (its own event-log reducer
|
|
// derives uncached input as inputTokens - cacheRead - cacheWrite), whereas
|
|
// TokenUsage.InputTokens is the uncached remainder that prices at the input
|
|
// rate. Subtract here so the cache tiers are not billed twice. reasoningTokens
|
|
// are deliberately ignored: they are already part of outputTokens.
|
|
func addUsage(dst map[string]TokenUsage, model string, input, output, cacheRead, cacheWrite int64) {
|
|
cacheRead = nonNegativeTokens(cacheRead)
|
|
cacheWrite = nonNegativeTokens(cacheWrite)
|
|
output = nonNegativeTokens(output)
|
|
uncachedInput := nonNegativeTokens(input - cacheRead - cacheWrite)
|
|
if uncachedInput == 0 && output == 0 && cacheRead == 0 && cacheWrite == 0 {
|
|
return
|
|
}
|
|
u := dst[model]
|
|
u.InputTokens += uncachedInput
|
|
u.OutputTokens += output
|
|
u.CacheReadTokens += cacheRead
|
|
u.CacheWriteTokens += cacheWrite
|
|
dst[model] = u
|
|
}
|
|
|
|
func nonNegativeTokens(n int64) int64 {
|
|
if n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// finalOutput is the deliverable for Result.Output: the last complete assistant
|
|
// turn, or the partial turn that was still streaming when the process died.
|
|
func (st *copilotEventState) finalOutput() string {
|
|
if st.pendingDelta.Len() > 0 {
|
|
return st.pendingDelta.String()
|
|
}
|
|
return st.output.String()
|
|
}
|
|
|
|
func newCopilotEventState(seedModel string, resumed bool) *copilotEventState {
|
|
return &copilotEventState{
|
|
activeModel: seedModel,
|
|
finalStatus: "completed",
|
|
callUsage: make(map[string]TokenUsage),
|
|
msgUsage: make(map[string]TokenUsage),
|
|
shutdownUsage: make(map[string]TokenUsage),
|
|
resumed: resumed,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if ss.SelectedModel != "" {
|
|
st.activeModel = ss.SelectedModel
|
|
}
|
|
// Capture sessionId from session.start as well: the synthetic
|
|
// "result" event may never arrive (timeout, cancel, crash, or a
|
|
// session.error before result), and without this the daemon
|
|
// reports SessionID="" and the chat-session resume pointer can
|
|
// drift to a stale turn. result still wins when it does arrive.
|
|
if ss.SessionID != "" {
|
|
st.sessionID = ss.SessionID
|
|
}
|
|
}
|
|
|
|
case "assistant.message_delta":
|
|
var delta copilotMessageDelta
|
|
if err := json.Unmarshal(evt.Data, &delta); err == nil && delta.DeltaContent != "" {
|
|
// Buffer deltas as defense-in-depth: if the process is killed before
|
|
// this turn's assistant.message arrives, we still have its text.
|
|
st.pendingDelta.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 and supersedes both the
|
|
// deltas that streamed it and whatever earlier turn output held: a run
|
|
// that narrates, calls a tool, then answers must deliver the answer, not
|
|
// the pair joined together.
|
|
if msg.Content != "" {
|
|
st.output.Reset()
|
|
st.output.WriteString(msg.Content)
|
|
}
|
|
// Clear unconditionally — this event IS the turn boundary. A tool-only
|
|
// turn reports content:"" (the toolRequests below are the whole turn), and
|
|
// leaving its deltas buffered would let them be stitched onto the NEXT
|
|
// turn's partial text if the process then died mid-stream.
|
|
st.pendingDelta.Reset()
|
|
// The message names the model that produced it. Without this the first
|
|
// turn's tokens land under the seed model — the literal string
|
|
// "copilot" when no model was configured — which no price table maps,
|
|
// so a run that DID report tokens still estimated $0.00.
|
|
if msg.Model != "" {
|
|
st.activeModel = msg.Model
|
|
}
|
|
if msg.ReasoningText != "" {
|
|
msgs = append(msgs, Message{Type: MessageThinking, Content: msg.ReasoningText})
|
|
}
|
|
if msg.OutputTokens > 0 {
|
|
addUsage(st.msgUsage, st.activeModel, 0, msg.OutputTokens, 0, 0)
|
|
}
|
|
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.usage":
|
|
// One record per model API call, with the full token breakdown. This is
|
|
// the only event that reports input and cache tokens at all.
|
|
var u copilotUsageData
|
|
if err := json.Unmarshal(evt.Data, &u); err != nil {
|
|
return nil
|
|
}
|
|
model := u.Model
|
|
// The CLI writes "unknown" when it cannot name the model; keep whatever
|
|
// the session already resolved rather than opening a bogus usage row.
|
|
if model == "" || model == "unknown" {
|
|
model = st.activeModel
|
|
} else {
|
|
st.activeModel = model
|
|
}
|
|
addUsage(st.callUsage, model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
|
|
|
|
case "session.shutdown":
|
|
// Session-wide per-model totals, emitted once as the session tears down.
|
|
var sd copilotShutdownData
|
|
if err := json.Unmarshal(evt.Data, &sd); err != nil {
|
|
return nil
|
|
}
|
|
for model, m := range sd.ModelMetrics {
|
|
if model == "" {
|
|
model = st.activeModel
|
|
}
|
|
addUsage(st.shutdownUsage, model, m.Usage.InputTokens, m.Usage.OutputTokens, m.Usage.CacheReadTokens, m.Usage.CacheWriteTokens)
|
|
}
|
|
|
|
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.sawResult = true
|
|
if evt.SessionID != "" {
|
|
st.sessionID = evt.SessionID
|
|
}
|
|
if evt.ExitCode != 0 {
|
|
st.finalStatus = "failed"
|
|
st.finalError = withCopilotExitCode(st.finalError, evt.ExitCode)
|
|
}
|
|
}
|
|
|
|
return msgs
|
|
}
|
|
|
|
func withCopilotExitCode(msg string, exitCode int) string {
|
|
exitMsg := fmt.Sprintf("copilot exited with code %d", exitCode)
|
|
msg = strings.TrimSpace(msg)
|
|
if msg == "" {
|
|
return exitMsg
|
|
}
|
|
if strings.Contains(msg, exitMsg) {
|
|
return msg
|
|
}
|
|
return msg + "; " + exitMsg
|
|
}
|
|
|
|
func (b *copilotBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execName := b.cfg.ExecutablePath
|
|
if execName == "" {
|
|
execName = "copilot"
|
|
}
|
|
lookedUp, err := exec.LookPath(execName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("copilot executable not found at %q: %w", execName, err)
|
|
}
|
|
|
|
// Without -p the CLI takes its prompt from stdin; an empty stdin would
|
|
// leave it nothing to run, so refuse before spawning anything.
|
|
if strings.TrimSpace(prompt) == "" {
|
|
return nil, fmt.Errorf("copilot: empty prompt")
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
// The task prompt is delivered on stdin, never argv (see buildCopilotArgs).
|
|
args := buildCopilotArgs(opts, b.cfg.Logger)
|
|
cmd, _, _ := b.cfg.commandAt(execName).execVia(runCtx, chooseCopilotInvocation, lookedUp, args, b.cfg.Logger)
|
|
hideAgentWindow(cmd)
|
|
b.cfg.logAgentCommandWithPrompt(cmd, newAgentCommandLogArgs(args), len(prompt))
|
|
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)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("copilot stdin pipe: %w", err)
|
|
}
|
|
var closeStdinOnce sync.Once
|
|
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
|
|
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[copilot:stderr] "), agentStderrTailBytes)
|
|
cmd.Stderr = stderrBuf
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start copilot: %w", err)
|
|
}
|
|
|
|
// Write the prompt from its own goroutine so it cannot deadlock against the
|
|
// stdout reader: a prompt larger than the OS pipe buffer blocks mid-write
|
|
// until Copilot drains it, and Copilot cannot drain while nobody consumes
|
|
// its stdout. Closing stdin is what ends the prompt, so close on every path.
|
|
writeErrCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.WriteString(stdin, prompt)
|
|
closeStdin()
|
|
writeErrCh <- 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, opts.ResumeSessionID != "")
|
|
|
|
go func() {
|
|
<-runCtx.Done()
|
|
// Release a prompt write still blocked on a full stdin pipe.
|
|
closeStdin()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
scanner := newAgentStreamScanner(stdout)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var evt copilotEvent
|
|
if err := json.Unmarshal([]byte(line), &evt); err != nil {
|
|
slog.Warn("copilot event parse failed", "err", err, "line", line)
|
|
continue
|
|
}
|
|
|
|
for _, m := range handleCopilotEvent(evt, st) {
|
|
trySend(msgCh, m)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
slog.Warn("copilot stdout scanner error", "err", err)
|
|
}
|
|
|
|
exitErr := cmd.Wait()
|
|
releaseProcessGroup(cmd)
|
|
duration := time.Since(startTime)
|
|
|
|
// Wait closes the process pipes, so a prompt write still blocked when
|
|
// Copilot exited has returned by now. The writer sends exactly once.
|
|
writeErr := <-writeErrCh
|
|
|
|
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)
|
|
} else if writeErr != nil && !st.sawResult {
|
|
// A broken prompt write is benign only once the run is proven to have
|
|
// finished: Copilot reads stdin to EOF before it starts, so a run that
|
|
// reached its terminal "result" event received the whole prompt and a
|
|
// late EPIPE just means the pipe closed on the way out. Without that
|
|
// proof the prompt may never have landed — never report that as a
|
|
// clean success.
|
|
st.finalStatus = "failed"
|
|
if st.finalError == "" {
|
|
st.finalError = fmt.Sprintf("copilot prompt write failed: %v", writeErr)
|
|
} else {
|
|
st.finalError = fmt.Sprintf("%s; copilot prompt write failed: %v", st.finalError, writeErr)
|
|
}
|
|
}
|
|
if st.finalError != "" {
|
|
st.finalError = withAgentStderr(st.finalError, "copilot", stderrBuf.Tail())
|
|
}
|
|
|
|
b.cfg.Logger.Info("copilot finished", "pid", cmd.Process.Pid, "status", st.finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
usage := st.resolveUsage()
|
|
// A run that produced output but no tokens is a silent billing hole:
|
|
// the daemon skips reporting empty usage, so nothing surfaces anywhere
|
|
// downstream. This is the current state on Copilot CLI 1.0.77 (see
|
|
// copilotUsageData), and it went unnoticed until a user reported it —
|
|
// log it so the next regression is visible from the daemon log alone.
|
|
if len(usage) == 0 && st.finalStatus == "completed" {
|
|
b.cfg.Logger.Warn("copilot reported no token usage",
|
|
"session", st.sessionID,
|
|
"model", st.activeModel,
|
|
"hint", "Copilot CLI filters assistant.usage and session.shutdown out of --output-format json; only assistant.message.outputTokens remains, and newer CLIs no longer populate it")
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: st.finalStatus,
|
|
Output: st.finalOutput(),
|
|
Error: st.finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: st.sessionID,
|
|
Usage: 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".
|
|
//
|
|
// OutputTokens is optional in the CLI's own event schema and is the ONLY token
|
|
// field that survives onto the `--output-format json` stream — see the
|
|
// suppression note on copilotUsageData.
|
|
type copilotAssistantMessage struct {
|
|
MessageID string `json:"messageId"`
|
|
Model string `json:"model"`
|
|
Content string `json:"content"`
|
|
ToolRequests []copilotToolRequest `json:"toolRequests"`
|
|
OutputTokens int64 `json:"outputTokens"`
|
|
InteractionID string `json:"interactionId"`
|
|
ReasoningText string `json:"reasoningText,omitempty"`
|
|
}
|
|
|
|
// copilotUsageData is data payload for "assistant.usage": one record per model
|
|
// API call, carrying the only complete token breakdown the CLI produces.
|
|
//
|
|
// InputTokens INCLUDES the cached tiers; addUsage subtracts them back out. The
|
|
// payload also carries reasoningTokens, deliberately not parsed here: those are
|
|
// a subset of outputTokens, so counting them would double-bill reasoning.
|
|
//
|
|
// NOTE (Copilot CLI 1.0.77): the CLI's JSONL writer drops a fixed set of event
|
|
// types before writing stdout, and both "assistant.usage" and
|
|
// "session.shutdown" are on that list — so on a current CLI neither reaches us
|
|
// and Copilot runs report no tokens at all. Everything here is still parsed
|
|
// because older CLIs, and any future build that stops filtering them, deliver
|
|
// real numbers through exactly these events. The `result` event we already
|
|
// parse carries premiumRequests and durations but no token counts.
|
|
type copilotUsageData struct {
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"inputTokens"`
|
|
OutputTokens int64 `json:"outputTokens"`
|
|
CacheReadTokens int64 `json:"cacheReadTokens"`
|
|
CacheWriteTokens int64 `json:"cacheWriteTokens"`
|
|
}
|
|
|
|
// copilotShutdownData is data payload for "session.shutdown": session-wide
|
|
// totals keyed by model.
|
|
type copilotShutdownData struct {
|
|
ModelMetrics map[string]copilotShutdownModelMetric `json:"modelMetrics"`
|
|
}
|
|
|
|
type copilotShutdownModelMetric struct {
|
|
Usage copilotUsageData `json:"usage"`
|
|
}
|
|
|
|
// 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 float64 `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 --output-format json --allow-all --no-ask-user
|
|
// [--resume <session-id>] [--model <model>]
|
|
//
|
|
// The prompt is NOT on argv: Copilot CLI enters non-interactive mode when its
|
|
// stdin is piped and no -p is given, running the piped text as the entire
|
|
// prompt (verified on Copilot CLI 1.0.98, including --resume). Inlining it as
|
|
// `-p <prompt>` fails at fork/exec with E2BIG on Linux once the prompt clears
|
|
// MAX_ARG_STRLEN (131,072 bytes) — a long issue thread is enough (#9362) — and
|
|
// hits the 32,767-character CreateProcess ceiling on Windows. "-p" stays in
|
|
// copilotBlockedArgs so custom_args cannot reintroduce that path.
|
|
func buildCopilotArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"--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
|
|
}
|