feat(codex): add telegram planner-executor flow
This commit is contained in:
parent
33cf828760
commit
36b48d6027
28 changed files with 9041 additions and 175 deletions
603
pkg/agent/codex_executor.go
Normal file
603
pkg/agent/codex_executor.go
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const codexPromptHistoryLimit = 24
|
||||||
|
|
||||||
|
func (al *AgentLoop) findCodexPlannerModelName(cfg *config.Config) string {
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if mc, err := cfg.GetModelConfig("gpt-5.4-mini"); err == nil && mc != nil {
|
||||||
|
proto, _ := providers.ExtractProtocol(mc.Model)
|
||||||
|
if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") {
|
||||||
|
return "gpt-5.4-mini"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultName := strings.TrimSpace(cfg.Agents.Defaults.ModelName)
|
||||||
|
if defaultName != "" {
|
||||||
|
if mc, err := cfg.GetModelConfig(defaultName); err == nil && mc != nil {
|
||||||
|
proto, _ := providers.ExtractProtocol(mc.Model)
|
||||||
|
if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") {
|
||||||
|
return defaultName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mc := range cfg.ModelList {
|
||||||
|
if mc == nil || strings.TrimSpace(mc.ModelName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proto, _ := providers.ExtractProtocol(mc.Model)
|
||||||
|
if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") {
|
||||||
|
return strings.TrimSpace(mc.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCodexCLIModelArg(cfg *config.Config, modelName string) string {
|
||||||
|
modelName = strings.TrimSpace(modelName)
|
||||||
|
if modelName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(modelName); err == nil && mc != nil {
|
||||||
|
proto, modelID := providers.ExtractProtocol(mc.Model)
|
||||||
|
if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") {
|
||||||
|
return strings.TrimSpace(modelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proto, modelID := providers.ExtractProtocol(modelName)
|
||||||
|
if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") {
|
||||||
|
return strings.TrimSpace(modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexPlannerStatusRuntimeInfo(sessionKey string) (*commands.CodexPlannerStatusInfo, bool) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
sessionRec, ok := al.codexStore.Active(sessionKey)
|
||||||
|
if !ok || sessionRec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
runtime, _ := al.codexStore.SessionRuntime(sessionKey)
|
||||||
|
phase := "planning"
|
||||||
|
switch {
|
||||||
|
case runtime.DeployConfirmPending:
|
||||||
|
phase = "awaiting deploy approval"
|
||||||
|
case runtime.ApprovalPending:
|
||||||
|
phase = "awaiting approval"
|
||||||
|
}
|
||||||
|
if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(active.Status)) {
|
||||||
|
case codexRunStatusQueued:
|
||||||
|
phase = "queued"
|
||||||
|
case codexRunStatusRunning:
|
||||||
|
phase = "executing"
|
||||||
|
default:
|
||||||
|
phase = strings.TrimSpace(active.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info := &commands.CodexPlannerStatusInfo{
|
||||||
|
Phase: phase,
|
||||||
|
Model: strings.TrimSpace(runtime.PlannerModel),
|
||||||
|
SessionID: sessionRec.ID,
|
||||||
|
RepoSlug: sessionRec.Slug,
|
||||||
|
RepoPath: sessionRec.RepoPath,
|
||||||
|
RepoURL: sessionRec.RepoURL,
|
||||||
|
ApprovalPending: runtime.ApprovalPending,
|
||||||
|
}
|
||||||
|
if info.Model == "" {
|
||||||
|
info.Model = strings.TrimSpace(al.findCodexPlannerModelName(al.GetConfig()))
|
||||||
|
}
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexRunStatusRuntimeInfo(sessionKey string) (*commands.CodexRunInfo, bool) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rec, ok := al.codexStore.ActiveRun(sessionKey); ok && rec != nil {
|
||||||
|
info := codexRunRecordToInfo(rec, true)
|
||||||
|
return &info, true
|
||||||
|
}
|
||||||
|
runs := al.codexStore.ListRuns(sessionKey)
|
||||||
|
if len(runs) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
info := codexRunRecordToInfo(&runs[0], false)
|
||||||
|
return &info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexRunListRuntimeInfo(sessionKey string) []commands.CodexRunInfo {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runs := al.codexStore.ListRuns(sessionKey)
|
||||||
|
out := make([]commands.CodexRunInfo, 0, len(runs))
|
||||||
|
activeID := ""
|
||||||
|
if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil {
|
||||||
|
activeID = active.ID
|
||||||
|
}
|
||||||
|
for i := range runs {
|
||||||
|
rec := runs[i]
|
||||||
|
out = append(out, codexRunRecordToInfo(&rec, rec.ID == activeID))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexRunRecordToInfo(rec *codexRunRecord, active bool) commands.CodexRunInfo {
|
||||||
|
if rec == nil {
|
||||||
|
return commands.CodexRunInfo{}
|
||||||
|
}
|
||||||
|
return commands.CodexRunInfo{
|
||||||
|
ID: rec.ID,
|
||||||
|
SessionID: rec.SessionID,
|
||||||
|
RepoSlug: rec.RepoSlug,
|
||||||
|
RepoPath: rec.RepoPath,
|
||||||
|
RepoURL: rec.RepoURL,
|
||||||
|
Branch: rec.BranchName,
|
||||||
|
Worktree: rec.WorktreePath,
|
||||||
|
Model: rec.ExecutorModel,
|
||||||
|
Status: rec.Status,
|
||||||
|
PID: rec.PID,
|
||||||
|
ExitCode: rec.ExitCode,
|
||||||
|
Active: active,
|
||||||
|
StartedAt: rec.StartedAt,
|
||||||
|
UpdatedAt: rec.UpdatedAt,
|
||||||
|
FinishedAt: rec.FinishedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexRunTail(sessionKey, runID string, lines int) (string, error) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return "", fmt.Errorf("codex runs are not initialized")
|
||||||
|
}
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if runID == "" {
|
||||||
|
if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil {
|
||||||
|
runID = active.ID
|
||||||
|
} else {
|
||||||
|
runs := al.codexStore.ListRuns(sessionKey)
|
||||||
|
if len(runs) == 0 {
|
||||||
|
return "", fmt.Errorf("no codex run is active yet")
|
||||||
|
}
|
||||||
|
runID = runs[0].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, ok := al.codexStore.GetRun(runID)
|
||||||
|
if !ok || run == nil {
|
||||||
|
return "", fmt.Errorf("codex run %q not found", runID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(run.LogPath) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return tailFileLines(run.LogPath, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) startApprovedCodexRun(
|
||||||
|
_ context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts *processOptions,
|
||||||
|
userMessage string,
|
||||||
|
) (string, error) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return "", fmt.Errorf("codex runs are not initialized")
|
||||||
|
}
|
||||||
|
if agent == nil || opts == nil {
|
||||||
|
return "", fmt.Errorf("codex planner context is incomplete")
|
||||||
|
}
|
||||||
|
sessionRec, ok := al.codexStore.Active(opts.SessionKey)
|
||||||
|
if !ok || sessionRec == nil {
|
||||||
|
return "", fmt.Errorf("no active codex session in this chat")
|
||||||
|
}
|
||||||
|
runtime, _ := al.codexStore.SessionRuntime(opts.SessionKey)
|
||||||
|
if !runtime.ApprovalPending {
|
||||||
|
return "", fmt.Errorf("no codex plan is awaiting approval yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
plannerModel := strings.TrimSpace(runtime.PlannerModel)
|
||||||
|
if plannerModel == "" {
|
||||||
|
plannerModel = strings.TrimSpace(al.getSessionModelOverride(opts.SessionKey))
|
||||||
|
}
|
||||||
|
if plannerModel == "" {
|
||||||
|
plannerModel = strings.TrimSpace(al.findCodexPlannerModelName(al.GetConfig()))
|
||||||
|
}
|
||||||
|
|
||||||
|
executorModel := strings.TrimSpace(runtime.ExecutorModel)
|
||||||
|
if executorModel == "" {
|
||||||
|
executorModel = strings.TrimSpace(al.findCodexModelName(al.GetConfig()))
|
||||||
|
}
|
||||||
|
if executorModel == "" {
|
||||||
|
return "", fmt.Errorf("no codex-cli model configured in model_list")
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := al.codexStore.CreateRun(opts.SessionKey, codexRunCreateOptions{
|
||||||
|
PlannerModel: plannerModel,
|
||||||
|
ExecutorModel: executorModel,
|
||||||
|
Mode: "autonomous",
|
||||||
|
PlanID: runtime.PendingPlanID,
|
||||||
|
PlanHash: runtime.PendingPlanHash,
|
||||||
|
InitiatedBy: strings.TrimSpace(opts.SenderID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
worktree, err := al.codexStore.PrepareRunWorktree(run.ID)
|
||||||
|
if err != nil {
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := ""
|
||||||
|
if agent != nil {
|
||||||
|
workspace = agent.Workspace
|
||||||
|
}
|
||||||
|
logDir := filepath.Join(workspace, "logs", "codex")
|
||||||
|
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(logDir, run.ID+".log")
|
||||||
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
|
summary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
|
planText := latestAssistantMessage(history)
|
||||||
|
promptMessages := buildCodexExecutionPromptMessages(sessionRec, run, summary, history, planText, userMessage)
|
||||||
|
prompt := providers.BuildCodexCLIPrompt(promptMessages, nil)
|
||||||
|
|
||||||
|
cliModel := resolveCodexCLIModelArg(al.GetConfig(), executorModel)
|
||||||
|
args := providers.BuildCodexCLIArgs(cliModel, worktree)
|
||||||
|
cmd := exec.Command("codex", args...)
|
||||||
|
cmd.Stdin = strings.NewReader(prompt)
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = logFile.Close()
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", fmt.Errorf("failed to start codex run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := al.codexStore.MarkRunStarted(run.ID, cmd.Process.Pid, logPath); err != nil {
|
||||||
|
_ = killCodexProcess(cmd.Process.Pid)
|
||||||
|
_ = logFile.Close()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = al.codexStore.UpdateSessionRuntime(opts.SessionKey, func(runtime *codexSessionRuntimeState) {
|
||||||
|
runtime.WorkMode = "codex-plan"
|
||||||
|
runtime.ApprovalPending = false
|
||||||
|
runtime.PendingPlanID = ""
|
||||||
|
runtime.PendingPlanHash = ""
|
||||||
|
runtime.ActiveRunID = run.ID
|
||||||
|
runtime.LastRunID = run.ID
|
||||||
|
runtime.PlannerModel = plannerModel
|
||||||
|
runtime.ExecutorModel = executorModel
|
||||||
|
})
|
||||||
|
|
||||||
|
go al.waitForCodexRun(cmd, logFile, run.ID, opts.Channel, opts.ChatID)
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf("Codex run started: %s", run.ID),
|
||||||
|
fmt.Sprintf("Repo: %s", sessionRec.Slug),
|
||||||
|
fmt.Sprintf("Planner: %s", plannerModel),
|
||||||
|
fmt.Sprintf("Executor: %s", executorModel),
|
||||||
|
fmt.Sprintf("Worktree: %s", worktree),
|
||||||
|
"Use /codex status or /codex tail to inspect progress.",
|
||||||
|
}, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) waitForCodexRun(cmd *exec.Cmd, logFile *os.File, runID, channel, chatID string) {
|
||||||
|
err := cmd.Wait()
|
||||||
|
_ = logFile.Close()
|
||||||
|
|
||||||
|
run, ok := al.codexStore.GetRun(runID)
|
||||||
|
if !ok || run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := 0
|
||||||
|
status := codexRunStatusSucceeded
|
||||||
|
if err != nil {
|
||||||
|
status = codexRunStatusFailed
|
||||||
|
exitCode = -1
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status == codexRunStatusSucceeded {
|
||||||
|
_ = al.codexStore.MarkRunFinished(runID, codexRunStatusSucceeded, exitCode, "")
|
||||||
|
} else {
|
||||||
|
_ = al.codexStore.MarkRunFailed(runID, exitCode, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if finished, ok := al.codexStore.GetRun(runID); ok && finished != nil {
|
||||||
|
if status == codexRunStatusSucceeded && isPicoClawRun(finished) {
|
||||||
|
_ = al.codexStore.UpdateSessionRuntime(finished.ScopeKey, func(runtime *codexSessionRuntimeState) {
|
||||||
|
runtime.DeployConfirmPending = true
|
||||||
|
runtime.LastRunID = finished.ID
|
||||||
|
runtime.ActiveRunID = ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
al.sendCodexRunNotification(channel, chatID, finished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) startApprovedPicoClawDeploy(scopeKey, channel, chatID string) (string, error) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return "", fmt.Errorf("codex runs are not initialized")
|
||||||
|
}
|
||||||
|
sessionRec, ok := al.codexStore.Active(scopeKey)
|
||||||
|
if !ok || sessionRec == nil {
|
||||||
|
return "", fmt.Errorf("no active codex session in this chat")
|
||||||
|
}
|
||||||
|
runtime, ok := al.codexStore.SessionRuntime(scopeKey)
|
||||||
|
if !ok || !runtime.DeployConfirmPending || strings.TrimSpace(runtime.LastRunID) == "" {
|
||||||
|
return "", fmt.Errorf("no PicoClaw deploy is awaiting confirmation")
|
||||||
|
}
|
||||||
|
baseRun, ok := al.codexStore.GetRun(runtime.LastRunID)
|
||||||
|
if !ok || baseRun == nil {
|
||||||
|
return "", fmt.Errorf("the last approved PicoClaw run could not be found")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(baseRun.WorktreePath) == "" {
|
||||||
|
return "", fmt.Errorf("the approved PicoClaw run does not have a worktree to deploy")
|
||||||
|
}
|
||||||
|
|
||||||
|
plannerModel := strings.TrimSpace(runtime.PlannerModel)
|
||||||
|
executorModel := "deploy-script"
|
||||||
|
run, err := al.codexStore.CreateRun(scopeKey, codexRunCreateOptions{
|
||||||
|
PlannerModel: plannerModel,
|
||||||
|
ExecutorModel: executorModel,
|
||||||
|
Mode: "deploy",
|
||||||
|
InitiatedBy: "deploy-approval",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := filepath.Dir(filepath.Dir(al.codexStore.stateFile))
|
||||||
|
logDir := filepath.Join(workspace, "logs", "codex")
|
||||||
|
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(logDir, run.ID+".log")
|
||||||
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(workspace, "scripts", "picoclaw_deploy_local.sh")
|
||||||
|
cmd := exec.Command("bash", scriptPath)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"SRC_DIR="+baseRun.WorktreePath,
|
||||||
|
)
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = logFile.Close()
|
||||||
|
_ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error())
|
||||||
|
return "", fmt.Errorf("failed to start deploy run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := al.codexStore.MarkRunStarted(run.ID, cmd.Process.Pid, logPath); err != nil {
|
||||||
|
_ = killCodexProcess(cmd.Process.Pid)
|
||||||
|
_ = logFile.Close()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = al.codexStore.UpdateSessionRuntime(scopeKey, func(runtime *codexSessionRuntimeState) {
|
||||||
|
runtime.DeployConfirmPending = false
|
||||||
|
runtime.WorkMode = "codex-plan"
|
||||||
|
runtime.ActiveRunID = run.ID
|
||||||
|
runtime.LastRunID = run.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
go al.waitForCodexRun(cmd, logFile, run.ID, channel, chatID)
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf("PicoClaw deploy started: %s", run.ID),
|
||||||
|
fmt.Sprintf("Source worktree: %s", baseRun.WorktreePath),
|
||||||
|
"Use /codex status or /codex tail to inspect progress.",
|
||||||
|
}, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) stopActiveCodexRun(scopeKey string) error {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return fmt.Errorf("codex runs are not initialized")
|
||||||
|
}
|
||||||
|
run, ok := al.codexStore.ActiveRun(scopeKey)
|
||||||
|
if !ok || run == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if run.PID <= 0 {
|
||||||
|
return al.codexStore.MarkRunStopped(run.ID, "run stopped without a live pid")
|
||||||
|
}
|
||||||
|
if err := killCodexProcess(run.PID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return al.codexStore.MarkRunStopped(run.ID, "run stopped by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestAssistantMessage(history []providers.Message) string {
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if history[i].Role == "assistant" && strings.TrimSpace(history[i].Content) != "" {
|
||||||
|
return strings.TrimSpace(history[i].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCodexExecutionPromptMessages(
|
||||||
|
sessionRec *codexSessionRecord,
|
||||||
|
run *codexRunRecord,
|
||||||
|
summary string,
|
||||||
|
history []providers.Message,
|
||||||
|
planText string,
|
||||||
|
userMessage string,
|
||||||
|
) []providers.Message {
|
||||||
|
systemLines := []string{
|
||||||
|
"You are the Codex executor for a managed Telegram /codex session.",
|
||||||
|
"Execute the approved plan inside the active repo worktree.",
|
||||||
|
"Make concrete progress autonomously and validate changes with focused commands where practical.",
|
||||||
|
"Do not deploy, restart services, or mutate system-wide state in this phase.",
|
||||||
|
}
|
||||||
|
if sessionRec != nil {
|
||||||
|
systemLines = append(systemLines,
|
||||||
|
"Repo slug: "+strings.TrimSpace(sessionRec.Slug),
|
||||||
|
"Canonical repo path: "+strings.TrimSpace(sessionRec.RepoPath),
|
||||||
|
)
|
||||||
|
if strings.TrimSpace(sessionRec.RepoURL) != "" {
|
||||||
|
systemLines = append(systemLines, "Repo remote: "+strings.TrimSpace(sessionRec.RepoURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if run != nil && strings.TrimSpace(run.WorktreePath) != "" {
|
||||||
|
systemLines = append(systemLines, "Execution worktree: "+strings.TrimSpace(run.WorktreePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := []providers.Message{{Role: "system", Content: strings.Join(systemLines, "\n")}}
|
||||||
|
if strings.TrimSpace(summary) != "" {
|
||||||
|
msgs = append(msgs, providers.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: "Conversation summary:\n" + strings.TrimSpace(summary),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(history) > codexPromptHistoryLimit {
|
||||||
|
history = history[len(history)-codexPromptHistoryLimit:]
|
||||||
|
}
|
||||||
|
for _, msg := range history {
|
||||||
|
if strings.TrimSpace(msg.Content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, providers.Message{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalText := "The user explicitly approved the latest plan. Execute it now in the active repo worktree."
|
||||||
|
if strings.TrimSpace(planText) != "" {
|
||||||
|
approvalText += "\nApproved plan:\n" + strings.TrimSpace(planText)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(userMessage) != "" {
|
||||||
|
approvalText += "\nApproval message: " + strings.TrimSpace(userMessage)
|
||||||
|
}
|
||||||
|
msgs = append(msgs, providers.Message{Role: "user", Content: approvalText})
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) sendCodexRunNotification(channel, chatID string, run *codexRunRecord) {
|
||||||
|
if al == nil || al.bus == nil || strings.TrimSpace(channel) == "" || strings.TrimSpace(chatID) == "" || run == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("Codex run %s finished with status: %s.", run.ID, run.Status)
|
||||||
|
if strings.TrimSpace(run.LogPath) != "" {
|
||||||
|
if summary, err := tailFileLines(run.LogPath, 20); err == nil && strings.TrimSpace(summary) != "" {
|
||||||
|
if parsed, err := providers.ParseCodexCLIJSONLEvents(summary); err == nil && parsed != nil && strings.TrimSpace(parsed.Content) != "" {
|
||||||
|
text += "\n" + strings.TrimSpace(parsed.Content)
|
||||||
|
} else {
|
||||||
|
text += "\nLog tail:\n" + strings.TrimSpace(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isPicoClawRun(run) && run.Status == codexRunStatusSucceeded && strings.TrimSpace(run.Mode) != "deploy" {
|
||||||
|
text += "\nReply `deploy` to apply and restart PicoClaw from this worktree."
|
||||||
|
}
|
||||||
|
|
||||||
|
pubCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPicoClawRun(run *codexRunRecord) bool {
|
||||||
|
if run == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
all := strings.ToLower(strings.Join([]string{run.RepoSlug, run.RepoPath, run.RepoURL}, " "))
|
||||||
|
return strings.Contains(all, "picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
func killCodexProcess(pid int) error {
|
||||||
|
if pid <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if err := syscall.Kill(pid, 0); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexPlanIdentity(planText string) (string, string) {
|
||||||
|
planText = strings.TrimSpace(planText)
|
||||||
|
if planText == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(planText))
|
||||||
|
return "plan-" + newCodexRunID(), hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func tailFileLines(path string, lines int) (string, error) {
|
||||||
|
if lines <= 0 {
|
||||||
|
lines = 120
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||||
|
parts := strings.Split(text, "\n")
|
||||||
|
if len(parts) > 0 && parts[len(parts)-1] == "" {
|
||||||
|
parts = parts[:len(parts)-1]
|
||||||
|
}
|
||||||
|
if len(parts) > lines {
|
||||||
|
parts = parts[len(parts)-lines:]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n"), nil
|
||||||
|
}
|
||||||
932
pkg/agent/codex_runs.go
Normal file
932
pkg/agent/codex_runs.go
Normal file
|
|
@ -0,0 +1,932 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
codexRunStatusQueued = "queued"
|
||||||
|
codexRunStatusRunning = "running"
|
||||||
|
codexRunStatusSucceeded = "succeeded"
|
||||||
|
codexRunStatusFailed = "failed"
|
||||||
|
codexRunStatusStopped = "stopped"
|
||||||
|
codexRunStatusUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
type codexSessionRuntimeState struct {
|
||||||
|
PlannerModel string `json:"planner_model,omitempty"`
|
||||||
|
ExecutorModel string `json:"executor_model,omitempty"`
|
||||||
|
WorkMode string `json:"work_mode,omitempty"`
|
||||||
|
ApprovalPending bool `json:"approval_pending,omitempty"`
|
||||||
|
DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"`
|
||||||
|
PendingPlanID string `json:"pending_plan_id,omitempty"`
|
||||||
|
PendingPlanHash string `json:"pending_plan_hash,omitempty"`
|
||||||
|
ActiveRunID string `json:"active_run_id,omitempty"`
|
||||||
|
LastRunID string `json:"last_run_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexRunRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ScopeKey string `json:"scope_key,omitempty"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
RepoSlug string `json:"repo_slug"`
|
||||||
|
RepoPath string `json:"repo_path"`
|
||||||
|
RepoURL string `json:"repo_url,omitempty"`
|
||||||
|
WorktreePath string `json:"worktree_path"`
|
||||||
|
BranchName string `json:"branch_name"`
|
||||||
|
PlannerModel string `json:"planner_model,omitempty"`
|
||||||
|
ExecutorModel string `json:"executor_model,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PID int `json:"pid,omitempty"`
|
||||||
|
ExitCode int `json:"exit_code,omitempty"`
|
||||||
|
LogPath string `json:"log_path,omitempty"`
|
||||||
|
PlanID string `json:"plan_id,omitempty"`
|
||||||
|
PlanHash string `json:"plan_hash,omitempty"`
|
||||||
|
InitiatedBy string `json:"initiated_by,omitempty"`
|
||||||
|
DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StartedAt time.Time `json:"started_at,omitempty"`
|
||||||
|
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||||
|
LastHeartbeatAt time.Time `json:"last_heartbeat_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexRunSnapshot struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Runs map[string]*codexRunRecord `json:"runs"`
|
||||||
|
ActiveBySession map[string]string `json:"active_by_session"`
|
||||||
|
ActiveByRepo map[string]string `json:"active_by_repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexRunCreateOptions struct {
|
||||||
|
PlannerModel string
|
||||||
|
ExecutorModel string
|
||||||
|
Mode string
|
||||||
|
PlanID string
|
||||||
|
PlanHash string
|
||||||
|
InitiatedBy string
|
||||||
|
DeployConfirmPending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) SessionRuntime(scopeKey string) (codexSessionRuntimeState, bool) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil || scopeKey == "" {
|
||||||
|
return codexSessionRuntimeState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rec := s.sessionRecordByScopeLocked(scopeKey)
|
||||||
|
if rec == nil {
|
||||||
|
return codexSessionRuntimeState{}, false
|
||||||
|
}
|
||||||
|
return sessionRuntimeFromRecord(rec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) SetSessionRuntime(scopeKey string, runtime codexSessionRuntimeState) error {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil || scopeKey == "" {
|
||||||
|
return fmt.Errorf("codex sessions require a valid session scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.sessionRecordByScopeLocked(scopeKey)
|
||||||
|
if rec == nil {
|
||||||
|
return fmt.Errorf("codex session not found for scope %q", scopeKey)
|
||||||
|
}
|
||||||
|
applySessionRuntimeLocked(rec, runtime)
|
||||||
|
rec.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) UpdateSessionRuntime(scopeKey string, fn func(*codexSessionRuntimeState)) error {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil || scopeKey == "" {
|
||||||
|
return fmt.Errorf("codex sessions require a valid session scope")
|
||||||
|
}
|
||||||
|
if fn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.sessionRecordByScopeLocked(scopeKey)
|
||||||
|
if rec == nil {
|
||||||
|
return fmt.Errorf("codex session not found for scope %q", scopeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := sessionRuntimeFromRecord(rec)
|
||||||
|
fn(&runtime)
|
||||||
|
applySessionRuntimeLocked(rec, runtime)
|
||||||
|
rec.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) CreateRun(scopeKey string, opts codexRunCreateOptions) (*codexRunRecord, error) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil || scopeKey == "" {
|
||||||
|
return nil, fmt.Errorf("codex sessions require a valid session scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.normalizeSessionStateLocked()
|
||||||
|
s.normalizeRunStateLocked()
|
||||||
|
|
||||||
|
sessionRec := s.sessionRecordByScopeLocked(scopeKey)
|
||||||
|
if sessionRec == nil {
|
||||||
|
return nil, fmt.Errorf("codex session not found for scope %q", scopeKey)
|
||||||
|
}
|
||||||
|
if active := s.activeRunForSessionLocked(scopeKey); active != nil {
|
||||||
|
return nil, fmt.Errorf("codex session %q already has an active run (%s)", scopeKey, active.ID)
|
||||||
|
}
|
||||||
|
if active := s.activeRunForRepoLocked(sessionRec.RepoPath, sessionRec.Slug); active != nil {
|
||||||
|
return nil, fmt.Errorf("repo %q already has an active run (%s)", sessionRec.Slug, active.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
runID := newCodexRunID()
|
||||||
|
branchName := fmt.Sprintf("pc/%s/%s", sessionRec.ID, runID)
|
||||||
|
worktreePath, err := s.worktreePathForRun(sessionRec.Slug, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
rec := &codexRunRecord{
|
||||||
|
ID: runID,
|
||||||
|
ScopeKey: scopeKey,
|
||||||
|
SessionID: sessionRec.ID,
|
||||||
|
RepoSlug: sessionRec.Slug,
|
||||||
|
RepoPath: sessionRec.RepoPath,
|
||||||
|
RepoURL: sanitizeRepoRemote(sessionRec.RepoURL),
|
||||||
|
WorktreePath: worktreePath,
|
||||||
|
BranchName: branchName,
|
||||||
|
PlannerModel: strings.TrimSpace(opts.PlannerModel),
|
||||||
|
ExecutorModel: strings.TrimSpace(opts.ExecutorModel),
|
||||||
|
Mode: strings.TrimSpace(opts.Mode),
|
||||||
|
Status: codexRunStatusQueued,
|
||||||
|
PlanID: strings.TrimSpace(opts.PlanID),
|
||||||
|
PlanHash: strings.TrimSpace(opts.PlanHash),
|
||||||
|
InitiatedBy: strings.TrimSpace(opts.InitiatedBy),
|
||||||
|
DeployConfirmPending: opts.DeployConfirmPending,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if rec.Mode == "" {
|
||||||
|
rec.Mode = "autonomous"
|
||||||
|
}
|
||||||
|
if rec.ExecutorModel == "" {
|
||||||
|
rec.ExecutorModel = rec.PlannerModel
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.runs.Runs == nil {
|
||||||
|
s.runs.Runs = make(map[string]*codexRunRecord)
|
||||||
|
}
|
||||||
|
if s.runs.ActiveBySession == nil {
|
||||||
|
s.runs.ActiveBySession = make(map[string]string)
|
||||||
|
}
|
||||||
|
if s.runs.ActiveByRepo == nil {
|
||||||
|
s.runs.ActiveByRepo = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.runs.Runs[rec.ID] = rec
|
||||||
|
s.runs.ActiveBySession[scopeKey] = rec.ID
|
||||||
|
s.runs.ActiveByRepo[sessionRec.RepoPath] = rec.ID
|
||||||
|
sessionRec.ActiveRunID = rec.ID
|
||||||
|
sessionRec.PlannerModel = rec.PlannerModel
|
||||||
|
sessionRec.ExecutorModel = rec.ExecutorModel
|
||||||
|
sessionRec.WorkMode = "codex-plan"
|
||||||
|
sessionRec.ApprovalPending = false
|
||||||
|
sessionRec.DeployConfirmPending = opts.DeployConfirmPending
|
||||||
|
sessionRec.PendingPlanID = rec.PlanID
|
||||||
|
sessionRec.PendingPlanHash = rec.PlanHash
|
||||||
|
sessionRec.LastRunID = rec.ID
|
||||||
|
sessionRec.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.saveRunsLocked(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneCodexRunRecord(rec), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) GetRun(runID string) (*codexRunRecord, bool) {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if s == nil || runID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cloneCodexRunRecord(rec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) ListRuns(scopeKey string) []codexRunRecord {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]codexRunRecord, 0, len(s.runs.Runs))
|
||||||
|
for _, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scopeKey != "" && rec.ScopeKey != scopeKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, *rec)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].UpdatedAt.Equal(result[j].UpdatedAt) {
|
||||||
|
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) ActiveRun(scopeKey string) (*codexRunRecord, bool) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if s == nil || scopeKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rec := s.activeRunForSessionLocked(scopeKey)
|
||||||
|
if rec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) ActiveRunForRepo(repoPath, repoSlug string) (*codexRunRecord, bool) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rec := s.activeRunForRepoLocked(repoPath, repoSlug)
|
||||||
|
if rec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) MarkRunStarted(runID string, pid int, logPath string) error {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if s == nil || runID == "" {
|
||||||
|
return fmt.Errorf("run id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil {
|
||||||
|
return fmt.Errorf("run %q not found", runID)
|
||||||
|
}
|
||||||
|
rec.PID = pid
|
||||||
|
rec.LogPath = strings.TrimSpace(logPath)
|
||||||
|
if rec.Status == "" || rec.Status == codexRunStatusQueued || rec.Status == codexRunStatusUnknown {
|
||||||
|
rec.Status = codexRunStatusRunning
|
||||||
|
}
|
||||||
|
if rec.StartedAt.IsZero() {
|
||||||
|
rec.StartedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
rec.LastHeartbeatAt = time.Now().UTC()
|
||||||
|
rec.UpdatedAt = rec.LastHeartbeatAt
|
||||||
|
|
||||||
|
sessionRec := s.sessionRecordByIDLocked(rec.SessionID)
|
||||||
|
if sessionRec != nil {
|
||||||
|
sessionRec.ActiveRunID = rec.ID
|
||||||
|
sessionRec.UpdatedAt = rec.UpdatedAt
|
||||||
|
}
|
||||||
|
s.runs.ActiveBySession[rec.ScopeKey] = rec.ID
|
||||||
|
s.runs.ActiveByRepo[rec.RepoPath] = rec.ID
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.saveRunsLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) MarkRunHeartbeat(runID string) error {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if s == nil || runID == "" {
|
||||||
|
return fmt.Errorf("run id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil {
|
||||||
|
return fmt.Errorf("run %q not found", runID)
|
||||||
|
}
|
||||||
|
rec.LastHeartbeatAt = time.Now().UTC()
|
||||||
|
rec.UpdatedAt = rec.LastHeartbeatAt
|
||||||
|
return s.saveRunsLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) MarkRunFinished(runID, status string, exitCode int, errMsg string) error {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if s == nil || runID == "" {
|
||||||
|
return fmt.Errorf("run id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil {
|
||||||
|
return fmt.Errorf("run %q not found", runID)
|
||||||
|
}
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status == "" {
|
||||||
|
status = codexRunStatusSucceeded
|
||||||
|
}
|
||||||
|
rec.Status = status
|
||||||
|
rec.ExitCode = exitCode
|
||||||
|
rec.Error = strings.TrimSpace(errMsg)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
rec.FinishedAt = now
|
||||||
|
rec.UpdatedAt = now
|
||||||
|
|
||||||
|
if activeID := s.runs.ActiveBySession[rec.ScopeKey]; activeID == rec.ID {
|
||||||
|
delete(s.runs.ActiveBySession, rec.ScopeKey)
|
||||||
|
}
|
||||||
|
if activeID := s.runs.ActiveByRepo[rec.RepoPath]; activeID == rec.ID {
|
||||||
|
delete(s.runs.ActiveByRepo, rec.RepoPath)
|
||||||
|
}
|
||||||
|
if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil {
|
||||||
|
sessionRec.LastRunID = rec.ID
|
||||||
|
if strings.TrimSpace(sessionRec.ActiveRunID) == rec.ID {
|
||||||
|
sessionRec.ActiveRunID = ""
|
||||||
|
}
|
||||||
|
sessionRec.DeployConfirmPending = false
|
||||||
|
sessionRec.UpdatedAt = now
|
||||||
|
}
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.saveRunsLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) MarkRunFailed(runID string, exitCode int, errMsg string) error {
|
||||||
|
return s.MarkRunFinished(runID, codexRunStatusFailed, exitCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) MarkRunStopped(runID string, errMsg string) error {
|
||||||
|
return s.MarkRunFinished(runID, codexRunStatusStopped, -1, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) ReconcileRuns() ([]codexRunRecord, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, fmt.Errorf("codex sessions are not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
changed := make([]codexRunRecord, 0)
|
||||||
|
for _, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isTerminalRunStatus(rec.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.PID <= 0 {
|
||||||
|
if rec.Status != codexRunStatusUnknown {
|
||||||
|
rec.Status = codexRunStatusUnknown
|
||||||
|
rec.ExitCode = -1
|
||||||
|
rec.FinishedAt = time.Now().UTC()
|
||||||
|
rec.UpdatedAt = rec.FinishedAt
|
||||||
|
changed = append(changed, *rec)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !processLooksAlive(rec.PID) {
|
||||||
|
outcome, ok := detectCodexRunOutcomeFromLog(rec.LogPath)
|
||||||
|
if ok {
|
||||||
|
rec.Status = outcome.Status
|
||||||
|
rec.ExitCode = outcome.ExitCode
|
||||||
|
rec.Error = outcome.Error
|
||||||
|
} else {
|
||||||
|
rec.Status = codexRunStatusUnknown
|
||||||
|
rec.ExitCode = -1
|
||||||
|
rec.Error = "process not found during reconciliation"
|
||||||
|
}
|
||||||
|
rec.FinishedAt = time.Now().UTC()
|
||||||
|
rec.UpdatedAt = rec.FinishedAt
|
||||||
|
if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil && strings.TrimSpace(sessionRec.ActiveRunID) == rec.ID {
|
||||||
|
sessionRec.ActiveRunID = ""
|
||||||
|
sessionRec.UpdatedAt = rec.UpdatedAt
|
||||||
|
}
|
||||||
|
delete(s.runs.ActiveBySession, rec.ScopeKey)
|
||||||
|
delete(s.runs.ActiveByRepo, rec.RepoPath)
|
||||||
|
changed = append(changed, *rec)
|
||||||
|
} else {
|
||||||
|
rec.LastHeartbeatAt = time.Now().UTC()
|
||||||
|
rec.UpdatedAt = rec.LastHeartbeatAt
|
||||||
|
s.runs.ActiveBySession[rec.ScopeKey] = rec.ID
|
||||||
|
s.runs.ActiveByRepo[rec.RepoPath] = rec.ID
|
||||||
|
if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil {
|
||||||
|
sessionRec.ActiveRunID = rec.ID
|
||||||
|
sessionRec.UpdatedAt = rec.UpdatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.normalizeRunStateLocked()
|
||||||
|
if len(changed) > 0 {
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
if err := s.saveRunsLocked(); err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexRunLogEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
ExitCode *int `json:"exit_code,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexRunLogOutcome struct {
|
||||||
|
Status string
|
||||||
|
ExitCode int
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCodexRunOutcomeFromLog(logPath string) (codexRunLogOutcome, bool) {
|
||||||
|
logPath = strings.TrimSpace(logPath)
|
||||||
|
if logPath == "" {
|
||||||
|
return codexRunLogOutcome{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(logPath)
|
||||||
|
if err != nil {
|
||||||
|
return codexRunLogOutcome{}, false
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
completed bool
|
||||||
|
failed bool
|
||||||
|
exitCode = -1
|
||||||
|
lastError string
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || !strings.Contains(line, `"type"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var evt codexRunLogEvent
|
||||||
|
if err := json.Unmarshal([]byte(line), &evt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch evt.Type {
|
||||||
|
case "turn.completed":
|
||||||
|
completed = true
|
||||||
|
if evt.ExitCode != nil {
|
||||||
|
exitCode = *evt.ExitCode
|
||||||
|
} else if exitCode < 0 {
|
||||||
|
exitCode = 0
|
||||||
|
}
|
||||||
|
case "turn.failed":
|
||||||
|
failed = true
|
||||||
|
if evt.ExitCode != nil {
|
||||||
|
exitCode = *evt.ExitCode
|
||||||
|
}
|
||||||
|
if evt.Error != nil && strings.TrimSpace(evt.Error.Message) != "" {
|
||||||
|
lastError = strings.TrimSpace(evt.Error.Message)
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
if strings.TrimSpace(evt.Message) != "" {
|
||||||
|
lastError = strings.TrimSpace(evt.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return codexRunLogOutcome{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
if exitCode < 0 {
|
||||||
|
exitCode = 0
|
||||||
|
}
|
||||||
|
return codexRunLogOutcome{
|
||||||
|
Status: codexRunStatusSucceeded,
|
||||||
|
ExitCode: exitCode,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
if failed || lastError != "" {
|
||||||
|
return codexRunLogOutcome{
|
||||||
|
Status: codexRunStatusFailed,
|
||||||
|
ExitCode: exitCode,
|
||||||
|
Error: lastError,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return codexRunLogOutcome{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) PrepareRunWorktree(runID string) (string, error) {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if s == nil || runID == "" {
|
||||||
|
return "", fmt.Errorf("run id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil {
|
||||||
|
return "", fmt.Errorf("run %q not found", runID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rec.RepoPath) == "" {
|
||||||
|
return "", fmt.Errorf("run %q has no repo path", runID)
|
||||||
|
}
|
||||||
|
if !isGitRepo(rec.RepoPath) {
|
||||||
|
return "", fmt.Errorf("repo path %q is not a git repository", rec.RepoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(rec.WorktreePath) == "" {
|
||||||
|
worktreePath, err := s.worktreePathForRun(rec.RepoSlug, rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rec.WorktreePath = worktreePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(rec.WorktreePath), 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(rec.WorktreePath); err == nil {
|
||||||
|
if isGitRepo(rec.WorktreePath) {
|
||||||
|
return rec.WorktreePath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("worktree path %q already exists and is not a git repository", rec.WorktreePath)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := strings.TrimSpace(rec.BranchName)
|
||||||
|
if branch == "" {
|
||||||
|
branch = fmt.Sprintf("pc/%s/%s", rec.SessionID, rec.ID)
|
||||||
|
rec.BranchName = branch
|
||||||
|
}
|
||||||
|
|
||||||
|
baseRef := "HEAD"
|
||||||
|
if out, err := exec.Command("git", "-C", rec.RepoPath, "branch", "--show-current").Output(); err == nil {
|
||||||
|
if current := strings.TrimSpace(string(out)); current != "" {
|
||||||
|
baseRef = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
args := []string{"-C", rec.RepoPath, "worktree", "add"}
|
||||||
|
if !branchExists(rec.RepoPath, branch) {
|
||||||
|
args = append(args, "-b", branch, rec.WorktreePath, baseRef)
|
||||||
|
} else {
|
||||||
|
args = append(args, rec.WorktreePath, branch)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("git worktree add failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveRunsLocked(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return rec.WorktreePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) worktreePathForRun(repoSlug, runID string) (string, error) {
|
||||||
|
repoSlug = strings.TrimSpace(repoSlug)
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
if repoSlug == "" || runID == "" {
|
||||||
|
return "", fmt.Errorf("repo slug and run id are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := filepath.Clean(filepath.Join(s.worktreesRoot, repoSlug, runID))
|
||||||
|
rel, err := filepath.Rel(s.worktreesRoot, candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||||
|
return "", fmt.Errorf("invalid worktree path for repo %q", repoSlug)
|
||||||
|
}
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) sessionRecordByScopeLocked(scopeKey string) *codexSessionRecord {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(s.state.Bindings[scopeKey])
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.state.Sessions[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) sessionRecordByIDLocked(sessionID string) *codexSessionRecord {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.state.Sessions[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) activeRunForSessionLocked(scopeKey string) *codexRunRecord {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if runID := strings.TrimSpace(s.runs.ActiveBySession[scopeKey]); runID != "" {
|
||||||
|
if rec := s.runs.Runs[runID]; rec != nil {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.ScopeKey == scopeKey && isRunActiveStatus(rec.Status) {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) activeRunForRepoLocked(repoPath, repoSlug string) *codexRunRecord {
|
||||||
|
repoPath = strings.TrimSpace(repoPath)
|
||||||
|
repoSlug = strings.TrimSpace(repoSlug)
|
||||||
|
if repoPath == "" && repoSlug == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if runID := strings.TrimSpace(s.runs.ActiveByRepo[repoPath]); runID != "" {
|
||||||
|
if rec := s.runs.Runs[runID]; rec != nil {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isRunActiveStatus(rec.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if repoPath != "" && rec.RepoPath == repoPath {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
if repoSlug != "" && rec.RepoSlug == repoSlug {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) normalizeRunStateLocked() {
|
||||||
|
if s.runs.Runs == nil {
|
||||||
|
s.runs.Runs = make(map[string]*codexRunRecord)
|
||||||
|
}
|
||||||
|
if s.runs.ActiveBySession == nil {
|
||||||
|
s.runs.ActiveBySession = make(map[string]string)
|
||||||
|
}
|
||||||
|
if s.runs.ActiveByRepo == nil {
|
||||||
|
s.runs.ActiveByRepo = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
delete(s.runs.Runs, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec.RepoURL = sanitizeRepoRemote(rec.RepoURL)
|
||||||
|
if strings.TrimSpace(rec.BranchName) == "" && strings.TrimSpace(rec.SessionID) != "" && strings.TrimSpace(rec.ID) != "" {
|
||||||
|
rec.BranchName = fmt.Sprintf("pc/%s/%s", rec.SessionID, rec.ID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rec.WorktreePath) == "" && strings.TrimSpace(rec.RepoSlug) != "" && strings.TrimSpace(rec.ID) != "" {
|
||||||
|
if worktreePath, err := s.worktreePathForRun(rec.RepoSlug, rec.ID); err == nil {
|
||||||
|
rec.WorktreePath = worktreePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, runID := range s.runs.ActiveBySession {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil || !isRunActiveStatus(rec.Status) {
|
||||||
|
delete(s.runs.ActiveBySession, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.ScopeKey == "" {
|
||||||
|
rec.ScopeKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, runID := range s.runs.ActiveByRepo {
|
||||||
|
runID = strings.TrimSpace(runID)
|
||||||
|
rec := s.runs.Runs[runID]
|
||||||
|
if rec == nil || !isRunActiveStatus(rec.Status) {
|
||||||
|
delete(s.runs.ActiveByRepo, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key != rec.RepoPath && key != rec.RepoSlug {
|
||||||
|
delete(s.runs.ActiveByRepo, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range s.runs.Runs {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isRunActiveStatus(rec.Status) {
|
||||||
|
if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil && sessionRec.ActiveRunID == rec.ID {
|
||||||
|
sessionRec.ActiveRunID = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.ScopeKey != "" {
|
||||||
|
s.runs.ActiveBySession[rec.ScopeKey] = rec.ID
|
||||||
|
}
|
||||||
|
if rec.RepoPath != "" {
|
||||||
|
s.runs.ActiveByRepo[rec.RepoPath] = rec.ID
|
||||||
|
}
|
||||||
|
if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil {
|
||||||
|
sessionRec.ActiveRunID = rec.ID
|
||||||
|
if sessionRec.PlannerModel == "" {
|
||||||
|
sessionRec.PlannerModel = rec.PlannerModel
|
||||||
|
}
|
||||||
|
if sessionRec.ExecutorModel == "" {
|
||||||
|
sessionRec.ExecutorModel = rec.ExecutorModel
|
||||||
|
}
|
||||||
|
if sessionRec.WorkMode == "" {
|
||||||
|
sessionRec.WorkMode = "codex-plan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySessionRuntimeLocked(rec *codexSessionRecord, runtime codexSessionRuntimeState) {
|
||||||
|
if rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec.PlannerModel = strings.TrimSpace(runtime.PlannerModel)
|
||||||
|
rec.ExecutorModel = strings.TrimSpace(runtime.ExecutorModel)
|
||||||
|
rec.WorkMode = strings.TrimSpace(runtime.WorkMode)
|
||||||
|
rec.ApprovalPending = runtime.ApprovalPending
|
||||||
|
rec.DeployConfirmPending = runtime.DeployConfirmPending
|
||||||
|
rec.PendingPlanID = strings.TrimSpace(runtime.PendingPlanID)
|
||||||
|
rec.PendingPlanHash = strings.TrimSpace(runtime.PendingPlanHash)
|
||||||
|
rec.ActiveRunID = strings.TrimSpace(runtime.ActiveRunID)
|
||||||
|
rec.LastRunID = strings.TrimSpace(runtime.LastRunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionRuntimeFromRecord(rec *codexSessionRecord) codexSessionRuntimeState {
|
||||||
|
if rec == nil {
|
||||||
|
return codexSessionRuntimeState{}
|
||||||
|
}
|
||||||
|
return codexSessionRuntimeState{
|
||||||
|
PlannerModel: strings.TrimSpace(rec.PlannerModel),
|
||||||
|
ExecutorModel: strings.TrimSpace(rec.ExecutorModel),
|
||||||
|
WorkMode: strings.TrimSpace(rec.WorkMode),
|
||||||
|
ApprovalPending: rec.ApprovalPending,
|
||||||
|
DeployConfirmPending: rec.DeployConfirmPending,
|
||||||
|
PendingPlanID: strings.TrimSpace(rec.PendingPlanID),
|
||||||
|
PendingPlanHash: strings.TrimSpace(rec.PendingPlanHash),
|
||||||
|
ActiveRunID: strings.TrimSpace(rec.ActiveRunID),
|
||||||
|
LastRunID: strings.TrimSpace(rec.LastRunID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCodexRunRecord(rec *codexRunRecord) *codexRunRecord {
|
||||||
|
if rec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *rec
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunActiveStatus(status string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case codexRunStatusQueued, codexRunStatusRunning:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminalRunStatus(status string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case codexRunStatusSucceeded, codexRunStatusFailed, codexRunStatusStopped:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processLooksAlive(pid int) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchExists(repoPath, branch string) bool {
|
||||||
|
repoPath = strings.TrimSpace(repoPath)
|
||||||
|
branch = strings.TrimSpace(branch)
|
||||||
|
if repoPath == "" || branch == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", "--verify", "--quiet", branch)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCodexRunID() string {
|
||||||
|
return newCodexSessionID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRepoRemote(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "git@") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
parsed.User = nil
|
||||||
|
}
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
273
pkg/agent/codex_runs_test.go
Normal file
273
pkg/agent/codex_runs_test.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCodexSessionStore_RunLifecycleAndPersistence(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
store := newCodexSessionStore(workspace)
|
||||||
|
if store == nil {
|
||||||
|
t.Fatal("expected codex session store")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeKey := "agent:test:main"
|
||||||
|
slug := "picoclaw-demo"
|
||||||
|
repoPath, err := store.repoPathForSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repoPathForSlug() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := initGitRepo(t, repoPath); err != nil {
|
||||||
|
t.Fatalf("initGitRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.CreateOrActivate(scopeKey, slug, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() error = %v", err)
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
t.Fatal("CreateOrActivate() returned nil session")
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := codexSessionRuntimeState{
|
||||||
|
PlannerModel: "gpt-5.4-mini",
|
||||||
|
ExecutorModel: "codex-cli-local",
|
||||||
|
WorkMode: "codex-plan",
|
||||||
|
ApprovalPending: true,
|
||||||
|
DeployConfirmPending: true,
|
||||||
|
PendingPlanID: "plan-1",
|
||||||
|
PendingPlanHash: "hash-1",
|
||||||
|
}
|
||||||
|
if err := store.SetSessionRuntime(scopeKey, runtime); err != nil {
|
||||||
|
t.Fatalf("SetSessionRuntime() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotRuntime, ok := store.SessionRuntime(scopeKey)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SessionRuntime() not found")
|
||||||
|
}
|
||||||
|
if gotRuntime.PlannerModel != runtime.PlannerModel || gotRuntime.ExecutorModel != runtime.ExecutorModel {
|
||||||
|
t.Fatalf("runtime=%+v, want planner/executor models preserved", gotRuntime)
|
||||||
|
}
|
||||||
|
if !gotRuntime.ApprovalPending || !gotRuntime.DeployConfirmPending {
|
||||||
|
t.Fatalf("runtime=%+v, want approval/deploy pending", gotRuntime)
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := store.CreateRun(scopeKey, codexRunCreateOptions{
|
||||||
|
PlannerModel: runtime.PlannerModel,
|
||||||
|
ExecutorModel: runtime.ExecutorModel,
|
||||||
|
Mode: "autonomous",
|
||||||
|
PlanID: runtime.PendingPlanID,
|
||||||
|
PlanHash: runtime.PendingPlanHash,
|
||||||
|
InitiatedBy: "telegram:123",
|
||||||
|
DeployConfirmPending: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("CreateRun() returned nil run")
|
||||||
|
}
|
||||||
|
if !strings.Contains(run.WorktreePath, filepath.Join("worktrees", slug, run.ID)) {
|
||||||
|
t.Fatalf("worktree path=%q, want repo/run hierarchy", run.WorktreePath)
|
||||||
|
}
|
||||||
|
if run.BranchName != "pc/"+session.ID+"/"+run.ID {
|
||||||
|
t.Fatalf("branch=%q, want deterministic branch name", run.BranchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: runtime.PlannerModel}); err == nil {
|
||||||
|
t.Fatal("CreateRun() on active session should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeKey2 := "agent:test:other"
|
||||||
|
if _, err := store.CreateOrActivate(scopeKey2, slug, ""); err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() for second scope error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateRun(scopeKey2, codexRunCreateOptions{PlannerModel: runtime.PlannerModel}); err == nil {
|
||||||
|
t.Fatal("CreateRun() on same repo from another scope should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.PrepareRunWorktree(run.ID); err != nil {
|
||||||
|
t.Fatalf("PrepareRunWorktree() error = %v", err)
|
||||||
|
}
|
||||||
|
if stat, err := os.Stat(run.WorktreePath); err != nil || !stat.IsDir() {
|
||||||
|
t.Fatalf("worktree path missing after prepare: stat=%v err=%v", stat, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.MarkRunStarted(run.ID, 999999, filepath.Join(workspace, "run.log")); err != nil {
|
||||||
|
t.Fatalf("MarkRunStarted() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.MarkRunFinished(run.ID, codexRunStatusSucceeded, 0, ""); err != nil {
|
||||||
|
t.Fatalf("MarkRunFinished() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded := newCodexSessionStore(workspace)
|
||||||
|
if loaded == nil {
|
||||||
|
t.Fatal("expected reloaded store")
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedRun, ok := loaded.GetRun(run.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("reloaded run missing")
|
||||||
|
}
|
||||||
|
if loadedRun.Status != codexRunStatusSucceeded {
|
||||||
|
t.Fatalf("reloaded run status=%q, want %q", loadedRun.Status, codexRunStatusSucceeded)
|
||||||
|
}
|
||||||
|
loadedRuntime, ok := loaded.SessionRuntime(scopeKey)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("reloaded session runtime missing")
|
||||||
|
}
|
||||||
|
if loadedRuntime.ActiveRunID != "" {
|
||||||
|
t.Fatalf("reloaded active run id=%q, want cleared after finish", loadedRuntime.ActiveRunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexSessionStore_ReconcileRunsMarksDeadPidUnknown(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
store := newCodexSessionStore(workspace)
|
||||||
|
if store == nil {
|
||||||
|
t.Fatal("expected codex session store")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeKey := "agent:test:main"
|
||||||
|
slug := "picoclaw-demo"
|
||||||
|
repoPath, err := store.repoPathForSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repoPathForSlug() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := initGitRepo(t, repoPath); err != nil {
|
||||||
|
t.Fatalf("initGitRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateOrActivate(scopeKey, slug, ""); err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: "gpt-5.4-mini", Mode: "autonomous"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.MarkRunStarted(run.ID, 999999, ""); err != nil {
|
||||||
|
t.Fatalf("MarkRunStarted() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, err := store.ReconcileRuns()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReconcileRuns() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
t.Fatal("expected reconciliation to report a changed run")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok := store.GetRun(run.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("updated run missing")
|
||||||
|
}
|
||||||
|
if updated.Status != codexRunStatusUnknown {
|
||||||
|
t.Fatalf("updated status=%q, want %q", updated.Status, codexRunStatusUnknown)
|
||||||
|
}
|
||||||
|
if runtime, ok := store.SessionRuntime(scopeKey); !ok || runtime.ActiveRunID != "" {
|
||||||
|
t.Fatalf("session runtime after reconcile = %+v, want cleared active run", runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexSessionStore_ReconcileRunsMarksCompletedLogSucceeded(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
store := newCodexSessionStore(workspace)
|
||||||
|
if store == nil {
|
||||||
|
t.Fatal("expected codex session store")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeKey := "agent:test:main"
|
||||||
|
slug := "picoclaw-demo"
|
||||||
|
repoPath, err := store.repoPathForSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repoPathForSlug() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := initGitRepo(t, repoPath); err != nil {
|
||||||
|
t.Fatalf("initGitRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateOrActivate(scopeKey, slug, ""); err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: "gpt-5.4-mini", Mode: "autonomous"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRun() error = %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(workspace, "codex.log")
|
||||||
|
if err := os.WriteFile(logPath, []byte("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"done\"}}\n{\"type\":\"turn.completed\"}\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.MarkRunStarted(run.ID, 999999, logPath); err != nil {
|
||||||
|
t.Fatalf("MarkRunStarted() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, err := store.ReconcileRuns()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReconcileRuns() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
t.Fatal("expected reconciliation to report a changed run")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok := store.GetRun(run.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("updated run missing")
|
||||||
|
}
|
||||||
|
if updated.Status != codexRunStatusSucceeded {
|
||||||
|
t.Fatalf("updated status=%q, want %q", updated.Status, codexRunStatusSucceeded)
|
||||||
|
}
|
||||||
|
if updated.ExitCode != 0 {
|
||||||
|
t.Fatalf("updated exit code=%d, want 0", updated.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeRepoRemoteStripsCredentials(t *testing.T) {
|
||||||
|
got := sanitizeRepoRemote("https://user:token@example.com/org/repo.git")
|
||||||
|
if got != "https://example.com/org/repo.git" {
|
||||||
|
t.Fatalf("sanitizeRepoRemote() = %q, want %q", got, "https://example.com/org/repo.git")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initGitRepo(t *testing.T, dir string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runGit(dir, "init"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runGit(dir, "config", "user.email", "test@example.com"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runGit(dir, "config", "user.name", "Test User"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runGit(dir, "add", "README.md"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runGit(dir, "commit", "-m", "initial commit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(dir string, args ...string) error {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
747
pkg/agent/codex_sessions.go
Normal file
747
pkg/agent/codex_sessions.go
Normal file
|
|
@ -0,0 +1,747 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxCodexRepoDiscoveryResults = 30
|
||||||
|
|
||||||
|
type codexSessionRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
RepoPath string `json:"repo_path"`
|
||||||
|
RepoURL string `json:"repo_url,omitempty"`
|
||||||
|
PlannerModel string `json:"planner_model,omitempty"`
|
||||||
|
ExecutorModel string `json:"executor_model,omitempty"`
|
||||||
|
WorkMode string `json:"work_mode,omitempty"`
|
||||||
|
ApprovalPending bool `json:"approval_pending,omitempty"`
|
||||||
|
DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"`
|
||||||
|
PendingPlanID string `json:"pending_plan_id,omitempty"`
|
||||||
|
PendingPlanHash string `json:"pending_plan_hash,omitempty"`
|
||||||
|
ActiveRunID string `json:"active_run_id,omitempty"`
|
||||||
|
LastRunID string `json:"last_run_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexSessionSnapshot struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sessions map[string]*codexSessionRecord `json:"sessions"`
|
||||||
|
Bindings map[string]string `json:"bindings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codexSessionStore struct {
|
||||||
|
stateFile string
|
||||||
|
runsFile string
|
||||||
|
reposRoot string
|
||||||
|
worktreesRoot string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
state codexSessionSnapshot
|
||||||
|
runs codexRunSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCodexSessionStore(workspace string) *codexSessionStore {
|
||||||
|
workspace = strings.TrimSpace(workspace)
|
||||||
|
if workspace == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &codexSessionStore{
|
||||||
|
stateFile: filepath.Join(workspace, "state", "codex_sessions.json"),
|
||||||
|
runsFile: filepath.Join(workspace, "state", "codex_runs.json"),
|
||||||
|
reposRoot: filepath.Join(workspace, "repos"),
|
||||||
|
worktreesRoot: filepath.Join(workspace, "worktrees"),
|
||||||
|
state: codexSessionSnapshot{
|
||||||
|
Version: 1,
|
||||||
|
Sessions: make(map[string]*codexSessionRecord),
|
||||||
|
Bindings: make(map[string]string),
|
||||||
|
},
|
||||||
|
runs: codexRunSnapshot{
|
||||||
|
Version: 1,
|
||||||
|
Runs: make(map[string]*codexRunRecord),
|
||||||
|
ActiveBySession: make(map[string]string),
|
||||||
|
ActiveByRepo: make(map[string]string),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(store.stateFile), 0o700); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to create codex state directory", map[string]any{"error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(store.reposRoot, 0o700); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to create codex repos directory", map[string]any{"error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(store.worktreesRoot, 0o700); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to create codex worktrees directory", map[string]any{"error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := store.load(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to load codex session state", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := store.loadRuns(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to load codex run state", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) load() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.stateFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot codexSessionSnapshot
|
||||||
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if snapshot.Sessions == nil {
|
||||||
|
snapshot.Sessions = make(map[string]*codexSessionRecord)
|
||||||
|
}
|
||||||
|
if snapshot.Bindings == nil {
|
||||||
|
snapshot.Bindings = make(map[string]string)
|
||||||
|
}
|
||||||
|
if snapshot.Version == 0 {
|
||||||
|
snapshot.Version = 1
|
||||||
|
}
|
||||||
|
s.state = snapshot
|
||||||
|
s.normalizeSessionStateLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) loadRuns() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.runsFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
s.normalizeRunStateLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot codexRunSnapshot
|
||||||
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if snapshot.Runs == nil {
|
||||||
|
snapshot.Runs = make(map[string]*codexRunRecord)
|
||||||
|
}
|
||||||
|
if snapshot.ActiveBySession == nil {
|
||||||
|
snapshot.ActiveBySession = make(map[string]string)
|
||||||
|
}
|
||||||
|
if snapshot.ActiveByRepo == nil {
|
||||||
|
snapshot.ActiveByRepo = make(map[string]string)
|
||||||
|
}
|
||||||
|
if snapshot.Version == 0 {
|
||||||
|
snapshot.Version = 1
|
||||||
|
}
|
||||||
|
s.runs = snapshot
|
||||||
|
s.normalizeRunStateLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) saveLocked() error {
|
||||||
|
data, err := json.MarshalIndent(s.state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(s.stateFile, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) saveRunsLocked() error {
|
||||||
|
data, err := json.MarshalIndent(s.runs, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(s.runsFile, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) CreateOrActivate(scopeKey, slug, source string) (*codexSessionRecord, error) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil, fmt.Errorf("codex sessions require a valid session scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
slug, err := sanitizeCodexSlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoURL, err := normalizeRepoSource(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
rec := s.findBySlugLocked(slug)
|
||||||
|
if rec == nil {
|
||||||
|
repoPath, pathErr := s.repoPathForSlug(slug)
|
||||||
|
if pathErr != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, pathErr
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
rec = &codexSessionRecord{
|
||||||
|
ID: newCodexSessionID(),
|
||||||
|
Slug: slug,
|
||||||
|
RepoPath: repoPath,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
s.state.Sessions[rec.ID] = rec
|
||||||
|
} else if repoURL != "" && rec.RepoURL != "" && !strings.EqualFold(rec.RepoURL, repoURL) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("repo source does not match existing session remote")
|
||||||
|
}
|
||||||
|
repoPath := rec.RepoPath
|
||||||
|
recID := rec.ID
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := s.prepareRepo(repoPath, repoURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
current, ok := s.state.Sessions[recID]
|
||||||
|
if !ok || current == nil {
|
||||||
|
return nil, fmt.Errorf("codex session state changed while preparing repo")
|
||||||
|
}
|
||||||
|
if current.RepoURL == "" && repoURL != "" {
|
||||||
|
current.RepoURL = sanitizeRepoRemote(repoURL)
|
||||||
|
}
|
||||||
|
current.UpdatedAt = time.Now().UTC()
|
||||||
|
s.state.Bindings[scopeKey] = current.ID
|
||||||
|
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cloneCodexRecord(current), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) Attach(scopeKey, ref string) (*codexSessionRecord, error) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil, fmt.Errorf("codex sessions require a valid session scope")
|
||||||
|
}
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
if ref == "" {
|
||||||
|
return nil, fmt.Errorf("session id or repo slug is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
rec := s.state.Sessions[ref]
|
||||||
|
if rec == nil {
|
||||||
|
normalizedRef, err := sanitizeCodexSlug(ref)
|
||||||
|
if err == nil {
|
||||||
|
rec = s.findBySlugLocked(normalizedRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rec == nil {
|
||||||
|
return nil, fmt.Errorf("codex session %q not found", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.UpdatedAt = time.Now().UTC()
|
||||||
|
if rec.RepoURL != "" {
|
||||||
|
rec.RepoURL = sanitizeRepoRemote(rec.RepoURL)
|
||||||
|
}
|
||||||
|
s.state.Bindings[scopeKey] = rec.ID
|
||||||
|
if err := s.saveLocked(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cloneCodexRecord(rec), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) Active(scopeKey string) (*codexSessionRecord, bool) {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
id := strings.TrimSpace(s.state.Bindings[scopeKey])
|
||||||
|
if id == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rec := s.state.Sessions[id]
|
||||||
|
if rec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cloneCodexRecord(rec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) List(scopeKey string) []codexSessionRecord {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]codexSessionRecord, 0, len(s.state.Sessions))
|
||||||
|
for _, rec := range s.state.Sessions {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, *rec)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].UpdatedAt.Equal(result[j].UpdatedAt) {
|
||||||
|
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) Stop(scopeKey string) error {
|
||||||
|
scopeKey = strings.TrimSpace(scopeKey)
|
||||||
|
if scopeKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := s.state.Bindings[scopeKey]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(s.state.Bindings, scopeKey)
|
||||||
|
return s.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) findBySlugLocked(slug string) *codexSessionRecord {
|
||||||
|
for _, rec := range s.state.Sessions {
|
||||||
|
if rec != nil && rec.Slug == slug {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) repoPathForSlug(slug string) (string, error) {
|
||||||
|
candidate := filepath.Clean(filepath.Join(s.reposRoot, slug))
|
||||||
|
rel, err := filepath.Rel(s.reposRoot, candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||||
|
return "", fmt.Errorf("invalid repo path for slug %q", slug)
|
||||||
|
}
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) prepareRepo(repoPath, repoURL string) error {
|
||||||
|
repoPath = filepath.Clean(strings.TrimSpace(repoPath))
|
||||||
|
if repoPath == "" {
|
||||||
|
return fmt.Errorf("repo path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statErr := os.Stat(repoPath)
|
||||||
|
if statErr != nil && !os.IsNotExist(statErr) {
|
||||||
|
return statErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.IsNotExist(statErr) {
|
||||||
|
if repoURL == "" {
|
||||||
|
return os.MkdirAll(repoPath, 0o755)
|
||||||
|
}
|
||||||
|
return cloneRepo(repoURL, repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if repoURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
if err := os.Remove(repoPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cloneRepo(repoURL, repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isGitRepo(repoPath) {
|
||||||
|
return fmt.Errorf("repo path already exists and is not a git repository")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCodexSlug(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("repo slug is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range raw {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
case r == '-' || r == '_' || r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
case r == '/' || r == ' ' || r == ':' || r == '@':
|
||||||
|
if !lastDash {
|
||||||
|
b.WriteRune('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !lastDash {
|
||||||
|
b.WriteRune('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := strings.Trim(b.String(), "-._")
|
||||||
|
if slug == "" {
|
||||||
|
return "", fmt.Errorf("repo slug %q is invalid", raw)
|
||||||
|
}
|
||||||
|
if len(slug) > 80 {
|
||||||
|
slug = strings.Trim(slug[:80], "-._")
|
||||||
|
}
|
||||||
|
if slug == "" {
|
||||||
|
return "", fmt.Errorf("repo slug %q is invalid", raw)
|
||||||
|
}
|
||||||
|
return slug, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRepoSource(source string) (string, error) {
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if source == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "ssh://") || strings.HasPrefix(source, "git@") {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(source, "/") == 1 && !strings.ContainsAny(source, " \t:") {
|
||||||
|
return "https://github.com/" + source + ".git", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("repo source must be https://, ssh://, git@, or owner/repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCodexSessionID() string {
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("cx-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRepo(repoURL, repoPath string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", repoURL, repoPath)
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("git clone timed out")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("git clone failed (check repository access/PAT): %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGitRepo(path string) bool {
|
||||||
|
_, err := os.Stat(filepath.Join(path, ".git"))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCodexRecord(rec *codexSessionRecord) *codexSessionRecord {
|
||||||
|
if rec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *rec
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *codexSessionStore) normalizeSessionStateLocked() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.state.Sessions == nil {
|
||||||
|
s.state.Sessions = make(map[string]*codexSessionRecord)
|
||||||
|
}
|
||||||
|
if s.state.Bindings == nil {
|
||||||
|
s.state.Bindings = make(map[string]string)
|
||||||
|
}
|
||||||
|
for _, rec := range s.state.Sessions {
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec.RepoURL = sanitizeRepoRemote(rec.RepoURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexRecordToInfo(rec *codexSessionRecord, active bool) commands.CodexSessionInfo {
|
||||||
|
if rec == nil {
|
||||||
|
return commands.CodexSessionInfo{}
|
||||||
|
}
|
||||||
|
return commands.CodexSessionInfo{
|
||||||
|
ID: rec.ID,
|
||||||
|
Slug: rec.Slug,
|
||||||
|
RepoPath: rec.RepoPath,
|
||||||
|
RepoURL: rec.RepoURL,
|
||||||
|
Updated: rec.UpdatedAt,
|
||||||
|
Active: active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) findCodexModelName(cfg *config.Config) string {
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultName := strings.TrimSpace(cfg.Agents.Defaults.ModelName)
|
||||||
|
if defaultName != "" {
|
||||||
|
if mc, err := cfg.GetModelConfig(defaultName); err == nil && mc != nil {
|
||||||
|
proto, _ := providers.ExtractProtocol(mc.Model)
|
||||||
|
if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") {
|
||||||
|
return defaultName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mc := range cfg.ModelList {
|
||||||
|
if mc == nil || strings.TrimSpace(mc.ModelName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proto, _ := providers.ExtractProtocol(mc.Model)
|
||||||
|
if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") {
|
||||||
|
return strings.TrimSpace(mc.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexDelegateTargets(cfg *config.Config) []string {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(cfg.ModelList)+3)
|
||||||
|
targets := make([]string, 0, len(cfg.ModelList)+3)
|
||||||
|
add := func(raw string) {
|
||||||
|
name := strings.TrimSpace(raw)
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
targets = append(targets, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the active codex model prominent, then include the configured defaults.
|
||||||
|
add(al.findCodexModelName(cfg))
|
||||||
|
add(cfg.Agents.Defaults.ModelName)
|
||||||
|
if cfg.Agents.Defaults.Routing != nil {
|
||||||
|
for _, label := range routingTierPrimaryLabels(cfg.Agents.Defaults.Routing) {
|
||||||
|
add(label)
|
||||||
|
}
|
||||||
|
if len(cfg.Agents.Defaults.Routing.Tiers) == 0 {
|
||||||
|
add(cfg.Agents.Defaults.Routing.LightModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mc := range cfg.ModelList {
|
||||||
|
if mc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(mc.ModelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingTierPrimaryLabels(rc *config.RoutingConfig) []string {
|
||||||
|
if rc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(rc.Tiers))
|
||||||
|
out := make([]string, 0, len(rc.Tiers))
|
||||||
|
for _, tier := range rc.Tiers {
|
||||||
|
if tier.Model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(tier.Model.Primary)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexWorkspaceOverride(sessionKey string, modelCfg *config.ModelConfig) string {
|
||||||
|
if al == nil || al.codexStore == nil || modelCfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
proto, _ := providers.ExtractProtocol(modelCfg.Model)
|
||||||
|
if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
active, ok := al.codexStore.Active(sessionKey)
|
||||||
|
if !ok || active == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(active.RepoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexActiveRuntimeInfo(sessionKey string) (*commands.CodexSessionInfo, bool) {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, ok := al.codexStore.Active(sessionKey)
|
||||||
|
if !ok || rec == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
info := codexRecordToInfo(rec, true)
|
||||||
|
return &info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexListRuntimeInfo(sessionKey string) []commands.CodexSessionInfo {
|
||||||
|
if al == nil || al.codexStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
activeID := ""
|
||||||
|
if active, ok := al.codexStore.Active(sessionKey); ok && active != nil {
|
||||||
|
activeID = active.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
records := al.codexStore.List(sessionKey)
|
||||||
|
result := make([]commands.CodexSessionInfo, 0, len(records))
|
||||||
|
for i := range records {
|
||||||
|
rec := records[i]
|
||||||
|
active := activeID != "" && rec.ID == activeID
|
||||||
|
result = append(result, commands.CodexSessionInfo{
|
||||||
|
ID: rec.ID,
|
||||||
|
Slug: rec.Slug,
|
||||||
|
RepoPath: rec.RepoPath,
|
||||||
|
RepoURL: rec.RepoURL,
|
||||||
|
Updated: rec.UpdatedAt,
|
||||||
|
Active: active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) codexGitHubRepos(limit int) ([]string, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > maxCodexRepoDiscoveryResults {
|
||||||
|
limit = maxCodexRepoDiscoveryResults
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("gh"); err != nil {
|
||||||
|
return nil, fmt.Errorf("gh CLI not found on host; install GitHub CLI first")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
authCmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com")
|
||||||
|
authCmd.Env = os.Environ()
|
||||||
|
if err := authCmd.Run(); err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, fmt.Errorf("gh repo discovery timed out")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("gh CLI is not authenticated to github.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
repoCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"gh",
|
||||||
|
"repo",
|
||||||
|
"list",
|
||||||
|
"--limit",
|
||||||
|
fmt.Sprintf("%d", limit),
|
||||||
|
"--json",
|
||||||
|
"nameWithOwner",
|
||||||
|
"--jq",
|
||||||
|
".[].nameWithOwner",
|
||||||
|
)
|
||||||
|
repoCmd.Env = os.Environ()
|
||||||
|
output, err := repoCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, fmt.Errorf("gh repo discovery timed out")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("gh repo discovery failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
repos := make([]string, 0, len(lines))
|
||||||
|
seen := make(map[string]struct{}, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[line]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[line] = struct{}{}
|
||||||
|
repos = append(repos, line)
|
||||||
|
if len(repos) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
|
|
@ -42,17 +42,27 @@ type AgentInstance struct {
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Router is non-nil when model routing is configured and the light model
|
// Router is non-nil when model routing is configured and the light model
|
||||||
// was successfully resolved. It scores each incoming message and decides
|
// or tier chain was successfully resolved. It scores each incoming message
|
||||||
// whether to route to LightCandidates or stay with Candidates.
|
// and decides whether to route to a named tier or stay with Candidates.
|
||||||
Router *routing.Router
|
Router *routing.Router
|
||||||
|
// RouteTiers holds pre-resolved named routing tiers keyed by tier name.
|
||||||
|
RouteTiers map[string]*ResolvedRouteTier
|
||||||
// LightCandidates holds the resolved provider candidates for the light model.
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
// Legacy compatibility field for binary routing configs.
|
||||||
LightCandidates []providers.FallbackCandidate
|
LightCandidates []providers.FallbackCandidate
|
||||||
// LightProvider is the concrete provider instance for the configured light model.
|
// LightProvider is the concrete provider instance for the configured light model.
|
||||||
// It is only used when routing selects the light tier for a turn.
|
// Legacy compatibility field for binary routing configs.
|
||||||
LightProvider providers.LLMProvider
|
LightProvider providers.LLMProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolvedRouteTier struct {
|
||||||
|
Name string
|
||||||
|
Primary string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
Provider providers.LLMProvider
|
||||||
|
ThinkingLevel ThinkingLevel
|
||||||
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
func NewAgentInstance(
|
func NewAgentInstance(
|
||||||
agentCfg *config.AgentConfig,
|
agentCfg *config.AgentConfig,
|
||||||
|
|
@ -99,6 +109,30 @@ func NewAgentInstance(
|
||||||
toolsRegistry.Register(execTool)
|
toolsRegistry.Register(execTool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("git") {
|
||||||
|
allowGitPaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
gitTool, err := tools.NewGitTool(workspace, restrict, cfg.Tools.Git.TimeoutSeconds, allowGitPaths)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to initialize git tool; continuing without git",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
toolsRegistry.Register(gitTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("github") {
|
||||||
|
githubTool, err := tools.NewGitHubTool(
|
||||||
|
cfg.Tools.Github.Token.String(),
|
||||||
|
cfg.Tools.Github.BaseURL,
|
||||||
|
cfg.Tools.Github.Proxy,
|
||||||
|
cfg.Tools.Github.TimeoutSeconds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to initialize github tool; continuing without github",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
toolsRegistry.Register(githubTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("edit_file") {
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
|
@ -175,35 +209,29 @@ func NewAgentInstance(
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
||||||
|
|
||||||
// Model routing setup: pre-resolve light model candidates at creation time
|
// Model routing setup: pre-resolve routing tiers at creation time to avoid
|
||||||
// to avoid repeated model_list lookups on every incoming message.
|
// repeated model_list lookups on every incoming message.
|
||||||
var router *routing.Router
|
var router *routing.Router
|
||||||
|
var routeTiers map[string]*ResolvedRouteTier
|
||||||
var lightCandidates []providers.FallbackCandidate
|
var lightCandidates []providers.FallbackCandidate
|
||||||
var lightProvider providers.LLMProvider
|
var lightProvider providers.LLMProvider
|
||||||
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
if rc := defaults.Routing; rc != nil && rc.Enabled {
|
||||||
resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)
|
routeTiers = resolveRouteTiers(cfg, defaults, workspace, agentID, rc)
|
||||||
if len(resolved) > 0 {
|
if len(routeTiers) > 0 || rc.LightModel != "" {
|
||||||
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
|
router = routing.New(routing.RouterConfig{
|
||||||
if err != nil {
|
LightModel: rc.LightModel,
|
||||||
logger.WarnCF("agent", "Routing light model config invalid; routing disabled",
|
Threshold: rc.Threshold,
|
||||||
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
|
Tiers: rc.Tiers,
|
||||||
} else {
|
})
|
||||||
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
|
}
|
||||||
if err != nil {
|
if legacyLight := routeTiers["light"]; legacyLight != nil {
|
||||||
logger.WarnCF("agent", "Routing light model provider init failed; routing disabled",
|
lightCandidates = legacyLight.Candidates
|
||||||
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
|
lightProvider = legacyLight.Provider
|
||||||
} else {
|
} else if freeTier := strings.TrimSpace(defaults.Routing.FreeTier); freeTier != "" {
|
||||||
router = routing.New(routing.RouterConfig{
|
if resolved := routeTiers[freeTier]; resolved != nil {
|
||||||
LightModel: rc.LightModel,
|
lightCandidates = resolved.Candidates
|
||||||
Threshold: rc.Threshold,
|
lightProvider = resolved.Provider
|
||||||
})
|
|
||||||
lightCandidates = resolved
|
|
||||||
lightProvider = lp
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.WarnCF("agent", "Routing light model not found; routing disabled",
|
|
||||||
map[string]any{"light_model": rc.LightModel, "agent_id": agentID})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,11 +256,81 @@ func NewAgentInstance(
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
Router: router,
|
Router: router,
|
||||||
|
RouteTiers: routeTiers,
|
||||||
LightCandidates: lightCandidates,
|
LightCandidates: lightCandidates,
|
||||||
LightProvider: lightProvider,
|
LightProvider: lightProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveRouteTiers(
|
||||||
|
cfg *config.Config,
|
||||||
|
defaults *config.AgentDefaults,
|
||||||
|
workspace, agentID string,
|
||||||
|
rc *config.RoutingConfig,
|
||||||
|
) map[string]*ResolvedRouteTier {
|
||||||
|
if cfg == nil || defaults == nil || rc == nil || !rc.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]*ResolvedRouteTier{}
|
||||||
|
addTier := func(name, primary string, fallbacks []string) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
primary = strings.TrimSpace(primary)
|
||||||
|
if name == "" || primary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelCfg, err := resolvedModelConfig(cfg, primary, workspace)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Routing tier model config invalid; tier disabled",
|
||||||
|
map[string]any{"tier": name, "model": primary, "agent_id": agentID, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider, _, err := providers.CreateProviderFromConfig(modelCfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Routing tier provider init failed; tier disabled",
|
||||||
|
map[string]any{"tier": name, "model": primary, "agent_id": agentID, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvedFallbacks := fallbacks
|
||||||
|
if len(resolvedFallbacks) == 0 {
|
||||||
|
if mc := lookupModelConfigByRef(cfg, primary); mc != nil && len(mc.Fallbacks) > 0 {
|
||||||
|
resolvedFallbacks = mc.Fallbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates := resolveModelCandidates(cfg, defaults.Provider, primary, resolvedFallbacks)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
logger.WarnCF("agent", "Routing tier model did not resolve; tier disabled",
|
||||||
|
map[string]any{"tier": name, "model": primary, "agent_id": agentID})
|
||||||
|
if stateful, ok := provider.(providers.StatefulProvider); ok {
|
||||||
|
stateful.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result[name] = &ResolvedRouteTier{
|
||||||
|
Name: name,
|
||||||
|
Primary: primary,
|
||||||
|
Candidates: candidates,
|
||||||
|
Provider: provider,
|
||||||
|
ThinkingLevel: parseThinkingLevel(modelCfg.ThinkingLevel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tier := range rc.Tiers {
|
||||||
|
if tier.Model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addTier(tier.Name, tier.Model.Primary, tier.Model.Fallbacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 && strings.TrimSpace(rc.LightModel) != "" {
|
||||||
|
addTier("light", rc.LightModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// resolveAgentWorkspace determines the workspace directory for an agent.
|
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||||
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -73,6 +74,9 @@ func newStartedTestChannelManager(
|
||||||
|
|
||||||
type recordingProvider struct {
|
type recordingProvider struct {
|
||||||
lastMessages []providers.Message
|
lastMessages []providers.Message
|
||||||
|
lastTools []providers.ToolDefinition
|
||||||
|
responseText string
|
||||||
|
callCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingProvider) Chat(
|
func (r *recordingProvider) Chat(
|
||||||
|
|
@ -82,9 +86,15 @@ func (r *recordingProvider) Chat(
|
||||||
model string,
|
model string,
|
||||||
opts map[string]any,
|
opts map[string]any,
|
||||||
) (*providers.LLMResponse, error) {
|
) (*providers.LLMResponse, error) {
|
||||||
|
r.callCount++
|
||||||
r.lastMessages = append([]providers.Message(nil), messages...)
|
r.lastMessages = append([]providers.Message(nil), messages...)
|
||||||
|
r.lastTools = append([]providers.ToolDefinition(nil), tools...)
|
||||||
|
content := strings.TrimSpace(r.responseText)
|
||||||
|
if content == "" {
|
||||||
|
content = "Mock response"
|
||||||
|
}
|
||||||
return &providers.LLMResponse{
|
return &providers.LLMResponse{
|
||||||
Content: "Mock response",
|
Content: content,
|
||||||
ToolCalls: []providers.ToolCall{},
|
ToolCalls: []providers.ToolCall{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +238,483 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodeWorkModeInjectsPrompt(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
al.setSessionWorkMode(sessionKeyAgentPrefix+routing.DefaultAgentID+":main", "code")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "add a new MCP",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "Mock response" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
|
||||||
|
}
|
||||||
|
if len(provider.lastMessages) == 0 {
|
||||||
|
t.Fatal("provider did not receive any messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := provider.lastMessages[0].Content
|
||||||
|
if !strings.Contains(systemPrompt, "## Code Mode") {
|
||||||
|
t.Fatalf("system prompt missing code mode section:\n%s", systemPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(systemPrompt, "spawn a paid subagent") {
|
||||||
|
t.Fatalf("system prompt missing code delegation guidance:\n%s", systemPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodexPlanModeInjectsPromptAndDisablesTools(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
al.setSessionWorkMode(sessionKeyAgentPrefix+routing.DefaultAgentID+":main", "codex-plan")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "Plan how to add a deployment healthcheck",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "Mock response" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
|
||||||
|
}
|
||||||
|
if len(provider.lastMessages) == 0 {
|
||||||
|
t.Fatal("provider did not receive any messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := provider.lastMessages[0].Content
|
||||||
|
if !strings.Contains(systemPrompt, "## Codex Planning Mode") {
|
||||||
|
t.Fatalf("system prompt missing codex planning mode section:\n%s", systemPrompt)
|
||||||
|
}
|
||||||
|
if len(provider.lastTools) != 0 {
|
||||||
|
t.Fatalf("planning mode should disable tools, got %d tool defs", len(provider.lastTools))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveScopeKey_PreservesExplicitAgentScopedKey(t *testing.T) {
|
||||||
|
route := routing.ResolvedRoute{
|
||||||
|
AgentID: routing.DefaultAgentID,
|
||||||
|
SessionKey: "agent:main:telegram:direct:123",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveScopeKey(route, "agent:main:cli:test")
|
||||||
|
if got != "agent:main:cli:test" {
|
||||||
|
t.Fatalf("resolveScopeKey() = %q, want explicit agent-scoped key preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveScopeKey_NamespacesExplicitExternalKey(t *testing.T) {
|
||||||
|
route := routing.ResolvedRoute{
|
||||||
|
AgentID: routing.DefaultAgentID,
|
||||||
|
SessionKey: "agent:main:cli:direct:cron",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveScopeKey(route, "cli:diag-github")
|
||||||
|
want := "agent:main:session:cli:diag-github"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveScopeKey() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveScopeKey_FallsBackToRouteSessionKey(t *testing.T) {
|
||||||
|
route := routing.ResolvedRoute{
|
||||||
|
AgentID: routing.DefaultAgentID,
|
||||||
|
SessionKey: "agent:main:telegram:direct:123",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveScopeKey(route, "")
|
||||||
|
if got != route.SessionKey {
|
||||||
|
t.Fatalf("resolveScopeKey() = %q, want route session key %q", got, route.SessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodexPlanModeArmsApprovalWhenMarkerPresent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{responseText: "Plan:\n- do the thing\n[CODEX_APPROVAL_READY]"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main"
|
||||||
|
al.setSessionWorkMode(sessionKey, "codex-plan")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "Plan the self-deploy change",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(response, "[CODEX_APPROVAL_READY]") {
|
||||||
|
t.Fatalf("response should not expose approval marker: %q", response)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response, "Reply `proceed` to execute this plan.") {
|
||||||
|
t.Fatalf("response=%q, want proceed instruction", response)
|
||||||
|
}
|
||||||
|
if !al.hasCodexApprovalPending(sessionKey) {
|
||||||
|
t.Fatal("expected approval to be armed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodexPlanModeArmsApprovalWhenResponseClearlyOffersProceed(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{responseText: "Plan:\n1. Create NOTES.md\n2. Validate the change\n\nIf you want, I can proceed with the change next."}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main"
|
||||||
|
al.setSessionWorkMode(sessionKey, "codex-plan")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "Plan the tiny repo change",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response, "Reply `proceed` to execute this plan.") {
|
||||||
|
t.Fatalf("response=%q, want proceed instruction", response)
|
||||||
|
}
|
||||||
|
if !al.hasCodexApprovalPending(sessionKey) {
|
||||||
|
t.Fatal("expected approval to be armed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodexProceedRequiresArmedApprovalLegacy(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main"
|
||||||
|
al.setSessionWorkMode(sessionKey, "codex-plan")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "proceed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "No codex plan is awaiting approval yet. Keep chatting in /codex until I ask you to reply `proceed`." {
|
||||||
|
t.Fatalf("response=%q, want approval gate message", response)
|
||||||
|
}
|
||||||
|
if provider.callCount != 0 {
|
||||||
|
t.Fatalf("provider call count=%d, want 0", provider.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CodexProceedLaunchesBackgroundRun(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main"
|
||||||
|
|
||||||
|
repoPath := filepath.Join(tmpDir, "repos", "picoclaw")
|
||||||
|
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir repo: %v", err)
|
||||||
|
}
|
||||||
|
run := func(args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s failed: %v\n%s", strings.Join(args, " "), err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run("git", "init", "-b", "main")
|
||||||
|
run("git", "config", "user.email", "test@example.com")
|
||||||
|
run("git", "config", "user.name", "Test User")
|
||||||
|
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hello\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write README: %v", err)
|
||||||
|
}
|
||||||
|
run("git", "add", "README.md")
|
||||||
|
run("git", "commit", "-m", "init")
|
||||||
|
|
||||||
|
binDir := filepath.Join(tmpDir, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir bin: %v", err)
|
||||||
|
}
|
||||||
|
codexPath := filepath.Join(binDir, "codex")
|
||||||
|
codexStub := "#!/bin/sh\ncat >/dev/null\nsleep 1\nprintf '{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Run complete\"}}\\n'\nprintf '{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\\n'\n"
|
||||||
|
if err := os.WriteFile(codexPath, []byte(codexStub), 0o755); err != nil {
|
||||||
|
t.Fatalf("write codex stub: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
if _, err := al.codexStore.CreateOrActivate(sessionKey, "picoclaw", ""); err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := al.codexStore.SetSessionRuntime(sessionKey, codexSessionRuntimeState{
|
||||||
|
PlannerModel: "test-model",
|
||||||
|
ExecutorModel: "codex-local",
|
||||||
|
WorkMode: "codex-plan",
|
||||||
|
ApprovalPending: true,
|
||||||
|
PendingPlanID: "plan-1",
|
||||||
|
PendingPlanHash: "hash-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetSessionRuntime() error = %v", err)
|
||||||
|
}
|
||||||
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
defaultAgent.Sessions.AddMessage(sessionKey, "assistant", "Plan:\n- update the repo\nReply `proceed` to execute this plan.")
|
||||||
|
al.setSessionWorkMode(sessionKey, "codex-plan")
|
||||||
|
al.setSessionModelOverride(sessionKey, "test-model")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "proceed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response, "Codex run started:") {
|
||||||
|
t.Fatalf("response=%q, want run start message", response)
|
||||||
|
}
|
||||||
|
if got := al.getSessionWorkMode(sessionKey); got != "codex-plan" {
|
||||||
|
t.Fatalf("work mode=%q, want %q", got, "codex-plan")
|
||||||
|
}
|
||||||
|
if al.hasCodexApprovalPending(sessionKey) {
|
||||||
|
t.Fatal("approval should be cleared after proceed")
|
||||||
|
}
|
||||||
|
if provider.callCount != 0 {
|
||||||
|
t.Fatalf("provider call count=%d, want 0", provider.callCount)
|
||||||
|
}
|
||||||
|
runs := al.codexStore.ListRuns(sessionKey)
|
||||||
|
if len(runs) != 1 {
|
||||||
|
t.Fatalf("expected 1 codex run, got %d", len(runs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSessionWorkMode_FallsBackToCodexPlanForBoundSession(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &recordingProvider{})
|
||||||
|
sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main"
|
||||||
|
if al.codexStore == nil {
|
||||||
|
t.Fatal("codex store should be initialized")
|
||||||
|
}
|
||||||
|
if _, err := al.codexStore.CreateOrActivate(sessionKey, "picoclaw", ""); err != nil {
|
||||||
|
t.Fatalf("CreateOrActivate() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := al.getSessionWorkMode(sessionKey); got != "codex-plan" {
|
||||||
|
t.Fatalf("getSessionWorkMode() = %q, want %q", got, "codex-plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegateTargets_IncludesConfiguredModels(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "gpt-5.4-mini",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
Routing: &config.RoutingConfig{
|
||||||
|
Enabled: true,
|
||||||
|
LightModel: "openrouter-free",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{ModelName: "gpt-5.4-mini", Model: "openai/gpt-5.4-mini"},
|
||||||
|
{ModelName: "openrouter-free", Model: "openrouter/free"},
|
||||||
|
{ModelName: "codex-local", Model: "codex-cli/gpt-5.4-mini"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &recordingProvider{})
|
||||||
|
|
||||||
|
targets := al.codexDelegateTargets(cfg)
|
||||||
|
want := []string{"codex-local", "gpt-5.4-mini", "openrouter-free"}
|
||||||
|
if len(targets) != len(want) {
|
||||||
|
t.Fatalf("targets=%v, want %v", targets, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if targets[i] != want[i] {
|
||||||
|
t.Fatalf("targets=%v, want %v", targets, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexGitHubRepos_ReturnsReposAndBoundsResults(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ghDir := filepath.Join(tmpDir, "bin")
|
||||||
|
if err := os.MkdirAll(ghDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir gh stub dir: %v", err)
|
||||||
|
}
|
||||||
|
ghPath := filepath.Join(ghDir, "gh")
|
||||||
|
ghScript := `#!/bin/sh
|
||||||
|
case "$1 $2" in
|
||||||
|
"auth status")
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"repo list")
|
||||||
|
printf 'octo/project-one
|
||||||
|
octo/project-two
|
||||||
|
octo/project-three
|
||||||
|
'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(ghPath, []byte(ghScript), 0o755); err != nil {
|
||||||
|
t.Fatalf("write gh stub: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", ghDir)
|
||||||
|
|
||||||
|
al := NewAgentLoop(&config.Config{}, bus.NewMessageBus(), &recordingProvider{})
|
||||||
|
repos, err := al.codexGitHubRepos(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("codexGitHubRepos() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"octo/project-one", "octo/project-two"}
|
||||||
|
if len(repos) != len(want) {
|
||||||
|
t.Fatalf("repos=%v, want %v", repos, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if repos[i] != want[i] {
|
||||||
|
t.Fatalf("repos=%v, want %v", repos, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexGitHubRepos_ReturnsClearErrorWhenUnauthenticated(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ghDir := filepath.Join(tmpDir, "bin")
|
||||||
|
if err := os.MkdirAll(ghDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir gh stub dir: %v", err)
|
||||||
|
}
|
||||||
|
ghPath := filepath.Join(ghDir, "gh")
|
||||||
|
ghScript := `#!/bin/sh
|
||||||
|
case "$1 $2" in
|
||||||
|
"auth status")
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(ghPath, []byte(ghScript), 0o755); err != nil {
|
||||||
|
t.Fatalf("write gh stub: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", ghDir)
|
||||||
|
|
||||||
|
al := NewAgentLoop(&config.Config{}, bus.NewMessageBus(), &recordingProvider{})
|
||||||
|
_, err := al.codexGitHubRepos(5)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("codexGitHubRepos() error = nil, want unauthenticated error")
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != "gh CLI is not authenticated to github.com" {
|
||||||
|
t.Fatalf("error = %q, want unauthenticated message", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCodexCLIModelArg_UsesConfiguredModelID(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "codex-cli-local",
|
||||||
|
Model: "codex-cli/codex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveCodexCLIModelArg(cfg, "codex-cli-local")
|
||||||
|
if got != "codex" {
|
||||||
|
t.Fatalf("resolveCodexCLIModelArg() = %q, want %q", got, "codex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
|
func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
|
@ -741,6 +1228,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
|
||||||
cfg.Agents.Defaults.ModelName = "test-model"
|
cfg.Agents.Defaults.ModelName = "test-model"
|
||||||
cfg.Agents.Defaults.MaxTokens = 4096
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
cfg.Agents.Defaults.MaxToolIterations = 10
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
|
cfg.Agents.Defaults.Routing = nil
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &artifactThenSendProvider{}
|
provider := &artifactThenSendProvider{}
|
||||||
|
|
@ -1959,6 +2447,111 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_TieredRoutingUsesToolsTierForRepoPrompt(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
fastCalls := 0
|
||||||
|
fastServer := newStrictChatCompletionTestServer(
|
||||||
|
t,
|
||||||
|
"fast",
|
||||||
|
"gpt-5.4-nano",
|
||||||
|
"fast reply",
|
||||||
|
&fastCalls,
|
||||||
|
)
|
||||||
|
defer fastServer.Close()
|
||||||
|
|
||||||
|
toolsCalls := 0
|
||||||
|
toolsServer := newStrictChatCompletionTestServer(
|
||||||
|
t,
|
||||||
|
"tools",
|
||||||
|
"gpt-5.4-mini",
|
||||||
|
"tools reply",
|
||||||
|
&toolsCalls,
|
||||||
|
)
|
||||||
|
defer toolsServer.Close()
|
||||||
|
|
||||||
|
heavyCalls := 0
|
||||||
|
heavyServer := newStrictChatCompletionTestServer(
|
||||||
|
t,
|
||||||
|
"heavy",
|
||||||
|
"gpt-5-mini",
|
||||||
|
"heavy reply",
|
||||||
|
&heavyCalls,
|
||||||
|
)
|
||||||
|
defer heavyServer.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "gpt-5-mini",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
Routing: &config.RoutingConfig{
|
||||||
|
Enabled: true,
|
||||||
|
FreeTier: "free",
|
||||||
|
PaidTier: "heavy",
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{Name: "fast", MaxScore: 0.20, Model: &config.AgentModelConfig{Primary: "gpt-5.4-nano"}},
|
||||||
|
{Name: "tools", MaxScore: 0, Model: &config.AgentModelConfig{Primary: "gpt-5.4-mini"}},
|
||||||
|
{Name: "heavy", MaxScore: 0, Model: &config.AgentModelConfig{Primary: "gpt-5-mini"}},
|
||||||
|
{Name: "free", MaxScore: -1, Model: &config.AgentModelConfig{Primary: "openrouter-free"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{ModelName: "gpt-5.4-nano", Model: "openai/gpt-5.4-nano", APIBase: fastServer.URL, APIKeys: config.SimpleSecureStrings("fast-key")},
|
||||||
|
{ModelName: "gpt-5.4-mini", Model: "openai/gpt-5.4-mini", APIBase: toolsServer.URL, APIKeys: config.SimpleSecureStrings("tools-key")},
|
||||||
|
{ModelName: "gpt-5-mini", Model: "openai/gpt-5-mini", APIBase: heavyServer.URL, APIKeys: config.SimpleSecureStrings("heavy-key")},
|
||||||
|
{ModelName: "openrouter-free", Model: "openrouter/openai/gpt-oss-20b:free", APIBase: "https://openrouter.ai/api/v1", APIKeys: config.SimpleSecureStrings("free-key")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider, _, err := providers.CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
simpleResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hi there",
|
||||||
|
Peer: bus.Peer{Kind: "direct", ID: "user1"},
|
||||||
|
})
|
||||||
|
if simpleResp != "fast reply" {
|
||||||
|
t.Fatalf("simple response = %q, want %q", simpleResp, "fast reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
repoResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "fix ./pkg/agent/loop.go and run go test",
|
||||||
|
Peer: bus.Peer{Kind: "direct", ID: "user1"},
|
||||||
|
})
|
||||||
|
if repoResp != "tools reply" {
|
||||||
|
t.Fatalf("repo response = %q, want %q", repoResp, "tools reply")
|
||||||
|
}
|
||||||
|
if fastCalls != 1 {
|
||||||
|
t.Fatalf("fast calls = %d, want 1", fastCalls)
|
||||||
|
}
|
||||||
|
if toolsCalls != 1 {
|
||||||
|
t.Fatalf("tools calls = %d, want 1", toolsCalls)
|
||||||
|
}
|
||||||
|
if heavyCalls != 0 {
|
||||||
|
t.Fatalf("heavy calls = %d, want 0", heavyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -2226,7 +2819,8 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
||||||
ID: "cron",
|
ID: "cron",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
history := defaultAgent.Sessions.GetHistory(route.SessionKey)
|
scopeKey := resolveScopeKey(route, "tool-limit")
|
||||||
|
history := defaultAgent.Sessions.GetHistory(scopeKey)
|
||||||
if len(history) != 4 {
|
if len(history) != 4 {
|
||||||
t.Fatalf("history len = %d, want 4", len(history))
|
t.Fatalf("history len = %d, want 4", len(history))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const routingTierRefPrefix = "tier:"
|
||||||
|
|
||||||
func ensureProtocolModel(model string) string {
|
func ensureProtocolModel(model string) string {
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
|
|
@ -168,3 +170,23 @@ func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*conf
|
||||||
|
|
||||||
return &clone, nil
|
return &clone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func routingTierRef(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return routingTierRefPrefix + name
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoutingTierRef(raw string) (string, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if !strings.HasPrefix(strings.ToLower(raw), routingTierRefPrefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(raw[len(routingTierRefPrefix):])
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ func BuiltinDefinitions() []Definition {
|
||||||
boostCommand(),
|
boostCommand(),
|
||||||
paidCommand(),
|
paidCommand(),
|
||||||
freeCommand(),
|
freeCommand(),
|
||||||
|
codeCommand(),
|
||||||
|
codexCommand(),
|
||||||
|
routeCommand(),
|
||||||
|
defaultCommand(),
|
||||||
statusCommand(),
|
statusCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
|
|
|
||||||
1103
pkg/commands/cmd_codex.go
Normal file
1103
pkg/commands/cmd_codex.go
Normal file
File diff suppressed because it is too large
Load diff
976
pkg/commands/cmd_codex_test.go
Normal file
976
pkg/commands/cmd_codex_test.go
Normal file
|
|
@ -0,0 +1,976 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCodexNew_ActivatesSession(t *testing.T) {
|
||||||
|
var gotSlug, gotSource string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "codex-local" },
|
||||||
|
CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) {
|
||||||
|
gotSlug, gotSource = slug, source
|
||||||
|
return &CodexSessionInfo{
|
||||||
|
ID: "abc123",
|
||||||
|
Slug: "acme_repo",
|
||||||
|
RepoPath: "/workspace/repos/acme_repo",
|
||||||
|
RepoURL: "https://github.com/acme/repo.git",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex new acme_repo https://github.com/acme/repo.git",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotSlug != "acme_repo" {
|
||||||
|
t.Fatalf("slug=%q, want=%q", gotSlug, "acme_repo")
|
||||||
|
}
|
||||||
|
if gotSource != "https://github.com/acme/repo.git" {
|
||||||
|
t.Fatalf("source=%q, want repo url", gotSource)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex session is active") {
|
||||||
|
t.Fatalf("reply=%q, expected activation message", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Planner model: codex-local") {
|
||||||
|
t.Fatalf("reply=%q, expected planner model line", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Executor model: codex-local") {
|
||||||
|
t.Fatalf("reply=%q, expected executor model line", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "reply `proceed`") {
|
||||||
|
t.Fatalf("reply=%q, expected proceed guidance", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexNew_RequiresCodexModel(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "" },
|
||||||
|
CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex new acme_repo",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Codex mode unavailable: no codex-cli model is configured." {
|
||||||
|
t.Fatalf("reply=%q, want unavailable codex model message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexNew_InferOwnerRepoSource(t *testing.T) {
|
||||||
|
var gotSlug, gotSource string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "codex-local" },
|
||||||
|
CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) {
|
||||||
|
gotSlug, gotSource = slug, source
|
||||||
|
return &CodexSessionInfo{
|
||||||
|
ID: "abc123",
|
||||||
|
Slug: slug,
|
||||||
|
RepoPath: "/workspace/repos/" + slug,
|
||||||
|
RepoURL: "https://github.com/acme/repo.git",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex new acme/repo",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotSlug != "acme-repo" {
|
||||||
|
t.Fatalf("slug=%q, want=%q", gotSlug, "acme-repo")
|
||||||
|
}
|
||||||
|
if gotSource != "acme/repo" {
|
||||||
|
t.Fatalf("source=%q, want=%q", gotSource, "acme/repo")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex session is active") {
|
||||||
|
t.Fatalf("reply=%q, expected activation message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexNew_InferURLSource(t *testing.T) {
|
||||||
|
var gotSlug, gotSource string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "codex-local" },
|
||||||
|
CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) {
|
||||||
|
gotSlug, gotSource = slug, source
|
||||||
|
return &CodexSessionInfo{
|
||||||
|
ID: "abc123",
|
||||||
|
Slug: slug,
|
||||||
|
RepoPath: "/workspace/repos/" + slug,
|
||||||
|
RepoURL: source,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex new https://github.com/acme/repo.git",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotSlug != "acme-repo" {
|
||||||
|
t.Fatalf("slug=%q, want=%q", gotSlug, "acme-repo")
|
||||||
|
}
|
||||||
|
if gotSource != "https://github.com/acme/repo.git" {
|
||||||
|
t.Fatalf("source=%q, want URL", gotSource)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex session is active") {
|
||||||
|
t.Fatalf("reply=%q, expected activation message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexList_IncludesActiveMarker(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 6, 14, 0, 0, 0, time.UTC)
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexListSessions: func() []CodexSessionInfo {
|
||||||
|
return []CodexSessionInfo{
|
||||||
|
{ID: "a1", Slug: "picoclaw", Active: true, Updated: now},
|
||||||
|
{ID: "b2", Slug: "docs"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex list",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "a1 picoclaw [active] @ 2026-04-06T14:00:00Z") {
|
||||||
|
t.Fatalf("reply=%q, expected active listing", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "b2 docs") {
|
||||||
|
t.Fatalf("reply=%q, expected secondary listing", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStatus_NoActiveSession(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex status",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "No active codex session in this chat. Use /codex new or /codex attach." {
|
||||||
|
t.Fatalf("reply=%q, want inactive message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStatus_ShowsAwaitingApprovalPhase(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, true
|
||||||
|
},
|
||||||
|
FindCodexModel: func() string { return "gpt-5.4-mini" },
|
||||||
|
GetSessionWorkMode: func() string { return "codex-plan" },
|
||||||
|
GetCodexApprovalPending: func() bool { return true },
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex status",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Repo: picoclaw", "Phase: awaiting approval"} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStatus_ShowsPlannerAndRunState(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw", RepoURL: "https://github.com/sipeed/picoclaw.git"}, true
|
||||||
|
},
|
||||||
|
CodexPlannerStatus: func() (*CodexPlannerStatusInfo, bool) {
|
||||||
|
return &CodexPlannerStatusInfo{
|
||||||
|
Phase: "planning",
|
||||||
|
Model: "gpt-5.4-mini",
|
||||||
|
SessionID: "chat-1",
|
||||||
|
ApprovalPending: false,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
CodexRunStatus: func() (*CodexRunInfo, bool) {
|
||||||
|
return &CodexRunInfo{
|
||||||
|
ID: "run-1",
|
||||||
|
RepoSlug: "picoclaw",
|
||||||
|
Status: "running",
|
||||||
|
Model: "codex-cli-local",
|
||||||
|
Branch: "pc/chat-1/run-1",
|
||||||
|
Worktree: "/workspace/worktrees/picoclaw/run-1",
|
||||||
|
PID: 1234,
|
||||||
|
Active: true,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex status",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Phase: planning",
|
||||||
|
"Planner model: gpt-5.4-mini",
|
||||||
|
"Planner session: chat-1",
|
||||||
|
"Run: running",
|
||||||
|
"Run ID: run-1",
|
||||||
|
"Run status: running",
|
||||||
|
"Run model: codex-cli-local",
|
||||||
|
"Run branch: pc/chat-1/run-1",
|
||||||
|
"Run worktree: /workspace/worktrees/picoclaw/run-1",
|
||||||
|
"Run pid: 1234",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexResume_UsesAttach(t *testing.T) {
|
||||||
|
var gotRef string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "codex-local" },
|
||||||
|
CodexAttach: func(ref string) (*CodexSessionInfo, error) {
|
||||||
|
gotRef = ref
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex resume picoclaw",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotRef != "picoclaw" {
|
||||||
|
t.Fatalf("ref=%q, want=%q", gotRef, "picoclaw")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex session is active") {
|
||||||
|
t.Fatalf("reply=%q, expected activation message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexUse_AliasForAttach(t *testing.T) {
|
||||||
|
var gotRef string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "codex-local" },
|
||||||
|
CodexAttach: func(ref string) (*CodexSessionInfo, error) {
|
||||||
|
gotRef = ref
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex use picoclaw",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotRef != "picoclaw" {
|
||||||
|
t.Fatalf("ref=%q, want=%q", gotRef, "picoclaw")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex session is active") {
|
||||||
|
t.Fatalf("reply=%q, expected activation message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexProjects_ShowsRepoPathsAndRemote(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 6, 14, 30, 0, 0, time.UTC)
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexListSessions: func() []CodexSessionInfo {
|
||||||
|
return []CodexSessionInfo{
|
||||||
|
{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw", RepoURL: "https://github.com/sipeed/picoclaw.git", Active: true, Updated: now},
|
||||||
|
{ID: "b2", Slug: "docs"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex projects",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Codex projects:") {
|
||||||
|
t.Fatalf("reply=%q, expected header", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "a1 picoclaw [active] path=/workspace/repos/picoclaw remote=https://github.com/sipeed/picoclaw.git @ 2026-04-06T14:30:00Z") {
|
||||||
|
t.Fatalf("reply=%q, expected detailed project listing", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "b2 docs") {
|
||||||
|
t.Fatalf("reply=%q, expected second project listing", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexRepos_ListsTargets(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListCodexRepoTargets: func(limit int) ([]string, error) {
|
||||||
|
if limit != 20 {
|
||||||
|
t.Fatalf("limit=%d, want=20", limit)
|
||||||
|
}
|
||||||
|
return []string{"acme/repo-one", "acme/repo-two"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex repos",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "GitHub repos:") {
|
||||||
|
t.Fatalf("reply=%q, expected header", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "- acme/repo-one") || !strings.Contains(reply, "- acme/repo-two") {
|
||||||
|
t.Fatalf("reply=%q, expected repo entries", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexRepos_InvalidLimitShowsUsage(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListCodexRepoTargets: func(limit int) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex repos abc",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /codex repos [limit]" {
|
||||||
|
t.Fatalf("reply=%q, want usage", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexModels_ShowsSessionAndTargets(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "gpt-5.4-mini" },
|
||||||
|
GetSessionModelMode: func() (string, string) {
|
||||||
|
return "gpt-5.4-pro", "gpt-5.4-mini"
|
||||||
|
},
|
||||||
|
GetSessionWorkMode: func() string { return "codex" },
|
||||||
|
ListCodexDelegateTargets: func() []string {
|
||||||
|
return []string{"gpt-5.4-mini", "gpt-5.4-pro"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex models",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Codex model settings:",
|
||||||
|
"- Default: gpt-5.4-mini",
|
||||||
|
"- Session: gpt-5.4-pro",
|
||||||
|
"- Pending: gpt-5.4-mini",
|
||||||
|
"- Work mode: codex",
|
||||||
|
"- Delegate targets: gpt-5.4-mini, gpt-5.4-pro",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexPlan_SetsPlanningWorkMode(t *testing.T) {
|
||||||
|
var setMode string
|
||||||
|
cleared := false
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
SetSessionWorkMode: func(value string) error {
|
||||||
|
setMode = value
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ClearCodexApprovalPending: func() {
|
||||||
|
cleared = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex plan",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if setMode != "codex-plan" {
|
||||||
|
t.Fatalf("work mode=%q, want=%q", setMode, "codex-plan")
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Fatal("expected approval state to be cleared")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "planning mode enabled") {
|
||||||
|
t.Fatalf("reply=%q, want planning mode confirmation", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "reply `proceed`") {
|
||||||
|
t.Fatalf("reply=%q, want proceed guidance", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexExecute_SetsExecutionWorkMode(t *testing.T) {
|
||||||
|
var setMode string
|
||||||
|
cleared := false
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
SetSessionWorkMode: func(value string) error {
|
||||||
|
setMode = value
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ClearCodexApprovalPending: func() {
|
||||||
|
cleared = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex execute",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if setMode != "codex-plan" {
|
||||||
|
t.Fatalf("work mode=%q, want=%q", setMode, "codex-plan")
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Fatal("expected approval state to be cleared")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "launch the approved run") {
|
||||||
|
t.Fatalf("reply=%q, want launch guidance", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexGuide_ContainsPlanAndExecuteFlow(t *testing.T) {
|
||||||
|
rt := &Runtime{}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex guide",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"reply `proceed`", "/codex status", "/codex runs", "/codex tail [run-id] [lines]"} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexRuns_ListsRuns(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 6, 15, 0, 0, 0, time.UTC)
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexRunList: func() []CodexRunInfo {
|
||||||
|
return []CodexRunInfo{
|
||||||
|
{ID: "run-1", RepoSlug: "picoclaw", Status: "running", Model: "codex-cli-local", Branch: "pc/chat-1/run-1", Worktree: "/workspace/worktrees/picoclaw/run-1", Active: true, StartedAt: now},
|
||||||
|
{ID: "run-2", RepoSlug: "skezos", Status: "succeeded", Model: "codex-cli-local", Branch: "pc/chat-2/run-2", Worktree: "/workspace/worktrees/skezos/run-2", StartedAt: now.Add(-time.Hour)},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex runs",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Codex runs:",
|
||||||
|
"run-1 picoclaw [active]",
|
||||||
|
"status=running",
|
||||||
|
"model=codex-cli-local",
|
||||||
|
"branch=pc/chat-1/run-1",
|
||||||
|
"worktree=/workspace/worktrees/picoclaw/run-1",
|
||||||
|
"run-2 skezos",
|
||||||
|
"status=succeeded",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexTail_UsesActiveRunWhenIDMissing(t *testing.T) {
|
||||||
|
var gotID string
|
||||||
|
var gotLines int
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexRunStatus: func() (*CodexRunInfo, bool) {
|
||||||
|
return &CodexRunInfo{ID: "run-1", RepoSlug: "picoclaw", Status: "running", Active: true}, true
|
||||||
|
},
|
||||||
|
CodexRunTail: func(runID string, lines int) (string, error) {
|
||||||
|
gotID = runID
|
||||||
|
gotLines = lines
|
||||||
|
return "tail line 1\ntail line 2", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex tail",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotID != "run-1" {
|
||||||
|
t.Fatalf("runID=%q, want=%q", gotID, "run-1")
|
||||||
|
}
|
||||||
|
if gotLines != 120 {
|
||||||
|
t.Fatalf("lines=%d, want=120", gotLines)
|
||||||
|
}
|
||||||
|
if reply != "tail line 1\ntail line 2" {
|
||||||
|
t.Fatalf("reply=%q, want tail contents", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexConversationalFallback_BareCodexResumesMostRecent(t *testing.T) {
|
||||||
|
var attachRef string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "gpt-5.4-mini" },
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
CodexListSessions: func() []CodexSessionInfo {
|
||||||
|
return []CodexSessionInfo{
|
||||||
|
{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"},
|
||||||
|
{ID: "s2", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CodexAttach: func(ref string) (*CodexSessionInfo, error) {
|
||||||
|
attachRef = ref
|
||||||
|
return &CodexSessionInfo{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if attachRef != "s1" {
|
||||||
|
t.Fatalf("attach ref=%q, want=%q", attachRef, "s1")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Codex conversational mode is ready.",
|
||||||
|
"Repo: skezos",
|
||||||
|
"Resumed most recent project: skezos.",
|
||||||
|
"Phase: planning",
|
||||||
|
"Talk normally now",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexConversationalFallback_IntentMatchesExistingSession(t *testing.T) {
|
||||||
|
var attachRef string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "gpt-5.4-mini" },
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
CodexListSessions: func() []CodexSessionInfo {
|
||||||
|
return []CodexSessionInfo{
|
||||||
|
{ID: "p1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"},
|
||||||
|
{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CodexAttach: func(ref string) (*CodexSessionInfo, error) {
|
||||||
|
attachRef = ref
|
||||||
|
return &CodexSessionInfo{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex I want you to check out SkezOS and review latest changes",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if attachRef != "s1" {
|
||||||
|
t.Fatalf("attach ref=%q, want=%q", attachRef, "s1")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Matched existing project: skezos.",
|
||||||
|
"planning brief",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Fatalf("reply=%q, missing %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexConversationalFallback_IntentCanCreateFromRepoDiscovery(t *testing.T) {
|
||||||
|
var gotSlug, gotSource string
|
||||||
|
rt := &Runtime{
|
||||||
|
FindCodexModel: func() string { return "gpt-5.4-mini" },
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
CodexListSessions: func() []CodexSessionInfo { return nil },
|
||||||
|
ListCodexRepoTargets: func(limit int) ([]string, error) {
|
||||||
|
return []string{"joe/SkezOS", "joe/picoclaw"}, nil
|
||||||
|
},
|
||||||
|
CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) {
|
||||||
|
gotSlug, gotSource = slug, source
|
||||||
|
return &CodexSessionInfo{ID: "s3", Slug: slug, RepoPath: "/workspace/repos/" + slug}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex please open SkezOS so we can work on it",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if gotSlug != "joe-SkezOS" {
|
||||||
|
t.Fatalf("slug=%q, want=%q", gotSlug, "joe-SkezOS")
|
||||||
|
}
|
||||||
|
if gotSource != "joe/SkezOS" {
|
||||||
|
t.Fatalf("source=%q, want=%q", gotSource, "joe/SkezOS")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Created new project from GitHub repo: joe/SkezOS.") {
|
||||||
|
t.Fatalf("reply=%q, expected repo discovery note", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegate_NextArmsNextModel(t *testing.T) {
|
||||||
|
var armed string
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
ArmNextModelMode: func(value string) error {
|
||||||
|
armed = value
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex delegate gpt-5.4-mini",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if armed != "gpt-5.4-mini" {
|
||||||
|
t.Fatalf("armed=%q, want=%q", armed, "gpt-5.4-mini")
|
||||||
|
}
|
||||||
|
if reply != "Codex delegation armed for next message: gpt-5.4-mini" {
|
||||||
|
t.Fatalf("reply=%q, want next delegation confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegate_SessionSetsPersistentModel(t *testing.T) {
|
||||||
|
var set string
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
SetSessionModelMode: func(value string) error {
|
||||||
|
set = value
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex delegate gpt-5.4-pro session",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if set != "gpt-5.4-pro" {
|
||||||
|
t.Fatalf("set=%q, want=%q", set, "gpt-5.4-pro")
|
||||||
|
}
|
||||||
|
if reply != "Codex session model set to gpt-5.4-pro." {
|
||||||
|
t.Fatalf("reply=%q, want session delegation confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegate_RequiresActiveSession(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex delegate gpt-5.4-mini",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "No active codex session in this chat. Use /codex new or /codex attach first." {
|
||||||
|
t.Fatalf("reply=%q, want inactive guidance", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegate_PropagatesCallbackErrors(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
ArmNextModelMode: func(value string) error {
|
||||||
|
return context.Canceled
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex delegate gpt-5.4-mini",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != context.Canceled.Error() {
|
||||||
|
t.Fatalf("reply=%q, want callback error verbatim", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexDelegate_RejectsUnknownModelAgainstTargets(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexActive: func() (*CodexSessionInfo, bool) {
|
||||||
|
return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true
|
||||||
|
},
|
||||||
|
ListCodexDelegateTargets: func() []string {
|
||||||
|
return []string{"gpt-5.4-mini", "gpt-5.4-pro"}
|
||||||
|
},
|
||||||
|
ArmNextModelMode: func(value string) error {
|
||||||
|
t.Fatalf("ArmNextModelMode should not be called for invalid model")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex delegate openrouter-free",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "is not an allowed delegate target") {
|
||||||
|
t.Fatalf("reply=%q, want delegate allowlist rejection", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStop_ClearsSession(t *testing.T) {
|
||||||
|
runStopped := false
|
||||||
|
sessionStopped := false
|
||||||
|
rt := &Runtime{
|
||||||
|
CodexRunStop: func() error {
|
||||||
|
runStopped = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
CodexStop: func() error {
|
||||||
|
sessionStopped = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/codex stop",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !runStopped {
|
||||||
|
t.Fatal("CodexRunStop callback was not called")
|
||||||
|
}
|
||||||
|
if sessionStopped {
|
||||||
|
t.Fatal("CodexStop fallback should not be called when CodexRunStop is available")
|
||||||
|
}
|
||||||
|
if reply != "Codex run stopped. Session routing returned to default." {
|
||||||
|
t.Fatalf("reply=%q, want stop confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,25 +4,32 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func boostCommand() Definition {
|
func boostCommand() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
Name: "boost",
|
Name: "boost",
|
||||||
Description: "Use the paid model for your next message",
|
Description: "Use the heavy routed model for your next message",
|
||||||
Usage: "/boost",
|
Usage: "/boost",
|
||||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
paidModel, _ := sessionModelNames(rt)
|
targets := sessionModeTargets(rt)
|
||||||
if paidModel == "" {
|
if targets.Heavy.Target == "" {
|
||||||
return req.Reply("Boost unavailable: paid model is not configured.")
|
return req.Reply("Boost unavailable: heavy routing tier is not configured.")
|
||||||
}
|
}
|
||||||
if rt == nil || rt.ArmNextModelMode == nil {
|
if rt == nil || rt.ArmNextModelMode == nil {
|
||||||
return req.Reply(unavailableMsg)
|
return req.Reply(unavailableMsg)
|
||||||
}
|
}
|
||||||
if err := rt.ArmNextModelMode(paidModel); err != nil {
|
if err := rt.ArmNextModelMode(targets.Heavy.Target); err != nil {
|
||||||
return req.Reply(err.Error())
|
return req.Reply(err.Error())
|
||||||
}
|
}
|
||||||
return req.Reply(fmt.Sprintf("Boost armed. Next message will use %s.", paidModel))
|
if rt.ClearSessionWorkMode != nil {
|
||||||
|
if err := rt.ClearSessionWorkMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Boost armed. Next message will use %s.", targets.Heavy.Label))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -30,20 +37,25 @@ func boostCommand() Definition {
|
||||||
func paidCommand() Definition {
|
func paidCommand() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
Name: "paid",
|
Name: "paid",
|
||||||
Description: "Use the paid model for this session",
|
Description: "Legacy alias for the heavy routed model",
|
||||||
Usage: "/paid",
|
Usage: "/paid",
|
||||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
paidModel, _ := sessionModelNames(rt)
|
targets := sessionModeTargets(rt)
|
||||||
if paidModel == "" {
|
if targets.Heavy.Target == "" {
|
||||||
return req.Reply("Paid mode unavailable: primary model is not configured.")
|
return req.Reply("Paid mode unavailable: heavy routing tier is not configured.")
|
||||||
}
|
}
|
||||||
if rt == nil || rt.SetSessionModelMode == nil {
|
if rt == nil || rt.SetSessionModelMode == nil {
|
||||||
return req.Reply(unavailableMsg)
|
return req.Reply(unavailableMsg)
|
||||||
}
|
}
|
||||||
if err := rt.SetSessionModelMode(paidModel); err != nil {
|
if err := rt.SetSessionModelMode(targets.Heavy.Target); err != nil {
|
||||||
return req.Reply(err.Error())
|
return req.Reply(err.Error())
|
||||||
}
|
}
|
||||||
return req.Reply(fmt.Sprintf("Session mode set to paid (%s).", paidModel))
|
if rt.ClearSessionWorkMode != nil {
|
||||||
|
if err := rt.ClearSessionWorkMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Legacy paid mode set to heavy (%s).", targets.Heavy.Label))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,20 +63,93 @@ func paidCommand() Definition {
|
||||||
func freeCommand() Definition {
|
func freeCommand() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
Name: "free",
|
Name: "free",
|
||||||
Description: "Use the free model for this session",
|
Description: "Use the manual free tier for this session",
|
||||||
Usage: "/free",
|
Usage: "/free",
|
||||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
_, freeModel := sessionModelNames(rt)
|
targets := sessionModeTargets(rt)
|
||||||
if freeModel == "" {
|
if targets.Free.Target == "" {
|
||||||
return req.Reply("Free mode unavailable: light model is not configured.")
|
return req.Reply("Free mode unavailable: free tier is not configured.")
|
||||||
}
|
}
|
||||||
if rt == nil || rt.SetSessionModelMode == nil {
|
if rt == nil || rt.SetSessionModelMode == nil {
|
||||||
return req.Reply(unavailableMsg)
|
return req.Reply(unavailableMsg)
|
||||||
}
|
}
|
||||||
if err := rt.SetSessionModelMode(freeModel); err != nil {
|
if err := rt.SetSessionModelMode(targets.Free.Target); err != nil {
|
||||||
return req.Reply(err.Error())
|
return req.Reply(err.Error())
|
||||||
}
|
}
|
||||||
return req.Reply(fmt.Sprintf("Session mode set to free (%s).", freeModel))
|
if rt.ClearSessionWorkMode != nil {
|
||||||
|
if err := rt.ClearSessionWorkMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Session mode set to free (%s).", targets.Free.Label))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func codeCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "code",
|
||||||
|
Description: "Use the tools routing tier for this session",
|
||||||
|
Usage: "/code",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
targets := sessionModeTargets(rt)
|
||||||
|
if targets.Tools.Target == "" {
|
||||||
|
return req.Reply("Code mode unavailable: tools tier is not configured.")
|
||||||
|
}
|
||||||
|
if rt == nil || rt.SetSessionModelMode == nil || rt.SetSessionWorkMode == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.SetSessionModelMode(targets.Tools.Target); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
if err := rt.SetSessionWorkMode("code"); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Session mode set to code (%s).", targets.Tools.Label))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "route",
|
||||||
|
Description: "Use automatic tier routing for this session",
|
||||||
|
Usage: "/route",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearSessionModelMode == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearSessionModelMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
if rt.ClearSessionWorkMode != nil {
|
||||||
|
if err := rt.ClearSessionWorkMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Reply("Session mode set to route.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "default",
|
||||||
|
Description: "Return this session to default routing",
|
||||||
|
Usage: "/default",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearSessionModelMode == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearSessionModelMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
if rt.ClearSessionWorkMode != nil {
|
||||||
|
if err := rt.ClearSessionWorkMode(); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Reply("Session mode set to route.")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +169,11 @@ func statusCommand() Definition {
|
||||||
currentModel, provider = rt.GetModelInfo()
|
currentModel, provider = rt.GetModelInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
paidModel, freeModel := sessionModelNames(rt)
|
targets := sessionModeTargets(rt)
|
||||||
|
workMode := ""
|
||||||
|
if rt.GetSessionWorkMode != nil {
|
||||||
|
workMode = strings.TrimSpace(rt.GetSessionWorkMode())
|
||||||
|
}
|
||||||
persistent, pending := "", ""
|
persistent, pending := "", ""
|
||||||
if rt.GetSessionModelMode != nil {
|
if rt.GetSessionModelMode != nil {
|
||||||
persistent, pending = rt.GetSessionModelMode()
|
persistent, pending = rt.GetSessionModelMode()
|
||||||
|
|
@ -98,47 +187,161 @@ func statusCommand() Definition {
|
||||||
lines = append(lines, fmt.Sprintf("Current Model: %s", currentModel))
|
lines = append(lines, fmt.Sprintf("Current Model: %s", currentModel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines = append(lines, fmt.Sprintf("Session Mode: %s", sessionModeDescription(persistent, pending, paidModel, freeModel)))
|
lines = append(lines, fmt.Sprintf("Session Mode: %s", sessionModeDescription(persistent, pending, workMode, targets)))
|
||||||
|
if workMode != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Work Mode: %s", workMode))
|
||||||
|
}
|
||||||
|
if rt.CodexActive != nil {
|
||||||
|
if codex, ok := rt.CodexActive(); ok && codex != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf("Codex Session: %s (%s)", codex.Slug, codex.ID))
|
||||||
|
lines = append(lines, fmt.Sprintf("Codex Repo Path: %s", codex.RepoPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
if pending != "" {
|
if pending != "" {
|
||||||
lines = append(lines, fmt.Sprintf("Pending Boost: %s", pending))
|
lines = append(lines, fmt.Sprintf("Pending Boost: %s", sessionModeLabel(pending, targets)))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, "Pending Boost: none")
|
lines = append(lines, "Pending Boost: none")
|
||||||
}
|
}
|
||||||
if paidModel != "" {
|
if targets.Fast.Label != "" {
|
||||||
lines = append(lines, fmt.Sprintf("Paid Model: %s", paidModel))
|
lines = append(lines, fmt.Sprintf("Fast Model: %s", targets.Fast.Label))
|
||||||
}
|
}
|
||||||
if freeModel != "" {
|
if targets.Heavy.Label != "" {
|
||||||
lines = append(lines, fmt.Sprintf("Free Model: %s", freeModel))
|
lines = append(lines, fmt.Sprintf("Heavy Model: %s", targets.Heavy.Label))
|
||||||
|
}
|
||||||
|
if targets.Tools.Label != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Tools Model: %s", targets.Tools.Label))
|
||||||
|
}
|
||||||
|
if targets.Free.Label != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Free Model: %s", targets.Free.Label))
|
||||||
}
|
}
|
||||||
return req.Reply(strings.Join(lines, "\n"))
|
return req.Reply(strings.Join(lines, "\n"))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionModelNames(rt *Runtime) (paidModel, freeModel string) {
|
type sessionModeTarget struct {
|
||||||
|
Name string
|
||||||
|
Target string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionTargets struct {
|
||||||
|
Fast sessionModeTarget
|
||||||
|
Heavy sessionModeTarget
|
||||||
|
Tools sessionModeTarget
|
||||||
|
Free sessionModeTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionModeTargets(rt *Runtime) sessionTargets {
|
||||||
if rt == nil || rt.Config == nil {
|
if rt == nil || rt.Config == nil {
|
||||||
|
return sessionTargets{}
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := sessionTargets{
|
||||||
|
Heavy: sessionModeTarget{
|
||||||
|
Name: "heavy",
|
||||||
|
Target: strings.TrimSpace(rt.Config.Agents.Defaults.ModelName),
|
||||||
|
Label: strings.TrimSpace(rt.Config.Agents.Defaults.ModelName),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := rt.Config.Agents.Defaults.Routing; rc != nil {
|
||||||
|
if tierName, tierLabel := preferredRoutingTier(rc, "fast"); tierName != "" && tierLabel != "" {
|
||||||
|
targets.Fast = sessionModeTarget{Name: "fast", Target: "tier:" + tierName, Label: tierLabel}
|
||||||
|
}
|
||||||
|
if tierName, tierLabel := preferredRoutingTier(rc, "heavy", "paid"); tierName != "" && tierLabel != "" {
|
||||||
|
targets.Heavy = sessionModeTarget{Name: "heavy", Target: "tier:" + tierName, Label: tierLabel}
|
||||||
|
}
|
||||||
|
if tierName, tierLabel := preferredRoutingTier(rc, "tools"); tierName != "" && tierLabel != "" {
|
||||||
|
targets.Tools = sessionModeTarget{Name: "tools", Target: "tier:" + tierName, Label: tierLabel}
|
||||||
|
} else {
|
||||||
|
targets.Tools = targets.Heavy
|
||||||
|
targets.Tools.Name = "tools"
|
||||||
|
}
|
||||||
|
if tierName, tierLabel := preferredRoutingTier(rc, "free"); tierName != "" && tierLabel != "" {
|
||||||
|
targets.Free = sessionModeTarget{Name: "free", Target: "tier:" + tierName, Label: tierLabel}
|
||||||
|
} else if free := strings.TrimSpace(rc.LightModel); free != "" {
|
||||||
|
targets.Free = sessionModeTarget{Name: "free", Target: free, Label: free}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func preferredRoutingTier(rc *config.RoutingConfig, modes ...string) (name, label string) {
|
||||||
|
if rc == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
paidModel = strings.TrimSpace(rt.Config.Agents.Defaults.ModelName)
|
for _, mode := range modes {
|
||||||
if rt.Config.Agents.Defaults.Routing != nil {
|
target := ""
|
||||||
freeModel = strings.TrimSpace(rt.Config.Agents.Defaults.Routing.LightModel)
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "paid":
|
||||||
|
target = strings.TrimSpace(rc.PaidTier)
|
||||||
|
if target == "" {
|
||||||
|
target = "paid"
|
||||||
|
}
|
||||||
|
case "free":
|
||||||
|
target = strings.TrimSpace(rc.FreeTier)
|
||||||
|
if target == "" {
|
||||||
|
target = "free"
|
||||||
|
}
|
||||||
|
case "fast", "heavy", "tools":
|
||||||
|
target = strings.TrimSpace(mode)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tier := range rc.Tiers {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(tier.Name), target) || tier.Model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primary := strings.TrimSpace(tier.Model.Primary)
|
||||||
|
if primary == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(tier.Name), primary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return paidModel, freeModel
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionModeDescription(persistent, pending, paidModel, freeModel string) string {
|
func sessionModeDescription(persistent, pending, workMode string, targets sessionTargets) string {
|
||||||
if pending != "" {
|
if pending != "" {
|
||||||
return fmt.Sprintf("boost armed for next message (%s)", pending)
|
return fmt.Sprintf("boost armed for next message (%s)", sessionModeLabel(pending, targets))
|
||||||
|
}
|
||||||
|
if workMode != "" {
|
||||||
|
if persistent != "" {
|
||||||
|
return fmt.Sprintf("%s (%s)", workMode, sessionModeLabel(persistent, targets))
|
||||||
|
}
|
||||||
|
return workMode
|
||||||
}
|
}
|
||||||
if persistent == "" {
|
if persistent == "" {
|
||||||
return "route (default)"
|
return "route (default)"
|
||||||
}
|
}
|
||||||
if paidModel != "" && strings.EqualFold(persistent, paidModel) {
|
if targets.Heavy.Target != "" && strings.EqualFold(persistent, targets.Heavy.Target) {
|
||||||
return fmt.Sprintf("paid (%s)", persistent)
|
return fmt.Sprintf("heavy (%s)", targets.Heavy.Label)
|
||||||
}
|
}
|
||||||
if freeModel != "" && strings.EqualFold(persistent, freeModel) {
|
if targets.Tools.Target != "" && strings.EqualFold(persistent, targets.Tools.Target) {
|
||||||
return fmt.Sprintf("free (%s)", persistent)
|
return fmt.Sprintf("tools (%s)", targets.Tools.Label)
|
||||||
|
}
|
||||||
|
if targets.Free.Target != "" && strings.EqualFold(persistent, targets.Free.Target) {
|
||||||
|
return fmt.Sprintf("free (%s)", targets.Free.Label)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("custom (%s)", sessionModeLabel(persistent, targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionModeLabel(value string, targets sessionTargets) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch {
|
||||||
|
case targets.Heavy.Target != "" && strings.EqualFold(value, targets.Heavy.Target):
|
||||||
|
return targets.Heavy.Label
|
||||||
|
case targets.Tools.Target != "" && strings.EqualFold(value, targets.Tools.Target):
|
||||||
|
return targets.Tools.Label
|
||||||
|
case targets.Free.Target != "" && strings.EqualFold(value, targets.Free.Target):
|
||||||
|
return targets.Free.Label
|
||||||
|
case targets.Fast.Target != "" && strings.EqualFold(value, targets.Fast.Target):
|
||||||
|
return targets.Fast.Label
|
||||||
|
default:
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("custom (%s)", persistent)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,36 @@ func newModeTestRuntime() *Runtime {
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-5.4-mini",
|
ModelName: "gpt-5-mini",
|
||||||
Routing: &config.RoutingConfig{
|
Routing: &config.RoutingConfig{
|
||||||
LightModel: "openrouter-free",
|
FreeTier: "free",
|
||||||
|
PaidTier: "heavy",
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{
|
||||||
|
Name: "fast",
|
||||||
|
Model: &config.AgentModelConfig{
|
||||||
|
Primary: "gpt-5.4-nano",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tools",
|
||||||
|
Model: &config.AgentModelConfig{
|
||||||
|
Primary: "gpt-5.4-mini",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "heavy",
|
||||||
|
Model: &config.AgentModelConfig{
|
||||||
|
Primary: "gpt-5-mini",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "free",
|
||||||
|
Model: &config.AgentModelConfig{
|
||||||
|
Primary: "openrouter-free",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -44,10 +71,10 @@ func TestBoostCommand_ArmsNextModel(t *testing.T) {
|
||||||
if res.Outcome != OutcomeHandled {
|
if res.Outcome != OutcomeHandled {
|
||||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
}
|
}
|
||||||
if armed != "gpt-5.4-mini" {
|
if armed != "tier:heavy" {
|
||||||
t.Fatalf("armed=%q, want %q", armed, "gpt-5.4-mini")
|
t.Fatalf("armed=%q, want %q", armed, "tier:heavy")
|
||||||
}
|
}
|
||||||
if reply != "Boost armed. Next message will use gpt-5.4-mini." {
|
if reply != "Boost armed. Next message will use gpt-5-mini." {
|
||||||
t.Fatalf("reply=%q, want boost confirmation", reply)
|
t.Fatalf("reply=%q, want boost confirmation", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,10 +82,15 @@ func TestBoostCommand_ArmsNextModel(t *testing.T) {
|
||||||
func TestPaidCommand_SetsPersistentModel(t *testing.T) {
|
func TestPaidCommand_SetsPersistentModel(t *testing.T) {
|
||||||
rt := newModeTestRuntime()
|
rt := newModeTestRuntime()
|
||||||
var persistent string
|
var persistent string
|
||||||
|
workMode := "code"
|
||||||
rt.SetSessionModelMode = func(value string) error {
|
rt.SetSessionModelMode = func(value string) error {
|
||||||
persistent = value
|
persistent = value
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
rt.ClearSessionWorkMode = func() error {
|
||||||
|
workMode = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
|
@ -73,21 +105,65 @@ func TestPaidCommand_SetsPersistentModel(t *testing.T) {
|
||||||
if res.Outcome != OutcomeHandled {
|
if res.Outcome != OutcomeHandled {
|
||||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
}
|
}
|
||||||
if persistent != "gpt-5.4-mini" {
|
if persistent != "tier:heavy" {
|
||||||
t.Fatalf("persistent=%q, want %q", persistent, "gpt-5.4-mini")
|
t.Fatalf("persistent=%q, want %q", persistent, "tier:heavy")
|
||||||
}
|
}
|
||||||
if reply != "Session mode set to paid (gpt-5.4-mini)." {
|
if workMode != "" {
|
||||||
|
t.Fatalf("workMode=%q, want cleared", workMode)
|
||||||
|
}
|
||||||
|
if reply != "Legacy paid mode set to heavy (gpt-5-mini)." {
|
||||||
t.Fatalf("reply=%q, want paid confirmation", reply)
|
t.Fatalf("reply=%q, want paid confirmation", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodeCommand_SetsWorkModeAndPaidModel(t *testing.T) {
|
||||||
|
rt := newModeTestRuntime()
|
||||||
|
var persistent, workMode string
|
||||||
|
rt.SetSessionModelMode = func(value string) error {
|
||||||
|
persistent = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.SetSessionWorkMode = func(value string) error {
|
||||||
|
workMode = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/code",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if persistent != "tier:tools" {
|
||||||
|
t.Fatalf("persistent=%q, want %q", persistent, "tier:tools")
|
||||||
|
}
|
||||||
|
if workMode != "code" {
|
||||||
|
t.Fatalf("workMode=%q, want %q", workMode, "code")
|
||||||
|
}
|
||||||
|
if reply != "Session mode set to code (gpt-5.4-mini)." {
|
||||||
|
t.Fatalf("reply=%q, want code confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFreeCommand_SetsPersistentModel(t *testing.T) {
|
func TestFreeCommand_SetsPersistentModel(t *testing.T) {
|
||||||
rt := newModeTestRuntime()
|
rt := newModeTestRuntime()
|
||||||
var persistent string
|
var persistent string
|
||||||
|
workMode := "code"
|
||||||
rt.SetSessionModelMode = func(value string) error {
|
rt.SetSessionModelMode = func(value string) error {
|
||||||
persistent = value
|
persistent = value
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
rt.ClearSessionWorkMode = func() error {
|
||||||
|
workMode = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
|
@ -102,22 +178,95 @@ func TestFreeCommand_SetsPersistentModel(t *testing.T) {
|
||||||
if res.Outcome != OutcomeHandled {
|
if res.Outcome != OutcomeHandled {
|
||||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
}
|
}
|
||||||
if persistent != "openrouter-free" {
|
if persistent != "tier:free" {
|
||||||
t.Fatalf("persistent=%q, want %q", persistent, "openrouter-free")
|
t.Fatalf("persistent=%q, want %q", persistent, "tier:free")
|
||||||
|
}
|
||||||
|
if workMode != "" {
|
||||||
|
t.Fatalf("workMode=%q, want cleared", workMode)
|
||||||
}
|
}
|
||||||
if reply != "Session mode set to free (openrouter-free)." {
|
if reply != "Session mode set to free (openrouter-free)." {
|
||||||
t.Fatalf("reply=%q, want free confirmation", reply)
|
t.Fatalf("reply=%q, want free confirmation", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultCommand_ClearsSessionModel(t *testing.T) {
|
||||||
|
rt := newModeTestRuntime()
|
||||||
|
persistent := "tier:free"
|
||||||
|
pending := "tier:heavy"
|
||||||
|
workMode := "code"
|
||||||
|
rt.GetSessionModelMode = func() (string, string) {
|
||||||
|
return persistent, pending
|
||||||
|
}
|
||||||
|
rt.GetSessionWorkMode = func() string { return workMode }
|
||||||
|
rt.ClearSessionModelMode = func() error {
|
||||||
|
persistent = ""
|
||||||
|
pending = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.ClearSessionWorkMode = func() error {
|
||||||
|
workMode = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/default",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if persistent != "" || pending != "" || workMode != "" {
|
||||||
|
t.Fatalf("persistent=%q pending=%q workMode=%q, want all cleared", persistent, pending, workMode)
|
||||||
|
}
|
||||||
|
if reply != "Session mode set to route." {
|
||||||
|
t.Fatalf("reply=%q, want default confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteCommand_ClearsSessionModel(t *testing.T) {
|
||||||
|
rt := newModeTestRuntime()
|
||||||
|
cleared := false
|
||||||
|
rt.ClearSessionModelMode = func() error {
|
||||||
|
cleared = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/route",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Fatal("expected session mode to be cleared")
|
||||||
|
}
|
||||||
|
if reply != "Session mode set to route." {
|
||||||
|
t.Fatalf("reply=%q, want route confirmation", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatusCommand_ReportsPendingBoost(t *testing.T) {
|
func TestStatusCommand_ReportsPendingBoost(t *testing.T) {
|
||||||
rt := newModeTestRuntime()
|
rt := newModeTestRuntime()
|
||||||
rt.GetModelInfo = func() (string, string) {
|
rt.GetModelInfo = func() (string, string) {
|
||||||
return "gpt-5.4-mini", "openai"
|
return "gpt-5.4-mini", "openai"
|
||||||
}
|
}
|
||||||
rt.GetSessionModelMode = func() (string, string) {
|
rt.GetSessionModelMode = func() (string, string) {
|
||||||
return "openrouter-free", "gpt-5.4-mini"
|
return "tier:free", "tier:heavy"
|
||||||
}
|
}
|
||||||
|
rt.GetSessionWorkMode = func() string { return "code" }
|
||||||
|
|
||||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
|
@ -134,9 +283,12 @@ func TestStatusCommand_ReportsPendingBoost(t *testing.T) {
|
||||||
}
|
}
|
||||||
if !containsAll(reply, []string{
|
if !containsAll(reply, []string{
|
||||||
"Current Model: gpt-5.4-mini (Provider: openai)",
|
"Current Model: gpt-5.4-mini (Provider: openai)",
|
||||||
"Session Mode: boost armed for next message (gpt-5.4-mini)",
|
"Session Mode: boost armed for next message (gpt-5-mini)",
|
||||||
"Pending Boost: gpt-5.4-mini",
|
"Work Mode: code",
|
||||||
"Paid Model: gpt-5.4-mini",
|
"Pending Boost: gpt-5-mini",
|
||||||
|
"Fast Model: gpt-5.4-nano",
|
||||||
|
"Heavy Model: gpt-5-mini",
|
||||||
|
"Tools Model: gpt-5.4-mini",
|
||||||
"Free Model: openrouter-free",
|
"Free Model: openrouter-free",
|
||||||
}) {
|
}) {
|
||||||
t.Fatalf("reply=%q, missing expected status content", reply)
|
t.Fatalf("reply=%q, missing expected status content", reply)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
|
||||||
// Sub-command routing
|
// Sub-command routing
|
||||||
subName := nthToken(req.Text, 1)
|
subName := nthToken(req.Text, 1)
|
||||||
if subName == "" {
|
if subName == "" {
|
||||||
|
if def.Handler != nil {
|
||||||
|
err := def.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
err := req.Reply("Usage: " + def.EffectiveUsage())
|
err := req.Reply("Usage: " + def.EffectiveUsage())
|
||||||
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +88,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown sub-command
|
// Unknown sub-command
|
||||||
|
if def.Handler != nil {
|
||||||
|
err := def.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage()))
|
err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage()))
|
||||||
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,50 @@
|
||||||
package commands
|
package commands
|
||||||
|
|
||||||
import "github.com/sipeed/picoclaw/pkg/config"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodexSessionInfo describes a repo-scoped codex session.
|
||||||
|
type CodexSessionInfo struct {
|
||||||
|
ID string
|
||||||
|
Slug string
|
||||||
|
RepoPath string
|
||||||
|
RepoURL string
|
||||||
|
Updated time.Time
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexPlannerStatusInfo describes the user-facing planner state for a repo-scoped codex session.
|
||||||
|
type CodexPlannerStatusInfo struct {
|
||||||
|
Phase string
|
||||||
|
Model string
|
||||||
|
SessionID string
|
||||||
|
RepoSlug string
|
||||||
|
RepoPath string
|
||||||
|
RepoURL string
|
||||||
|
ApprovalPending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexRunInfo describes a background codex execution run.
|
||||||
|
type CodexRunInfo struct {
|
||||||
|
ID string
|
||||||
|
SessionID string
|
||||||
|
RepoSlug string
|
||||||
|
RepoPath string
|
||||||
|
RepoURL string
|
||||||
|
Branch string
|
||||||
|
Worktree string
|
||||||
|
Model string
|
||||||
|
Status string
|
||||||
|
PID int
|
||||||
|
ExitCode int
|
||||||
|
Active bool
|
||||||
|
StartedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime provides runtime dependencies to command handlers. It is constructed
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
// per-request by the agent loop so that per-request state (like session scope)
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
|
|
@ -18,7 +62,28 @@ type Runtime struct {
|
||||||
ClearHistory func() error
|
ClearHistory func() error
|
||||||
ReloadConfig func() error
|
ReloadConfig func() error
|
||||||
|
|
||||||
GetSessionModelMode func() (persistent, pending string)
|
GetSessionModelMode func() (persistent, pending string)
|
||||||
SetSessionModelMode func(value string) error
|
SetSessionModelMode func(value string) error
|
||||||
ArmNextModelMode func(value string) error
|
ArmNextModelMode func(value string) error
|
||||||
|
ClearSessionModelMode func() error
|
||||||
|
|
||||||
|
GetSessionWorkMode func() string
|
||||||
|
SetSessionWorkMode func(value string) error
|
||||||
|
ClearSessionWorkMode func() error
|
||||||
|
GetCodexApprovalPending func() bool
|
||||||
|
ClearCodexApprovalPending func()
|
||||||
|
|
||||||
|
FindCodexModel func() string
|
||||||
|
ListCodexDelegateTargets func() []string
|
||||||
|
ListCodexRepoTargets func(limit int) ([]string, error)
|
||||||
|
CodexNewSession func(slug, source string) (*CodexSessionInfo, error)
|
||||||
|
CodexAttach func(ref string) (*CodexSessionInfo, error)
|
||||||
|
CodexListSessions func() []CodexSessionInfo
|
||||||
|
CodexActive func() (*CodexSessionInfo, bool)
|
||||||
|
CodexPlannerStatus func() (*CodexPlannerStatusInfo, bool)
|
||||||
|
CodexRunList func() []CodexRunInfo
|
||||||
|
CodexRunStatus func() (*CodexRunInfo, bool)
|
||||||
|
CodexRunTail func(runID string, lines int) (string, error)
|
||||||
|
CodexRunStop func() error
|
||||||
|
CodexStop func() error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,16 +200,30 @@ type SessionConfig struct {
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoutingTierConfig defines a named tier that routing can select.
|
||||||
|
// Tiers are evaluated in order; the first tier whose MaxScore is >= the
|
||||||
|
// computed complexity score is selected. A MaxScore of 0 means "catch-all".
|
||||||
|
// A MaxScore below 0 marks the tier as manual-only, so commands like /free or
|
||||||
|
// /paid can select it without /route ever picking it automatically.
|
||||||
|
type RoutingTierConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxScore float64 `json:"max_score,omitempty"`
|
||||||
|
Model *AgentModelConfig `json:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// RoutingConfig controls the intelligent model routing feature.
|
// RoutingConfig controls the intelligent model routing feature.
|
||||||
// When enabled, each incoming message is scored against structural features
|
// When enabled, each incoming message is scored against structural features
|
||||||
// (message length, code blocks, tool call history, conversation depth, attachments).
|
// (message length, code blocks, tool call history, conversation depth, attachments).
|
||||||
// Messages scoring below Threshold are sent to LightModel; all others use the
|
// Legacy configs can continue to use LightModel/Threshold; newer configs can
|
||||||
// agent's primary model. This reduces cost and latency for simple tasks without
|
// define named tiers with their own model chains and use FreeTier/PaidTier
|
||||||
// requiring any keyword matching — all scoring is language-agnostic.
|
// for the session mode commands.
|
||||||
type RoutingConfig struct {
|
type RoutingConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
|
LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks
|
||||||
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
|
Threshold float64 `json:"threshold,omitempty"` // legacy: complexity score in [0,1]; score >= threshold → primary model
|
||||||
|
Tiers []RoutingTierConfig `json:"tiers,omitempty"`
|
||||||
|
FreeTier string `json:"free_tier,omitempty"`
|
||||||
|
PaidTier string `json:"paid_tier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubTurnConfig configures the SubTurn execution system.
|
// SubTurnConfig configures the SubTurn execution system.
|
||||||
|
|
@ -805,6 +819,19 @@ type ExecConfig struct {
|
||||||
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GitToolsConfig struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_GIT_"`
|
||||||
|
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_GIT_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GithubToolsConfig struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_GITHUB_"`
|
||||||
|
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_TOOLS_GITHUB_TOKEN"`
|
||||||
|
BaseURL string `json:"base_url,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_BASE_URL"`
|
||||||
|
Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_PROXY"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_TIMEOUT_SECONDS"`
|
||||||
|
}
|
||||||
|
|
||||||
type SkillsToolsConfig struct {
|
type SkillsToolsConfig struct {
|
||||||
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"`
|
Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"`
|
||||||
|
|
@ -855,6 +882,8 @@ type ToolsConfig struct {
|
||||||
Web WebToolsConfig `json:"web" yaml:"web,omitempty"`
|
Web WebToolsConfig `json:"web" yaml:"web,omitempty"`
|
||||||
Cron CronToolsConfig `json:"cron" yaml:"-"`
|
Cron CronToolsConfig `json:"cron" yaml:"-"`
|
||||||
Exec ExecConfig `json:"exec" yaml:"-"`
|
Exec ExecConfig `json:"exec" yaml:"-"`
|
||||||
|
Git GitToolsConfig `json:"git" yaml:"-"`
|
||||||
|
Github GithubToolsConfig `json:"github" yaml:"github,omitempty"`
|
||||||
Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"`
|
Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"`
|
||||||
MCP MCPConfig `json:"mcp" yaml:"-"`
|
MCP MCPConfig `json:"mcp" yaml:"-"`
|
||||||
|
|
@ -1084,6 +1113,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
if err = cfg.ValidateModelList(); err != nil {
|
if err = cfg.ValidateModelList(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err = cfg.ValidateRouting(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure Workspace has a default if not set
|
// Ensure Workspace has a default if not set
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.Agents.Defaults.Workspace == "" {
|
||||||
|
|
@ -1219,6 +1251,49 @@ func (c *Config) ValidateModelList() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) ValidateRouting() error {
|
||||||
|
if c == nil || c.Agents.Defaults.Routing == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := c.Agents.Defaults.Routing
|
||||||
|
seen := make(map[string]struct{}, len(rc.Tiers))
|
||||||
|
for i, tier := range rc.Tiers {
|
||||||
|
name := strings.TrimSpace(tier.Name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("agents.defaults.routing.tiers[%d]: name is required", i)
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return fmt.Errorf("agents.defaults.routing.tiers[%d]: duplicate tier name %q", i, name)
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
if tier.Model == nil || strings.TrimSpace(tier.Model.Primary) == "" {
|
||||||
|
return fmt.Errorf("agents.defaults.routing.tiers[%d]: model.primary is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateTierRef := func(fieldName, tierName string) error {
|
||||||
|
tierName = strings.TrimSpace(tierName)
|
||||||
|
if tierName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := seen[strings.ToLower(tierName)]; !ok {
|
||||||
|
return fmt.Errorf("agents.defaults.routing.%s references unknown tier %q", fieldName, tierName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTierRef("free_tier", rc.FreeTier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateTierRef("paid_tier", rc.PaidTier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) SecurityCopyFrom(path string) error {
|
func (c *Config) SecurityCopyFrom(path string) error {
|
||||||
return loadSecurityConfig(c, securityPath(path))
|
return loadSecurityConfig(c, securityPath(path))
|
||||||
}
|
}
|
||||||
|
|
@ -1312,6 +1387,10 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.Cron.Enabled
|
return t.Cron.Enabled
|
||||||
case "exec":
|
case "exec":
|
||||||
return t.Exec.Enabled
|
return t.Exec.Enabled
|
||||||
|
case "git":
|
||||||
|
return t.Git.Enabled
|
||||||
|
case "github":
|
||||||
|
return t.Github.Enabled
|
||||||
case "skills":
|
case "skills":
|
||||||
return t.Skills.Enabled
|
return t.Skills.Enabled
|
||||||
case "media_cleanup":
|
case "media_cleanup":
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,114 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_TieredRoutingHydrated(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.ModelName != "gpt-5-mini" {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "gpt-5-mini")
|
||||||
|
}
|
||||||
|
if got := cfg.Agents.Defaults.ModelFallbacks; len(got) != 1 || got[0] != "gpt-5.4-mini" {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.ModelFallbacks = %v, want [gpt-5.4-mini]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := cfg.Agents.Defaults.Routing
|
||||||
|
if rt == nil {
|
||||||
|
t.Fatal("DefaultConfig().Agents.Defaults.Routing should be configured")
|
||||||
|
}
|
||||||
|
if !rt.Enabled {
|
||||||
|
t.Fatal("DefaultConfig().Agents.Defaults.Routing.Enabled should be true")
|
||||||
|
}
|
||||||
|
if rt.FreeTier != "free" {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.Routing.FreeTier = %q, want %q", rt.FreeTier, "free")
|
||||||
|
}
|
||||||
|
if rt.PaidTier != "heavy" {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.Routing.PaidTier = %q, want %q", rt.PaidTier, "heavy")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := map[string]struct {
|
||||||
|
maxScore float64
|
||||||
|
primary string
|
||||||
|
fallbacks []string
|
||||||
|
}{
|
||||||
|
"fast": {
|
||||||
|
maxScore: 0.20,
|
||||||
|
primary: "gpt-5.4-nano",
|
||||||
|
fallbacks: []string{"gpt-5-nano"},
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
maxScore: 0,
|
||||||
|
primary: "gpt-5.4-mini",
|
||||||
|
fallbacks: []string{"gpt-5-mini"},
|
||||||
|
},
|
||||||
|
"heavy": {
|
||||||
|
maxScore: 0,
|
||||||
|
primary: "gpt-5-mini",
|
||||||
|
fallbacks: []string{"gpt-5.4-mini"},
|
||||||
|
},
|
||||||
|
"free": {
|
||||||
|
maxScore: -1,
|
||||||
|
primary: "openrouter-free-qwen",
|
||||||
|
fallbacks: []string{"openrouter-free-oss", "openrouter-free-step"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rt.Tiers) != len(expected) {
|
||||||
|
t.Fatalf("DefaultConfig().Agents.Defaults.Routing.Tiers len = %d, want %d", len(rt.Tiers), len(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tier := range rt.Tiers {
|
||||||
|
want, ok := expected[tier.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected routing tier %q in defaults", tier.Name)
|
||||||
|
}
|
||||||
|
if tier.MaxScore != want.maxScore {
|
||||||
|
t.Fatalf("tier %q max_score = %v, want %v", tier.Name, tier.MaxScore, want.maxScore)
|
||||||
|
}
|
||||||
|
if tier.Model == nil {
|
||||||
|
t.Fatalf("tier %q model should not be nil", tier.Name)
|
||||||
|
}
|
||||||
|
if tier.Model.Primary != want.primary {
|
||||||
|
t.Fatalf("tier %q primary = %q, want %q", tier.Name, tier.Model.Primary, want.primary)
|
||||||
|
}
|
||||||
|
if strings.Join(tier.Model.Fallbacks, ",") != strings.Join(want.fallbacks, ",") {
|
||||||
|
t.Fatalf("tier %q fallbacks = %v, want %v", tier.Name, tier.Model.Fallbacks, want.fallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.ValidateRouting(); err != nil {
|
||||||
|
t.Fatalf("DefaultConfig().ValidateRouting() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_TieredModelAliasesPresent(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
expectedModels := map[string]string{
|
||||||
|
"gpt-5.4-mini": "openai/gpt-5.4-mini",
|
||||||
|
"gpt-5-mini": "openai/gpt-5-mini",
|
||||||
|
"gpt-5.4-nano": "openai/gpt-5.4-nano",
|
||||||
|
"gpt-5-nano": "openai/gpt-5-nano",
|
||||||
|
"openrouter-free-qwen": "openrouter/qwen/qwen3-next-80b-a3b-instruct:free",
|
||||||
|
"openrouter-free-oss": "openrouter/openai/gpt-oss-20b:free",
|
||||||
|
"openrouter-free-step": "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsByName := make(map[string]*ModelConfig, len(cfg.ModelList))
|
||||||
|
for _, modelCfg := range cfg.ModelList {
|
||||||
|
modelsByName[modelCfg.ModelName] = modelCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
for modelName, modelPath := range expectedModels {
|
||||||
|
modelCfg, ok := modelsByName[modelName]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DefaultConfig().ModelList missing %q", modelName)
|
||||||
|
}
|
||||||
|
if modelCfg.Model != modelPath {
|
||||||
|
t.Fatalf("model %q path = %q, want %q", modelName, modelCfg.Model, modelPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_Temperature verifies temperature has default value
|
// TestDefaultConfig_Temperature verifies temperature has default value
|
||||||
func TestDefaultConfig_Temperature(t *testing.T) {
|
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -348,7 +456,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
func TestSaveConfig_IncludesHydratedDefaultModelField(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := filepath.Join(tmpDir, "config.json")
|
path := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
||||||
|
|
@ -362,8 +470,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
||||||
t.Fatalf("ReadFile failed: %v", err)
|
t.Fatalf("ReadFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(string(data), `"model_name": ""`) {
|
if !strings.Contains(string(data), `"model_name": "gpt-5-mini"`) {
|
||||||
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
|
t.Fatalf("saved config should include hydrated default model_name, got: %s", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,6 +668,26 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_GitTimeout(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.Tools.Git.TimeoutSeconds != 60 {
|
||||||
|
t.Fatalf("DefaultConfig().Tools.Git.TimeoutSeconds = %d, want 60", cfg.Tools.Git.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_GithubDefaults(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.Tools.Github.Enabled {
|
||||||
|
t.Fatal("DefaultConfig().Tools.Github.Enabled should be false")
|
||||||
|
}
|
||||||
|
if cfg.Tools.Github.BaseURL != "https://api.github.com" {
|
||||||
|
t.Fatalf("DefaultConfig().Tools.Github.BaseURL = %q, want https://api.github.com", cfg.Tools.Github.BaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Tools.Github.TimeoutSeconds != 20 {
|
||||||
|
t.Fatalf("DefaultConfig().Tools.Github.TimeoutSeconds = %d, want 20", cfg.Tools.Github.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if !cfg.Tools.FilterSensitiveData {
|
if !cfg.Tools.FilterSensitiveData {
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,51 @@ func DefaultConfig() *Config {
|
||||||
Workspace: workspacePath,
|
Workspace: workspacePath,
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
|
ModelName: "gpt-5-mini",
|
||||||
|
ModelFallbacks: []string{"gpt-5.4-mini"},
|
||||||
MaxTokens: 32768,
|
MaxTokens: 32768,
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 50,
|
||||||
SummarizeMessageThreshold: 20,
|
SummarizeMessageThreshold: 20,
|
||||||
SummarizeTokenPercent: 75,
|
SummarizeTokenPercent: 75,
|
||||||
SteeringMode: "one-at-a-time",
|
Routing: &RoutingConfig{
|
||||||
|
Enabled: true,
|
||||||
|
FreeTier: "free",
|
||||||
|
PaidTier: "heavy",
|
||||||
|
Tiers: []RoutingTierConfig{
|
||||||
|
{
|
||||||
|
Name: "fast",
|
||||||
|
MaxScore: 0.20,
|
||||||
|
Model: &AgentModelConfig{
|
||||||
|
Primary: "gpt-5.4-nano",
|
||||||
|
Fallbacks: []string{"gpt-5-nano"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tools",
|
||||||
|
Model: &AgentModelConfig{
|
||||||
|
Primary: "gpt-5.4-mini",
|
||||||
|
Fallbacks: []string{"gpt-5-mini"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "heavy",
|
||||||
|
Model: &AgentModelConfig{
|
||||||
|
Primary: "gpt-5-mini",
|
||||||
|
Fallbacks: []string{"gpt-5.4-mini"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "free",
|
||||||
|
MaxScore: -1,
|
||||||
|
Model: &AgentModelConfig{
|
||||||
|
Primary: "openrouter-free-qwen",
|
||||||
|
Fallbacks: []string{"openrouter-free-oss", "openrouter-free-step"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SteeringMode: "one-at-a-time",
|
||||||
ToolFeedback: ToolFeedbackConfig{
|
ToolFeedback: ToolFeedbackConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
|
|
@ -170,6 +209,26 @@ func DefaultConfig() *Config {
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
APIBase: "https://api.openai.com/v1",
|
APIBase: "https://api.openai.com/v1",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ModelName: "gpt-5.4-mini",
|
||||||
|
Model: "openai/gpt-5.4-mini",
|
||||||
|
APIBase: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "gpt-5-mini",
|
||||||
|
Model: "openai/gpt-5-mini",
|
||||||
|
APIBase: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "gpt-5.4-nano",
|
||||||
|
Model: "openai/gpt-5.4-nano",
|
||||||
|
APIBase: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "gpt-5-nano",
|
||||||
|
Model: "openai/gpt-5-nano",
|
||||||
|
APIBase: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
|
||||||
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||||
{
|
{
|
||||||
|
|
@ -231,6 +290,21 @@ func DefaultConfig() *Config {
|
||||||
Model: "openrouter/openai/gpt-5.4",
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-free-qwen",
|
||||||
|
Model: "openrouter/qwen/qwen3-next-80b-a3b-instruct:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-free-oss",
|
||||||
|
Model: "openrouter/openai/gpt-oss-20b:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-free-step",
|
||||||
|
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
|
||||||
// NVIDIA - https://build.nvidia.com/
|
// NVIDIA - https://build.nvidia.com/
|
||||||
{
|
{
|
||||||
|
|
@ -429,6 +503,19 @@ func DefaultConfig() *Config {
|
||||||
AllowRemote: true,
|
AllowRemote: true,
|
||||||
TimeoutSeconds: 60,
|
TimeoutSeconds: 60,
|
||||||
},
|
},
|
||||||
|
Git: GitToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
TimeoutSeconds: 60,
|
||||||
|
},
|
||||||
|
Github: GithubToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
BaseURL: "https://api.github.com",
|
||||||
|
TimeoutSeconds: 20,
|
||||||
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,95 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_ValidateRouting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid tiered routing",
|
||||||
|
config: &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Routing: &RoutingConfig{
|
||||||
|
FreeTier: "free",
|
||||||
|
PaidTier: "heavy",
|
||||||
|
Tiers: []RoutingTierConfig{
|
||||||
|
{Name: "fast", Model: &AgentModelConfig{Primary: "gpt-5.4-nano"}},
|
||||||
|
{Name: "tools", Model: &AgentModelConfig{Primary: "gpt-5.4-mini"}},
|
||||||
|
{Name: "heavy", Model: &AgentModelConfig{Primary: "gpt-5-mini"}},
|
||||||
|
{Name: "free", MaxScore: -1, Model: &AgentModelConfig{Primary: "openrouter-free"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate tier names",
|
||||||
|
config: &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Routing: &RoutingConfig{
|
||||||
|
Tiers: []RoutingTierConfig{
|
||||||
|
{Name: "fast", Model: &AgentModelConfig{Primary: "a"}},
|
||||||
|
{Name: "fast", Model: &AgentModelConfig{Primary: "b"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "duplicate tier name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing tier primary",
|
||||||
|
config: &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Routing: &RoutingConfig{
|
||||||
|
Tiers: []RoutingTierConfig{{Name: "fast", Model: &AgentModelConfig{}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "model.primary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown free tier reference",
|
||||||
|
config: &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Routing: &RoutingConfig{
|
||||||
|
FreeTier: "free",
|
||||||
|
Tiers: []RoutingTierConfig{
|
||||||
|
{Name: "fast", Model: &AgentModelConfig{Primary: "a"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "unknown tier",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.config.ValidateRouting()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("ValidateRouting() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Fatalf("ValidateRouting() error = %v, want substring %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
|
func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
|
||||||
jsonData := `{
|
jsonData := `{
|
||||||
"model_name": "slow-local",
|
"model_name": "slow-local",
|
||||||
|
|
|
||||||
|
|
@ -32,22 +32,9 @@ func (p *CodexCliProvider) Chat(
|
||||||
return nil, fmt.Errorf("codex command not configured")
|
return nil, fmt.Errorf("codex command not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
prompt := BuildCodexCLIPrompt(messages, tools)
|
||||||
|
|
||||||
args := []string{
|
args := BuildCodexCLIArgs(model, p.workspace)
|
||||||
"exec",
|
|
||||||
"--json",
|
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
|
||||||
"--skip-git-repo-check",
|
|
||||||
"--color", "never",
|
|
||||||
}
|
|
||||||
if model != "" && model != "codex-cli" {
|
|
||||||
args = append(args, "-m", model)
|
|
||||||
}
|
|
||||||
if p.workspace != "" {
|
|
||||||
args = append(args, "-C", p.workspace)
|
|
||||||
}
|
|
||||||
args = append(args, "-") // read prompt from stdin
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
cmd := exec.CommandContext(ctx, p.command, args...)
|
||||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
||||||
|
|
@ -62,7 +49,7 @@ func (p *CodexCliProvider) Chat(
|
||||||
// because codex writes diagnostic noise to stderr (e.g. rollout errors)
|
// because codex writes diagnostic noise to stderr (e.g. rollout errors)
|
||||||
// but still produces valid JSONL output.
|
// but still produces valid JSONL output.
|
||||||
if stdoutStr := stdout.String(); stdoutStr != "" {
|
if stdoutStr := stdout.String(); stdoutStr != "" {
|
||||||
resp, parseErr := p.parseJSONLEvents(stdoutStr)
|
resp, parseErr := ParseCodexCLIJSONLEvents(stdoutStr)
|
||||||
if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +65,7 @@ func (p *CodexCliProvider) Chat(
|
||||||
return nil, fmt.Errorf("codex cli error: %w", err)
|
return nil, fmt.Errorf("codex cli error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.parseJSONLEvents(stdout.String())
|
return ParseCodexCLIJSONLEvents(stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultModel returns the default model identifier.
|
// GetDefaultModel returns the default model identifier.
|
||||||
|
|
@ -86,9 +73,29 @@ func (p *CodexCliProvider) GetDefaultModel() string {
|
||||||
return "codex-cli"
|
return "codex-cli"
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPrompt converts messages to a prompt string for the Codex CLI.
|
// BuildCodexCLIArgs returns the standard non-interactive codex exec arguments.
|
||||||
|
func BuildCodexCLIArgs(model, workspace string) []string {
|
||||||
|
args := []string{
|
||||||
|
"exec",
|
||||||
|
"--json",
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
|
"--skip-git-repo-check",
|
||||||
|
"--color", "never",
|
||||||
|
}
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model != "" && model != "codex-cli" && !strings.EqualFold(model, "codex") {
|
||||||
|
args = append(args, "-m", model)
|
||||||
|
}
|
||||||
|
if workspace != "" {
|
||||||
|
args = append(args, "-C", workspace)
|
||||||
|
}
|
||||||
|
args = append(args, "-")
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCodexCLIPrompt converts messages to a prompt string for the Codex CLI.
|
||||||
// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.
|
// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.
|
||||||
func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
|
func BuildCodexCLIPrompt(messages []Message, tools []ToolDefinition) string {
|
||||||
var systemParts []string
|
var systemParts []string
|
||||||
var conversationParts []string
|
var conversationParts []string
|
||||||
|
|
||||||
|
|
@ -128,6 +135,12 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPrompt preserves the existing method surface for tests and callers inside
|
||||||
|
// this provider while delegating to the shared exported helper.
|
||||||
|
func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
|
||||||
|
return BuildCodexCLIPrompt(messages, tools)
|
||||||
|
}
|
||||||
|
|
||||||
// codexEvent represents a single JSONL event from `codex exec --json`.
|
// codexEvent represents a single JSONL event from `codex exec --json`.
|
||||||
type codexEvent struct {
|
type codexEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
@ -158,8 +171,8 @@ type codexEventErr struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJSONLEvents processes the JSONL output from codex exec --json.
|
// ParseCodexCLIJSONLEvents processes the JSONL output from codex exec --json.
|
||||||
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
|
func ParseCodexCLIJSONLEvents(output string) (*LLMResponse, error) {
|
||||||
var contentParts []string
|
var contentParts []string
|
||||||
var usage *UsageInfo
|
var usage *UsageInfo
|
||||||
var lastError string
|
var lastError string
|
||||||
|
|
@ -221,3 +234,7 @@ func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error)
|
||||||
Usage: usage,
|
Usage: usage,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
|
||||||
|
return ParseCodexCLIJSONLEvents(output)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,39 @@ echo '{"type":"turn.completed"}'`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodexCliProvider_MockCLI_OmitsDefaultCodexModelFlag(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := filepath.Join(tmpDir, "codex")
|
||||||
|
script := `#!/bin/bash
|
||||||
|
echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
|
||||||
|
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
||||||
|
echo '{"type":"turn.completed"}'`
|
||||||
|
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &CodexCliProvider{
|
||||||
|
command: scriptPath,
|
||||||
|
workspace: "/tmp/test-workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []Message{{Role: "user", Content: "test"}}
|
||||||
|
_, err := p.Chat(context.Background(), messages, nil, "codex", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading args: %v", err)
|
||||||
|
}
|
||||||
|
args := string(argsData)
|
||||||
|
if strings.Contains(args, "-m codex") {
|
||||||
|
t.Errorf("args should omit default codex model flag, got: %s", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
||||||
// Script that sleeps forever
|
// Script that sleeps forever
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type Classifier interface {
|
||||||
// token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex
|
// token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex
|
||||||
// token 50-200: 0.15 — medium length; may or may not be complex
|
// token 50-200: 0.15 — medium length; may or may not be complex
|
||||||
// code block present: 0.40 — coding tasks need the heavy model
|
// code block present: 0.40 — coding tasks need the heavy model
|
||||||
|
// tool intent detected: 0.30 — repo/tool prompts should leave nano early
|
||||||
// tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow
|
// tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow
|
||||||
// tool calls 1-3 (recent): 0.10 — some tool activity
|
// tool calls 1-3 (recent): 0.10 — some tool activity
|
||||||
// conversation depth > 10: 0.10 — long sessions carry implicit complexity
|
// conversation depth > 10: 0.10 — long sessions carry implicit complexity
|
||||||
|
|
@ -57,6 +58,9 @@ func (c *RuleClassifier) Score(f Features) float64 {
|
||||||
if f.CodeBlockCount > 0 {
|
if f.CodeBlockCount > 0 {
|
||||||
score += 0.40
|
score += 0.40
|
||||||
}
|
}
|
||||||
|
if f.ToolIntent {
|
||||||
|
score += 0.30
|
||||||
|
}
|
||||||
|
|
||||||
// Recent tool call density — indicates an ongoing agentic workflow
|
// Recent tool call density — indicates an ongoing agentic workflow
|
||||||
switch {
|
switch {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package routing
|
package routing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
|
@ -11,9 +12,9 @@ import (
|
||||||
// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).
|
// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).
|
||||||
const lookbackWindow = 6
|
const lookbackWindow = 6
|
||||||
|
|
||||||
// Features holds the structural signals extracted from a message and its session context.
|
// Features holds the signals extracted from a message and its session context.
|
||||||
// Every dimension is language-agnostic by construction — no keyword or pattern matching
|
// Most dimensions are structural; ToolIntent adds a narrow set of repo/tool cues
|
||||||
// against natural-language content. This ensures consistent routing for all locales.
|
// so routing can leave nano early for likely agentic turns.
|
||||||
type Features struct {
|
type Features struct {
|
||||||
// TokenEstimate is a proxy for token count.
|
// TokenEstimate is a proxy for token count.
|
||||||
// CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each.
|
// CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each.
|
||||||
|
|
@ -35,8 +36,25 @@ type Features struct {
|
||||||
// HasAttachments is true when the message appears to contain media (images,
|
// HasAttachments is true when the message appears to contain media (images,
|
||||||
// audio, video). Multi-modal inputs require vision-capable heavy models.
|
// audio, video). Multi-modal inputs require vision-capable heavy models.
|
||||||
HasAttachments bool
|
HasAttachments bool
|
||||||
|
|
||||||
|
// ToolIntent is true when the prompt strongly suggests repo/tool activity
|
||||||
|
// before any tool calls have happened in this session.
|
||||||
|
ToolIntent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pathLikePattern = regexp.MustCompile(`(?i)(?:\./|\.\./|/|(?:^|\s)(?:src|pkg|cmd|internal|test|tests|apps?)/)[^\s]*`)
|
||||||
|
fileExtPattern = regexp.MustCompile(`(?i)\.(go|ts|tsx|js|jsx|py|rs|java|json|ya?ml|toml|sh|sql|md)\b`)
|
||||||
|
actionCues = []string{
|
||||||
|
"fix", "edit", "change", "patch", "refactor", "implement", "rename", "remove", "add",
|
||||||
|
"run", "test", "build", "debug", "grep", "search", "inspect",
|
||||||
|
}
|
||||||
|
commandCues = []string{
|
||||||
|
"git ", "go ", "npm ", "pnpm ", "yarn ", "cargo ", "pytest", "python ", "uv ", "bash ", "sh ", "make ", "docker ", "kubectl ",
|
||||||
|
"$ ", "> ",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ExtractFeatures computes the structural feature vector for a message.
|
// ExtractFeatures computes the structural feature vector for a message.
|
||||||
// It is a pure function with no side effects and zero allocations beyond
|
// It is a pure function with no side effects and zero allocations beyond
|
||||||
// the returned struct.
|
// the returned struct.
|
||||||
|
|
@ -47,6 +65,7 @@ func ExtractFeatures(msg string, history []providers.Message) Features {
|
||||||
RecentToolCalls: countRecentToolCalls(history),
|
RecentToolCalls: countRecentToolCalls(history),
|
||||||
ConversationDepth: len(history),
|
ConversationDepth: len(history),
|
||||||
HasAttachments: hasAttachments(msg),
|
HasAttachments: hasAttachments(msg),
|
||||||
|
ToolIntent: hasToolIntent(msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,3 +144,36 @@ func hasAttachments(msg string) bool {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasToolIntent(msg string) bool {
|
||||||
|
lower := strings.ToLower(msg)
|
||||||
|
if lower == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code fences and shell-like snippets are strong signals on their own.
|
||||||
|
if countCodeBlocks(msg) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, cue := range commandCues {
|
||||||
|
if strings.Contains(lower, cue) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactSignal := pathLikePattern.MatchString(msg) || fileExtPattern.MatchString(msg)
|
||||||
|
if !artifactSignal {
|
||||||
|
// Inline code spans can also carry repo/tool intent when paired with an action.
|
||||||
|
artifactSignal = strings.Count(msg, "`") >= 2
|
||||||
|
}
|
||||||
|
if !artifactSignal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cue := range actionCues {
|
||||||
|
if strings.Contains(lower, cue) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package routing
|
package routing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,12 +17,18 @@ const defaultThreshold = 0.35
|
||||||
// dependency graph simple: pkg/agent resolves config → routing, not the reverse.
|
// dependency graph simple: pkg/agent resolves config → routing, not the reverse.
|
||||||
type RouterConfig struct {
|
type RouterConfig struct {
|
||||||
// LightModel is the model_name (from model_list) used for simple tasks.
|
// LightModel is the model_name (from model_list) used for simple tasks.
|
||||||
|
// Legacy field retained for backwards compatibility with the original
|
||||||
|
// binary "light vs primary" router.
|
||||||
LightModel string
|
LightModel string
|
||||||
|
|
||||||
// Threshold is the complexity score cutoff in [0, 1].
|
// Threshold is the complexity score cutoff in [0, 1].
|
||||||
// score >= Threshold → primary (heavy) model.
|
// score >= Threshold → primary (heavy) model.
|
||||||
// score < Threshold → light model.
|
// score < Threshold → light model.
|
||||||
Threshold float64
|
Threshold float64
|
||||||
|
|
||||||
|
// Tiers are evaluated in order. The first tier whose MaxScore is greater
|
||||||
|
// than or equal to the computed complexity score is selected.
|
||||||
|
Tiers []config.RoutingTierConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router selects the appropriate model tier for each incoming message.
|
// Router selects the appropriate model tier for each incoming message.
|
||||||
|
|
@ -29,6 +38,13 @@ type Router struct {
|
||||||
classifier Classifier
|
classifier Classifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultFastTierName = "fast"
|
||||||
|
DefaultHeavyTierName = "heavy"
|
||||||
|
DefaultToolsTierName = "tools"
|
||||||
|
DefaultFreeTierName = "free"
|
||||||
|
)
|
||||||
|
|
||||||
// New creates a Router with the given config and the default RuleClassifier.
|
// New creates a Router with the given config and the default RuleClassifier.
|
||||||
// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used.
|
// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used.
|
||||||
func New(cfg RouterConfig) *Router {
|
func New(cfg RouterConfig) *Router {
|
||||||
|
|
@ -63,14 +79,52 @@ func (r *Router) SelectModel(
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
primaryModel string,
|
primaryModel string,
|
||||||
) (model string, usedLight bool, score float64) {
|
) (model string, usedLight bool, score float64) {
|
||||||
features := ExtractFeatures(msg, history)
|
tier, score := r.SelectTier(msg, history)
|
||||||
score = r.classifier.Score(features)
|
if tier != "" {
|
||||||
if score < r.cfg.Threshold {
|
|
||||||
return r.cfg.LightModel, true, score
|
return r.cfg.LightModel, true, score
|
||||||
}
|
}
|
||||||
return primaryModel, false, score
|
return primaryModel, false, score
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SelectTier returns the selected routing tier name, or the empty string when
|
||||||
|
// the primary agent model should be used. When a "tools" tier exists, prompts
|
||||||
|
// with early tool intent or recent tool activity are promoted there before
|
||||||
|
// score-based tier matching runs.
|
||||||
|
func (r *Router) SelectTier(msg string, history []providers.Message) (tier string, score float64) {
|
||||||
|
features := ExtractFeatures(msg, history)
|
||||||
|
score = r.classifier.Score(features)
|
||||||
|
|
||||||
|
if len(r.cfg.Tiers) > 0 {
|
||||||
|
if (features.ToolIntent || features.RecentToolCalls > 0) && r.hasAutoTier(DefaultToolsTierName) {
|
||||||
|
return DefaultToolsTierName, score
|
||||||
|
}
|
||||||
|
for _, candidate := range r.cfg.Tiers {
|
||||||
|
name := strings.TrimSpace(candidate.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate.MaxScore < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, DefaultToolsTierName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate.MaxScore == 0 || score <= candidate.MaxScore {
|
||||||
|
return name, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", score
|
||||||
|
}
|
||||||
|
|
||||||
|
if features.ToolIntent || features.RecentToolCalls > 0 {
|
||||||
|
return "", score
|
||||||
|
}
|
||||||
|
if score < r.cfg.Threshold {
|
||||||
|
return "light", score
|
||||||
|
}
|
||||||
|
return "", score
|
||||||
|
}
|
||||||
|
|
||||||
// LightModel returns the configured light model name.
|
// LightModel returns the configured light model name.
|
||||||
func (r *Router) LightModel() string {
|
func (r *Router) LightModel() string {
|
||||||
return r.cfg.LightModel
|
return r.cfg.LightModel
|
||||||
|
|
@ -80,3 +134,12 @@ func (r *Router) LightModel() string {
|
||||||
func (r *Router) Threshold() float64 {
|
func (r *Router) Threshold() float64 {
|
||||||
return r.cfg.Threshold
|
return r.cfg.Threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) hasAutoTier(name string) bool {
|
||||||
|
for _, tier := range r.cfg.Tiers {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(tier.Name), name) && tier.MaxScore >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,6 +27,9 @@ func TestExtractFeatures_EmptyMessage(t *testing.T) {
|
||||||
if f.HasAttachments {
|
if f.HasAttachments {
|
||||||
t.Error("HasAttachments: got true, want false")
|
t.Error("HasAttachments: got true, want false")
|
||||||
}
|
}
|
||||||
|
if f.ToolIntent {
|
||||||
|
t.Error("ToolIntent: got true, want false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractFeatures_TokenEstimate(t *testing.T) {
|
func TestExtractFeatures_TokenEstimate(t *testing.T) {
|
||||||
|
|
@ -143,6 +147,24 @@ func TestExtractFeatures_HasAttachments_Extension(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractFeatures_ToolIntent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
msg string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"fix src/app.ts and run tests", true},
|
||||||
|
{"please inspect ./pkg/agent/loop.go", true},
|
||||||
|
{"git status and then patch the handler", true},
|
||||||
|
{"what's the weather today?", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
f := ExtractFeatures(tc.msg, nil)
|
||||||
|
if f.ToolIntent != tc.want {
|
||||||
|
t.Errorf("msg=%q: ToolIntent got %v, want %v", tc.msg, f.ToolIntent, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── RuleClassifier ───────────────────────────────────────────────────────────
|
// ── RuleClassifier ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRuleClassifier_ZeroFeatures(t *testing.T) {
|
func TestRuleClassifier_ZeroFeatures(t *testing.T) {
|
||||||
|
|
@ -239,6 +261,14 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRuleClassifier_ToolIntentRaisesScore(t *testing.T) {
|
||||||
|
c := &RuleClassifier{}
|
||||||
|
score := c.Score(Features{ToolIntent: true})
|
||||||
|
if score < 0.30 {
|
||||||
|
t.Fatalf("tool intent score = %f, want at least 0.30", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Router ───────────────────────────────────────────────────────────────────
|
// ── Router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRouter_DefaultThreshold(t *testing.T) {
|
func TestRouter_DefaultThreshold(t *testing.T) {
|
||||||
|
|
@ -304,9 +334,7 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
|
func TestRouter_SelectModel_DeepToolChainUsesPrimary(t *testing.T) {
|
||||||
// Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior.
|
|
||||||
// Routing is conservative: only promote to heavy when the signal is unambiguous.
|
|
||||||
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
|
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
|
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
|
||||||
|
|
@ -314,8 +342,17 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
|
||||||
}
|
}
|
||||||
msg := "ok"
|
msg := "ok"
|
||||||
_, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6")
|
_, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6")
|
||||||
if !usedLight {
|
if usedLight {
|
||||||
t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)")
|
t.Error("short message + tool history: expected primary model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_SelectModel_ToolIntentUsesPrimaryInLegacyMode(t *testing.T) {
|
||||||
|
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
|
||||||
|
msg := "fix ./pkg/agent/loop.go and run go test"
|
||||||
|
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
|
if usedLight {
|
||||||
|
t.Error("tool-intent prompt: expected primary model in legacy mode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,12 +383,12 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SelectModel_HighThreshold(t *testing.T) {
|
func TestRouter_SelectModel_HighThreshold(t *testing.T) {
|
||||||
// Very high threshold: even code blocks route to light
|
// Tool-intent prompts stay on the primary model even with a high threshold.
|
||||||
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99})
|
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99})
|
||||||
msg := "```go\nfmt.Println()\n```"
|
msg := "```go\nfmt.Println()\n```"
|
||||||
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if !usedLight {
|
if usedLight {
|
||||||
t.Error("very high threshold: code block (0.40) should route to light model")
|
t.Error("very high threshold: code block should still use primary model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,6 +399,72 @@ func TestRouter_LightModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouter_SelectTier_UsesFirstMatchingTier(t *testing.T) {
|
||||||
|
r := New(RouterConfig{
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{Name: "free", MaxScore: 0.2},
|
||||||
|
{Name: "balanced", MaxScore: 1.0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
tier, _ := r.SelectTier("hi", nil)
|
||||||
|
if tier != "free" {
|
||||||
|
t.Fatalf("tier=%q, want %q", tier, "free")
|
||||||
|
}
|
||||||
|
|
||||||
|
complex := "```go\nfmt.Println(\"x\")\n```\nPlease refactor this with tests."
|
||||||
|
tier, _ = r.SelectTier(complex, nil)
|
||||||
|
if tier != "balanced" {
|
||||||
|
t.Fatalf("tier=%q, want %q", tier, "balanced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_SelectTier_ToolIntentPrefersToolsTier(t *testing.T) {
|
||||||
|
r := New(RouterConfig{
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{Name: "fast", MaxScore: 0.2},
|
||||||
|
{Name: "tools", MaxScore: 0},
|
||||||
|
{Name: "heavy", MaxScore: 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
tier, _ := r.SelectTier("fix ./pkg/agent/loop.go and run go test", nil)
|
||||||
|
if tier != "tools" {
|
||||||
|
t.Fatalf("tier=%q, want %q", tier, "tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_SelectTier_RecentToolCallsPreferToolsTier(t *testing.T) {
|
||||||
|
r := New(RouterConfig{
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{Name: "fast", MaxScore: 0.2},
|
||||||
|
{Name: "tools", MaxScore: 0},
|
||||||
|
{Name: "heavy", MaxScore: 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}}},
|
||||||
|
}
|
||||||
|
tier, _ := r.SelectTier("ok", history)
|
||||||
|
if tier != "tools" {
|
||||||
|
t.Fatalf("tier=%q, want %q", tier, "tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_SelectTier_SkipsManualOnlyTiers(t *testing.T) {
|
||||||
|
r := New(RouterConfig{
|
||||||
|
Tiers: []config.RoutingTierConfig{
|
||||||
|
{Name: "free", MaxScore: -1},
|
||||||
|
{Name: "fast", MaxScore: 0.3},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
tier, _ := r.SelectTier("hi", nil)
|
||||||
|
if tier != "fast" {
|
||||||
|
t.Fatalf("tier=%q, want %q", tier, "fast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── newWithClassifier (internal testing hook) ─────────────────────────────────
|
// ── newWithClassifier (internal testing hook) ─────────────────────────────────
|
||||||
|
|
||||||
type fixedScoreClassifier struct{ score float64 }
|
type fixedScoreClassifier struct{ score float64 }
|
||||||
|
|
|
||||||
576
pkg/tools/git.go
Normal file
576
pkg/tools/git.go
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultGitTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// GitRunner runs git commands. It exists so tests can inject a fake runner.
|
||||||
|
type GitRunner interface {
|
||||||
|
Run(ctx context.Context, dir string, args ...string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type osGitRunner struct{}
|
||||||
|
|
||||||
|
func (osGitRunner) Run(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep git non-interactive and avoid system/global hook/config surprises.
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_TERMINAL_PROMPT=0",
|
||||||
|
"GIT_ASKPASS=",
|
||||||
|
"GIT_CONFIG_NOSYSTEM=1",
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitTool wraps the git binary with workspace-scoped path validation.
|
||||||
|
type GitTool struct {
|
||||||
|
workspace string
|
||||||
|
timeout time.Duration
|
||||||
|
restrictToWorkspace bool
|
||||||
|
allowedPathPatterns []*regexp.Regexp
|
||||||
|
runner GitRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGitTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
timeoutSeconds int,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) (*GitTool, error) {
|
||||||
|
return newGitTool(workspace, restrict, timeoutSeconds, osGitRunner{}, allowPaths...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
timeoutSeconds int,
|
||||||
|
runner GitRunner,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) (*GitTool, error) {
|
||||||
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultGitTimeout
|
||||||
|
}
|
||||||
|
if runner == nil {
|
||||||
|
runner = osGitRunner{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GitTool{
|
||||||
|
workspace: workspace,
|
||||||
|
timeout: timeout,
|
||||||
|
restrictToWorkspace: restrict,
|
||||||
|
allowedPathPatterns: patterns,
|
||||||
|
runner: runner,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) Name() string {
|
||||||
|
return "git"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) Description() string {
|
||||||
|
return "Work with git repositories using the git binary. Supports status, branch, log, diff, fetch, pull, checkout, clone, add, commit, and push. Paths are workspace-scoped when workspace restriction is enabled."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"status", "branch", "log", "diff", "fetch", "pull", "checkout", "clone", "add", "commit", "push"},
|
||||||
|
"description": "Git action to run",
|
||||||
|
},
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Working tree path or repo path for non-clone actions",
|
||||||
|
},
|
||||||
|
"destination": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Destination path for clone",
|
||||||
|
},
|
||||||
|
"url": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Clone source URL or local path",
|
||||||
|
},
|
||||||
|
"ref": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Checkout ref or branch name",
|
||||||
|
},
|
||||||
|
"remote": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Remote name for pull/push (defaults to origin)",
|
||||||
|
},
|
||||||
|
"branch": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Branch name for checkout/pull/push/clone",
|
||||||
|
},
|
||||||
|
"message": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Commit message",
|
||||||
|
},
|
||||||
|
"paths": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "string"},
|
||||||
|
"description": "File paths for add or diff",
|
||||||
|
},
|
||||||
|
"limit": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum log entries to return",
|
||||||
|
},
|
||||||
|
"cached": map[string]any{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Use staged changes for diff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if action == "" {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "status":
|
||||||
|
output, err = t.runInRepo(ctx, args, "status", "--short", "--branch")
|
||||||
|
case "branch":
|
||||||
|
output, err = t.runInRepo(ctx, args, "branch", "--show-current")
|
||||||
|
case "log":
|
||||||
|
limit, limitErr := getInt64Arg(args, "limit", 10)
|
||||||
|
if limitErr != nil {
|
||||||
|
return ErrorResult(limitErr.Error())
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
output, err = t.runInRepo(ctx, args, "log", "--oneline", "--decorate", "--graph", "-n", strconv.FormatInt(limit, 10))
|
||||||
|
case "diff":
|
||||||
|
output, err = t.runDiff(ctx, args)
|
||||||
|
case "fetch":
|
||||||
|
output, err = t.runFetch(ctx, args)
|
||||||
|
case "pull":
|
||||||
|
output, err = t.runPull(ctx, args)
|
||||||
|
case "checkout":
|
||||||
|
output, err = t.runCheckout(ctx, args)
|
||||||
|
case "clone":
|
||||||
|
output, err = t.runClone(ctx, args)
|
||||||
|
case "add":
|
||||||
|
output, err = t.runAdd(ctx, args)
|
||||||
|
case "commit":
|
||||||
|
output, err = t.runCommit(ctx, args)
|
||||||
|
case "push":
|
||||||
|
output, err = t.runPush(ctx, args)
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown git action: %s", action))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
msg := output
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
} else if !strings.Contains(msg, err.Error()) {
|
||||||
|
msg += "\n" + err.Error()
|
||||||
|
}
|
||||||
|
return ErrorResult(msg).WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if output == "" {
|
||||||
|
output = "(no output)"
|
||||||
|
}
|
||||||
|
return SilentResult(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runInRepo(ctx context.Context, args map[string]any, gitArgs ...string) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmdArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, gitArgs...)
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runDiff(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "diff"}
|
||||||
|
if cached, _ := boolArg(args, "cached"); cached {
|
||||||
|
cmdArgs = append(cmdArgs, "--cached")
|
||||||
|
}
|
||||||
|
paths, err := stringSliceArg(args, "paths")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(paths) > 0 {
|
||||||
|
cmdArgs = append(cmdArgs, "--")
|
||||||
|
cmdArgs = append(cmdArgs, paths...)
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runFetch(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remote, err := stringArg(args, "remote", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "fetch", "--prune"}
|
||||||
|
if remote != "" {
|
||||||
|
if err := rejectOptionLike(remote, "remote"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, remote)
|
||||||
|
} else {
|
||||||
|
cmdArgs = append([]string{"-c", "core.hooksPath=/dev/null", "fetch", "--all", "--prune"})
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runPull(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remote, err := stringArg(args, "remote", "origin")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
branch, err := stringArg(args, "branch", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if remote != "" {
|
||||||
|
if err := rejectOptionLike(remote, "remote"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
if err := rejectOptionLike(branch, "branch"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "pull", "--ff-only"}
|
||||||
|
if remote != "" {
|
||||||
|
cmdArgs = append(cmdArgs, remote)
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
cmdArgs = append(cmdArgs, branch)
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runCheckout(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ref, err := stringArg(args, "ref", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := rejectOptionLike(ref, "ref"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, "-c", "core.hooksPath=/dev/null", "checkout", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runClone(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
url, err := stringArg(args, "url", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := rejectOptionLike(url, "url"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := stringArg(args, "destination", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := rejectOptionLike(dest, "destination"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resolvedDest, err := t.resolvePath(dest, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("clone destination blocked by safety guard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
branch, err := stringArg(args, "branch", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
if err := rejectOptionLike(branch, "branch"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depth, err := getInt64Arg(args, "depth", 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "clone"}
|
||||||
|
if branch != "" {
|
||||||
|
cmdArgs = append(cmdArgs, "--branch", branch)
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
cmdArgs = append(cmdArgs, "--depth", strconv.FormatInt(depth, 10))
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, url, resolvedDest)
|
||||||
|
|
||||||
|
return t.runGit(ctx, t.workspace, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runAdd(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
paths, err := stringSliceArg(args, "paths")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return "", fmt.Errorf("paths is required")
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := rejectOptionLike(p, "path"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdArgs := append([]string{"-c", "core.hooksPath=/dev/null", "add", "--"}, paths...)
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runCommit(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
message, err := stringArg(args, "message", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message) == "" {
|
||||||
|
return "", fmt.Errorf("message is required")
|
||||||
|
}
|
||||||
|
if err := rejectOptionLike(message, "message"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, "-c", "core.hooksPath=/dev/null", "commit", "-m", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runPush(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
repoDir, err := t.resolveRepoDir(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remote, err := stringArg(args, "remote", "origin")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
branch, err := stringArg(args, "branch", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if remote != "" {
|
||||||
|
if err := rejectOptionLike(remote, "remote"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
if err := rejectOptionLike(branch, "branch"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "push"}
|
||||||
|
if remote != "" {
|
||||||
|
cmdArgs = append(cmdArgs, remote)
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
cmdArgs = append(cmdArgs, branch)
|
||||||
|
}
|
||||||
|
return t.runGit(ctx, repoDir, cmdArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) resolveRepoDir(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
path, err := stringArg(args, "path", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resolved, err := t.resolvePath(path, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := t.repoRoot(ctx, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) resolvePath(path string, allowMissing bool) (string, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
if t.workspace == "" {
|
||||||
|
return "", fmt.Errorf("workspace is not defined")
|
||||||
|
}
|
||||||
|
return filepath.Clean(t.workspace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrictToWorkspace, t.allowedPathPatterns)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowMissing {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
return filepath.Dir(resolved), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) repoRoot(ctx context.Context, dir string) (string, error) {
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return "", fmt.Errorf("repository path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := t.runner.Run(cmdCtx, dir, "-c", "core.hooksPath=/dev/null", "rev-parse", "--show-toplevel")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return "", fmt.Errorf("git command timed out after %s", t.timeout)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("not a git repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := strings.TrimSpace(out)
|
||||||
|
if root == "" {
|
||||||
|
return "", fmt.Errorf("not a git repository")
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitTool) runGit(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := t.runner.Run(cmdCtx, dir, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return strings.TrimSpace(out), fmt.Errorf("git command timed out after %s", t.timeout)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringArg(args map[string]any, key, defaultVal string) (string, error) {
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists || raw == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolArg(args map[string]any, key string) (bool, error) {
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists || raw == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return v, nil
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(v), "true"), nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("%s must be a boolean", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceArg(args map[string]any, key string) ([]string, error) {
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists || raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
return v, nil
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
s, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must contain only strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%s must be an array of strings", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectOptionLike(value, field string) error {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%s is required", field)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(value), "-") {
|
||||||
|
return fmt.Errorf("%s cannot start with '-'", field)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
840
pkg/tools/github.go
Normal file
840
pkg/tools/github.go
Normal file
|
|
@ -0,0 +1,840 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultGitHubBaseURL = "https://api.github.com"
|
||||||
|
defaultGitHubTimeout = 20 * time.Second
|
||||||
|
defaultGitHubAPIVersion = "2022-11-28"
|
||||||
|
defaultGitHubListLimit = int64(10)
|
||||||
|
defaultGitHubMaxChars = int64(12000)
|
||||||
|
maxGitHubListLimit = int64(100)
|
||||||
|
maxGitHubContentChars = int64(40000)
|
||||||
|
maxGitHubLogsZipBytes = int64(15 * 1024 * 1024)
|
||||||
|
gitHubToolUserAgent = "picoclaw-github-tool/1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitHubTool struct {
|
||||||
|
client *http.Client
|
||||||
|
baseURL string
|
||||||
|
apiVersion string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubUser struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
PublicRepos int `json:"public_repos"`
|
||||||
|
TotalPrivateRepos int `json:"total_private_repos"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Following int `json:"following"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubRepo struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
Fork bool `json:"fork"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
PushedAt string `json:"pushed_at"`
|
||||||
|
OpenIssues int `json:"open_issues_count"`
|
||||||
|
Watchers int `json:"watchers_count"`
|
||||||
|
Stargazers int `json:"stargazers_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubCommit struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Commit struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Author struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
} `json:"author"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubBranch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
Commit struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubContent struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubWorkflowRun struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayTitle string `json:"display_title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
HeadBranch string `json:"head_branch"`
|
||||||
|
HeadSHA string `json:"head_sha"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubWorkflowRunsResponse struct {
|
||||||
|
WorkflowRuns []gitHubWorkflowRun `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGitHubTool(token, baseURL, proxy string, timeoutSeconds int) (*GitHubTool, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(os.Getenv("GITHUB_MCP_PAT"))
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("github token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultGitHubTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := utils.CreateHTTPClient(proxy, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GitHub HTTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultGitHubBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GitHubTool{
|
||||||
|
client: client,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
apiVersion: defaultGitHubAPIVersion,
|
||||||
|
token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) Name() string {
|
||||||
|
return "github"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) Description() string {
|
||||||
|
return "Inspect GitHub via the REST API. Supports account lookup, repo listing, repo summaries, branches, directory listings, file reads, workflow runs, and workflow run logs."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{
|
||||||
|
"me",
|
||||||
|
"my_repos",
|
||||||
|
"repo_summary",
|
||||||
|
"list_branches",
|
||||||
|
"list_directory",
|
||||||
|
"get_file",
|
||||||
|
"list_workflow_runs",
|
||||||
|
"get_workflow_run_logs",
|
||||||
|
},
|
||||||
|
"description": "GitHub action to run",
|
||||||
|
},
|
||||||
|
"repo": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Repository in owner/repo format",
|
||||||
|
},
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Repository file or directory path for content actions",
|
||||||
|
},
|
||||||
|
"ref": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Branch, tag, or commit for content actions",
|
||||||
|
},
|
||||||
|
"limit": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results to return",
|
||||||
|
},
|
||||||
|
"visibility": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"all", "public", "private"},
|
||||||
|
"description": "Visibility filter for my_repos",
|
||||||
|
},
|
||||||
|
"affiliation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Affiliation filter for my_repos, for example owner,collaborator,organization_member",
|
||||||
|
},
|
||||||
|
"branch": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Branch filter for workflow runs",
|
||||||
|
},
|
||||||
|
"status": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Workflow status filter, for example queued, in_progress, completed, success, failure",
|
||||||
|
},
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Workflow event filter, for example push or pull_request",
|
||||||
|
},
|
||||||
|
"run_id": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Workflow run ID for log retrieval",
|
||||||
|
},
|
||||||
|
"max_chars": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum characters to return for file content or workflow logs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if action == "" {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "me":
|
||||||
|
output, err = t.me(ctx)
|
||||||
|
case "my_repos":
|
||||||
|
output, err = t.myRepos(ctx, args)
|
||||||
|
case "repo_summary":
|
||||||
|
output, err = t.repoSummary(ctx, args)
|
||||||
|
case "list_branches":
|
||||||
|
output, err = t.listBranches(ctx, args)
|
||||||
|
case "list_directory":
|
||||||
|
output, err = t.listDirectory(ctx, args)
|
||||||
|
case "get_file":
|
||||||
|
output, err = t.getFile(ctx, args)
|
||||||
|
case "list_workflow_runs":
|
||||||
|
output, err = t.listWorkflowRuns(ctx, args)
|
||||||
|
case "get_workflow_run_logs":
|
||||||
|
output, err = t.getWorkflowRunLogs(ctx, args)
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown github action: %s", action))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
return SilentResult(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) me(ctx context.Context) (string, error) {
|
||||||
|
var user gitHubUser
|
||||||
|
if err := t.getJSON(ctx, "/user", nil, &user); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("Authenticated GitHub user: %s", user.Login),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(user.Name) != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Name: %s", user.Name))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(user.HTMLURL) != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Profile: %s", user.HTMLURL))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(user.Bio) != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Bio: %s", user.Bio))
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
fmt.Sprintf("Public repos: %d", user.PublicRepos),
|
||||||
|
fmt.Sprintf("Private repos visible to token: %d", user.TotalPrivateRepos),
|
||||||
|
fmt.Sprintf("Followers: %d", user.Followers),
|
||||||
|
fmt.Sprintf("Following: %d", user.Following),
|
||||||
|
)
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) myRepos(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
visibility, err := stringArg(args, "visibility", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
affiliation, err := stringArg(args, "affiliation", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("per_page", fmt.Sprintf("%d", limit))
|
||||||
|
query.Set("sort", "updated")
|
||||||
|
query.Set("direction", "desc")
|
||||||
|
if visibility != "" {
|
||||||
|
query.Set("visibility", visibility)
|
||||||
|
}
|
||||||
|
if affiliation != "" {
|
||||||
|
query.Set("affiliation", affiliation)
|
||||||
|
}
|
||||||
|
|
||||||
|
var repos []gitHubRepo
|
||||||
|
if err := t.getJSON(ctx, "/user/repos", query, &repos); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
return "No repositories matched the current filters.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("Repositories (%d):", len(repos))}
|
||||||
|
for _, repo := range repos {
|
||||||
|
visibilityLabel := "public"
|
||||||
|
if repo.Private {
|
||||||
|
visibilityLabel = "private"
|
||||||
|
}
|
||||||
|
extras := []string{visibilityLabel}
|
||||||
|
if repo.Archived {
|
||||||
|
extras = append(extras, "archived")
|
||||||
|
}
|
||||||
|
if repo.Fork {
|
||||||
|
extras = append(extras, "fork")
|
||||||
|
}
|
||||||
|
meta := strings.Join(extras, ", ")
|
||||||
|
if repo.Language != "" {
|
||||||
|
meta += ", " + repo.Language
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s [%s]", repo.FullName, meta))
|
||||||
|
if repo.Description != "" {
|
||||||
|
lines = append(lines, " "+repo.Description)
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(" default=%s updated=%s", repo.DefaultBranch, repo.UpdatedAt))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) repoSummary(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo gitHubRepo
|
||||||
|
if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s", owner, repoName), nil, &repo); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
languages := map[string]int{}
|
||||||
|
_ = t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/languages", owner, repoName), nil, &languages)
|
||||||
|
|
||||||
|
var commits []gitHubCommit
|
||||||
|
commitsQuery := url.Values{}
|
||||||
|
commitsQuery.Set("per_page", "5")
|
||||||
|
_ = t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/commits", owner, repoName), commitsQuery, &commits)
|
||||||
|
|
||||||
|
visibilityLabel := "public"
|
||||||
|
if repo.Private {
|
||||||
|
visibilityLabel = "private"
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("Repository: %s", repo.FullName),
|
||||||
|
fmt.Sprintf("URL: %s", repo.HTMLURL),
|
||||||
|
fmt.Sprintf("Visibility: %s", visibilityLabel),
|
||||||
|
fmt.Sprintf("Default branch: %s", repo.DefaultBranch),
|
||||||
|
fmt.Sprintf("Archived: %t", repo.Archived),
|
||||||
|
fmt.Sprintf("Open issues: %d", repo.OpenIssues),
|
||||||
|
fmt.Sprintf("Stars: %d", repo.Stargazers),
|
||||||
|
fmt.Sprintf("Forks: %d", repo.Forks),
|
||||||
|
fmt.Sprintf("Watchers: %d", repo.Watchers),
|
||||||
|
fmt.Sprintf("Last push: %s", repo.PushedAt),
|
||||||
|
}
|
||||||
|
if repo.Description != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Description: %s", repo.Description))
|
||||||
|
}
|
||||||
|
if len(languages) > 0 {
|
||||||
|
lines = append(lines, "Languages: "+formatLanguageBreakdown(languages))
|
||||||
|
}
|
||||||
|
if len(commits) > 0 {
|
||||||
|
lines = append(lines, "Recent commits:")
|
||||||
|
for _, commit := range commits {
|
||||||
|
subject := strings.TrimSpace(strings.Split(commit.Commit.Message, "\n")[0])
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s %s (%s, %s)",
|
||||||
|
shortSHA(commit.SHA), subject, commit.Commit.Author.Name, commit.Commit.Author.Date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) listBranches(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("per_page", fmt.Sprintf("%d", limit))
|
||||||
|
|
||||||
|
var branches []gitHubBranch
|
||||||
|
if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/branches", owner, repoName), query, &branches); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(branches) == 0 {
|
||||||
|
return fmt.Sprintf("No branches returned for %s/%s.", owner, repoName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("Branches for %s/%s:", owner, repoName)}
|
||||||
|
for _, branch := range branches {
|
||||||
|
protected := "unprotected"
|
||||||
|
if branch.Protected {
|
||||||
|
protected = "protected"
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s [%s] %s", branch.Name, protected, shortSHA(branch.Commit.SHA)))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) listDirectory(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ref, err := stringArg(args, "ref", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pathArg, err := stringArg(args, "path", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := t.getContentRaw(ctx, owner, repoName, pathArg, ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []gitHubContent
|
||||||
|
if err := json.Unmarshal(body, &items); err != nil {
|
||||||
|
var file gitHubContent
|
||||||
|
if err2 := json.Unmarshal(body, &file); err2 == nil && file.Type == "file" {
|
||||||
|
return "", fmt.Errorf("%s is a file; use get_file instead", displayRepoPath(pathArg))
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unexpected GitHub contents response: %w", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return fmt.Sprintf("Directory %s is empty.", displayRepoPath(pathArg)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].Type != items[j].Type {
|
||||||
|
return items[i].Type == "dir"
|
||||||
|
}
|
||||||
|
return items[i].Name < items[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("Directory listing for %s/%s:%s", owner, repoName, formatRepoPathSuffix(pathArg))}
|
||||||
|
for _, item := range items {
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s %s (%d bytes)", item.Type, item.Path, item.Size))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) getFile(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pathArg, err := stringArg(args, "path", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pathArg) == "" {
|
||||||
|
return "", fmt.Errorf("path is required")
|
||||||
|
}
|
||||||
|
ref, err := stringArg(args, "ref", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
maxChars, err := getBoundedIntArg(args, "max_chars", defaultGitHubMaxChars, 256, maxGitHubContentChars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := t.getContentRaw(ctx, owner, repoName, pathArg, ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var file gitHubContent
|
||||||
|
if err := json.Unmarshal(body, &file); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode file response: %w", err)
|
||||||
|
}
|
||||||
|
if file.Type != "file" {
|
||||||
|
return "", fmt.Errorf("%s is not a file", displayRepoPath(pathArg))
|
||||||
|
}
|
||||||
|
if file.Encoding != "base64" {
|
||||||
|
return "", fmt.Errorf("unsupported file encoding %q for %s", file.Encoding, pathArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(file.Content, "\n", ""))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode file content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := normalizeGitHubText(string(decoded))
|
||||||
|
truncated := false
|
||||||
|
if int64(len(text)) > maxChars {
|
||||||
|
text = text[:maxChars]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("File: %s", file.Path),
|
||||||
|
fmt.Sprintf("Repository: %s/%s", owner, repoName),
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Ref: %s", ref))
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("Size: %d bytes", file.Size))
|
||||||
|
if truncated {
|
||||||
|
lines = append(lines, fmt.Sprintf("Content truncated to %d characters.", maxChars))
|
||||||
|
}
|
||||||
|
lines = append(lines, "", text)
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) listWorkflowRuns(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
branch, err := stringArg(args, "branch", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
status, err := stringArg(args, "status", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
event, err := stringArg(args, "event", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("per_page", fmt.Sprintf("%d", limit))
|
||||||
|
if branch != "" {
|
||||||
|
query.Set("branch", branch)
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
query.Set("status", status)
|
||||||
|
}
|
||||||
|
if event != "" {
|
||||||
|
query.Set("event", event)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp gitHubWorkflowRunsResponse
|
||||||
|
if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/actions/runs", owner, repoName), query, &resp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(resp.WorkflowRuns) == 0 {
|
||||||
|
return fmt.Sprintf("No workflow runs matched for %s/%s.", owner, repoName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("Workflow runs for %s/%s:", owner, repoName)}
|
||||||
|
for _, run := range resp.WorkflowRuns {
|
||||||
|
title := strings.TrimSpace(run.DisplayTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = run.Name
|
||||||
|
}
|
||||||
|
statusText := run.Status
|
||||||
|
if run.Conclusion != "" {
|
||||||
|
statusText += "/" + run.Conclusion
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- #%d %s [%s] branch=%s sha=%s event=%s updated=%s",
|
||||||
|
run.ID, title, statusText, run.HeadBranch, shortSHA(run.HeadSHA), run.Event, run.UpdatedAt))
|
||||||
|
if run.HTMLURL != "" {
|
||||||
|
lines = append(lines, " "+run.HTMLURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) getWorkflowRunLogs(ctx context.Context, args map[string]any) (string, error) {
|
||||||
|
owner, repoName, err := repoArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
runID, err := getBoundedIntArg(args, "run_id", 0, 1, 1<<62)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
maxChars, err := getBoundedIntArg(args, "max_chars", defaultGitHubMaxChars, 512, maxGitHubContentChars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := t.getBytes(ctx, fmt.Sprintf("/repos/%s/%s/actions/runs/%d/logs", owner, repoName, runID), nil, maxGitHubLogsZipBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read workflow logs archive: %w", err)
|
||||||
|
}
|
||||||
|
if len(reader.File) == 0 {
|
||||||
|
return "", fmt.Errorf("workflow logs archive is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(reader.File, func(i, j int) bool {
|
||||||
|
return reader.File[i].Name < reader.File[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Workflow logs for %s/%s run #%d\n", owner, repoName, runID)
|
||||||
|
remaining := maxChars - int64(b.Len())
|
||||||
|
if remaining <= 0 {
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open workflow log %s: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
entryBytes, readErr := io.ReadAll(io.LimitReader(rc, remaining+1))
|
||||||
|
rc.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return "", fmt.Errorf("failed to read workflow log %s: %w", file.Name, readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryText := normalizeGitHubText(string(entryBytes))
|
||||||
|
entryHeader := fmt.Sprintf("\n=== %s ===\n", file.Name)
|
||||||
|
if int64(len(entryHeader)) >= remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(entryHeader)
|
||||||
|
remaining = maxChars - int64(b.Len())
|
||||||
|
|
||||||
|
truncated := int64(len(entryText)) > remaining
|
||||||
|
if truncated {
|
||||||
|
entryText = entryText[:remaining]
|
||||||
|
}
|
||||||
|
b.WriteString(entryText)
|
||||||
|
if truncated {
|
||||||
|
b.WriteString("\n[truncated]\n")
|
||||||
|
}
|
||||||
|
remaining = maxChars - int64(b.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxChars-int64(b.Len()) <= 0 {
|
||||||
|
b.WriteString("\n[overall log output truncated]\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) getJSON(ctx context.Context, apiPath string, query url.Values, dst any) error {
|
||||||
|
body, err := t.getBytes(ctx, apiPath, query, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, dst); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode GitHub response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) getContentRaw(ctx context.Context, owner, repoName, contentPath, ref string) ([]byte, error) {
|
||||||
|
relPath := fmt.Sprintf("/repos/%s/%s/contents", owner, repoName)
|
||||||
|
if trimmed := strings.Trim(contentPath, "/"); trimmed != "" {
|
||||||
|
escaped := make([]string, 0, len(strings.Split(trimmed, "/")))
|
||||||
|
for _, part := range strings.Split(trimmed, "/") {
|
||||||
|
escaped = append(escaped, url.PathEscape(part))
|
||||||
|
}
|
||||||
|
relPath += "/" + strings.Join(escaped, "/")
|
||||||
|
}
|
||||||
|
query := url.Values{}
|
||||||
|
if ref != "" {
|
||||||
|
query.Set("ref", ref)
|
||||||
|
}
|
||||||
|
return t.getBytes(ctx, relPath, query, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) getBytes(ctx context.Context, apiPath string, query url.Values, maxBytes int64) ([]byte, error) {
|
||||||
|
req, err := t.newRequest(ctx, apiPath, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := utils.DoRequestWithRetry(t.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GitHub request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("GitHub API %s returned %d: %s", apiPath, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := io.Reader(resp.Body)
|
||||||
|
if maxBytes > 0 {
|
||||||
|
reader = io.LimitReader(resp.Body, maxBytes+1)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read GitHub response: %w", err)
|
||||||
|
}
|
||||||
|
if maxBytes > 0 && int64(len(body)) > maxBytes {
|
||||||
|
return nil, fmt.Errorf("GitHub response exceeded %d bytes", maxBytes)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GitHubTool) newRequest(ctx context.Context, apiPath string, query url.Values) (*http.Request, error) {
|
||||||
|
fullURL := t.baseURL + apiPath
|
||||||
|
if len(query) > 0 {
|
||||||
|
fullURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GitHub request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||||
|
req.Header.Set("User-Agent", gitHubToolUserAgent)
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", t.apiVersion)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoArg(args map[string]any) (string, string, error) {
|
||||||
|
repo, err := stringArg(args, "repo", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(repo) == "" {
|
||||||
|
return "", "", fmt.Errorf("repo is required")
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(repo, "/"), "/")
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", "", fmt.Errorf("repo must be in owner/repo format")
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoundedIntArg(args map[string]any, key string, defaultVal, minVal, maxVal int64) (int64, error) {
|
||||||
|
value, err := getInt64Arg(args, key, defaultVal)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if value < minVal {
|
||||||
|
return 0, fmt.Errorf("%s must be at least %d", key, minVal)
|
||||||
|
}
|
||||||
|
if value > maxVal {
|
||||||
|
return maxVal, nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLanguageBreakdown(languages map[string]int) string {
|
||||||
|
type item struct {
|
||||||
|
Name string
|
||||||
|
Bytes int
|
||||||
|
}
|
||||||
|
items := make([]item, 0, len(languages))
|
||||||
|
total := 0
|
||||||
|
for name, count := range languages {
|
||||||
|
items = append(items, item{Name: name, Bytes: count})
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].Bytes > items[j].Bytes
|
||||||
|
})
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if total == 0 {
|
||||||
|
parts = append(parts, item.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %.1f%%", item.Name, (float64(item.Bytes)/float64(total))*100))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortSHA(sha string) string {
|
||||||
|
if len(sha) > 7 {
|
||||||
|
return sha[:7]
|
||||||
|
}
|
||||||
|
return sha
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayRepoPath(path string) string {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRepoPathSuffix(path string) string {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ":" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGitHubText(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
175
pkg/tools/github_test.go
Normal file
175
pkg/tools/github_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGitHubTool_UsesEnvFallback(t *testing.T) {
|
||||||
|
t.Setenv("GITHUB_MCP_PAT", "env-token")
|
||||||
|
|
||||||
|
tool, err := NewGitHubTool("", "", "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitHubTool() error = %v", err)
|
||||||
|
}
|
||||||
|
if tool.token != "env-token" {
|
||||||
|
t.Fatalf("tool.token = %q, want env-token", tool.token)
|
||||||
|
}
|
||||||
|
if tool.baseURL != defaultGitHubBaseURL {
|
||||||
|
t.Fatalf("tool.baseURL = %q, want %q", tool.baseURL, defaultGitHubBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubTool_Me(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/user" {
|
||||||
|
t.Fatalf("path = %q, want /user", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Fatalf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-GitHub-Api-Version"); got != defaultGitHubAPIVersion {
|
||||||
|
t.Fatalf("X-GitHub-Api-Version = %q", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{
|
||||||
|
"login":"Skezza",
|
||||||
|
"name":"Joe",
|
||||||
|
"html_url":"https://github.com/Skezza",
|
||||||
|
"bio":"Builder",
|
||||||
|
"public_repos":12,
|
||||||
|
"total_private_repos":5,
|
||||||
|
"followers":3,
|
||||||
|
"following":4
|
||||||
|
}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewGitHubTool("test-token", server.URL, "", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitHubTool() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"action": "me"})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !result.Silent {
|
||||||
|
t.Fatal("me should be silent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Authenticated GitHub user: Skezza") {
|
||||||
|
t.Fatalf("ForLLM = %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubTool_GetFile(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if want := "/repos/Skezza/picoclaw/contents/docs/readme.md"; r.URL.Path != want {
|
||||||
|
t.Fatalf("path = %q, want %q", r.URL.Path, want)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("ref"); got != "main" {
|
||||||
|
t.Fatalf("ref query = %q, want main", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{
|
||||||
|
"type":"file",
|
||||||
|
"path":"docs/readme.md",
|
||||||
|
"size":24,
|
||||||
|
"encoding":"base64",
|
||||||
|
"content":"SGVsbG8gZnJvbSBHaXRIdWIgZmlsZSEK"
|
||||||
|
}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewGitHubTool("test-token", server.URL, "", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitHubTool() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "get_file",
|
||||||
|
"repo": "Skezza/picoclaw",
|
||||||
|
"path": "docs/readme.md",
|
||||||
|
"ref": "main",
|
||||||
|
"max_chars": 512,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "File: docs/readme.md") {
|
||||||
|
t.Fatalf("ForLLM = %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Hello from GitHub file!") {
|
||||||
|
t.Fatalf("ForLLM missing decoded content: %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubTool_GetWorkflowRunLogs(t *testing.T) {
|
||||||
|
var zipBuf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&zipBuf)
|
||||||
|
fw, err := zw.Create("build/1_Setup.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte("setup complete\n")); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
fw, err = zw.Create("build/2_Test.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte("tests passed\n")); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if want := "/repos/Skezza/picoclaw/actions/runs/42/logs"; r.URL.Path != want {
|
||||||
|
t.Fatalf("path = %q, want %q", r.URL.Path, want)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
if _, err := w.Write(zipBuf.Bytes()); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewGitHubTool("test-token", server.URL, "", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitHubTool() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "get_workflow_run_logs",
|
||||||
|
"repo": "Skezza/picoclaw",
|
||||||
|
"run_id": 42,
|
||||||
|
"max_chars": 4096,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Execute() unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Workflow logs for Skezza/picoclaw run #42") {
|
||||||
|
t.Fatalf("ForLLM = %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "setup complete") || !strings.Contains(result.ForLLM, "tests passed") {
|
||||||
|
t.Fatalf("ForLLM missing log contents: %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGitHubTool_RequiresToken(t *testing.T) {
|
||||||
|
_ = os.Unsetenv("GITHUB_MCP_PAT")
|
||||||
|
_ = os.Unsetenv("GITHUB_TOKEN")
|
||||||
|
|
||||||
|
if _, err := NewGitHubTool("", "", "", 0); err == nil {
|
||||||
|
t.Fatal("expected missing token error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue