feat(codex): add telegram planner-executor flow

This commit is contained in:
Joe Skerratt 2026-04-06 20:51:27 +01:00
parent 33cf828760
commit 36b48d6027
28 changed files with 9041 additions and 175 deletions

603
pkg/agent/codex_executor.go Normal file
View 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
View 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()
}

View 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
View 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
}

View file

@ -42,17 +42,27 @@ type AgentInstance struct {
Candidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model
// was successfully resolved. It scores each incoming message and decides
// whether to route to LightCandidates or stay with Candidates.
// or tier chain was successfully resolved. It scores each incoming message
// and decides whether to route to a named tier or stay with Candidates.
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.
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
// Legacy compatibility field for binary routing configs.
LightCandidates []providers.FallbackCandidate
// 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
}
type ResolvedRouteTier struct {
Name string
Primary string
Candidates []providers.FallbackCandidate
Provider providers.LLMProvider
ThinkingLevel ThinkingLevel
}
// NewAgentInstance creates an agent instance from config.
func NewAgentInstance(
agentCfg *config.AgentConfig,
@ -99,6 +109,30 @@ func NewAgentInstance(
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") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
@ -175,35 +209,29 @@ func NewAgentInstance(
// Resolve fallback candidates
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
// Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message.
// Model routing setup: pre-resolve routing tiers at creation time to avoid
// repeated model_list lookups on every incoming message.
var router *routing.Router
var routeTiers map[string]*ResolvedRouteTier
var lightCandidates []providers.FallbackCandidate
var lightProvider providers.LLMProvider
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)
if len(resolved) > 0 {
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
if err != nil {
logger.WarnCF("agent", "Routing light model config invalid; routing disabled",
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
} else {
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
if err != nil {
logger.WarnCF("agent", "Routing light model provider init failed; routing disabled",
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
} else {
router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel,
Threshold: rc.Threshold,
})
lightCandidates = resolved
lightProvider = lp
}
if rc := defaults.Routing; rc != nil && rc.Enabled {
routeTiers = resolveRouteTiers(cfg, defaults, workspace, agentID, rc)
if len(routeTiers) > 0 || rc.LightModel != "" {
router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel,
Threshold: rc.Threshold,
Tiers: rc.Tiers,
})
}
if legacyLight := routeTiers["light"]; legacyLight != nil {
lightCandidates = legacyLight.Candidates
lightProvider = legacyLight.Provider
} else if freeTier := strings.TrimSpace(defaults.Routing.FreeTier); freeTier != "" {
if resolved := routeTiers[freeTier]; resolved != nil {
lightCandidates = resolved.Candidates
lightProvider = resolved.Provider
}
} 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,
Candidates: candidates,
Router: router,
RouteTiers: routeTiers,
LightCandidates: lightCandidates,
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.
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
@ -73,6 +74,9 @@ func newStartedTestChannelManager(
type recordingProvider struct {
lastMessages []providers.Message
lastTools []providers.ToolDefinition
responseText string
callCount int
}
func (r *recordingProvider) Chat(
@ -82,9 +86,15 @@ func (r *recordingProvider) Chat(
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
r.callCount++
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{
Content: "Mock response",
Content: content,
ToolCalls: []providers.ToolCall{},
}, 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) {
tmpDir := t.TempDir()
cfg := &config.Config{
@ -741,6 +1228,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
cfg.Agents.Defaults.ModelName = "test-model"
cfg.Agents.Defaults.MaxTokens = 4096
cfg.Agents.Defaults.MaxToolIterations = 10
cfg.Agents.Defaults.Routing = nil
msgBus := bus.NewMessageBus()
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
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -2226,7 +2819,8 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
ID: "cron",
},
})
history := defaultAgent.Sessions.GetHistory(route.SessionKey)
scopeKey := resolveScopeKey(route, "tool-limit")
history := defaultAgent.Sessions.GetHistory(scopeKey)
if len(history) != 4 {
t.Fatalf("history len = %d, want 4", len(history))
}

View file

@ -8,6 +8,8 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
const routingTierRefPrefix = "tier:"
func ensureProtocolModel(model string) string {
model = strings.TrimSpace(model)
if model == "" {
@ -168,3 +170,23 @@ func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*conf
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
}

View file

@ -15,6 +15,10 @@ func BuiltinDefinitions() []Definition {
boostCommand(),
paidCommand(),
freeCommand(),
codeCommand(),
codexCommand(),
routeCommand(),
defaultCommand(),
statusCommand(),
checkCommand(),
clearCommand(),

1103
pkg/commands/cmd_codex.go Normal file

File diff suppressed because it is too large Load diff

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

View file

@ -4,25 +4,32 @@ import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
func boostCommand() Definition {
return Definition{
Name: "boost",
Description: "Use the paid model for your next message",
Description: "Use the heavy routed model for your next message",
Usage: "/boost",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
paidModel, _ := sessionModelNames(rt)
if paidModel == "" {
return req.Reply("Boost unavailable: paid model is not configured.")
targets := sessionModeTargets(rt)
if targets.Heavy.Target == "" {
return req.Reply("Boost unavailable: heavy routing tier is not configured.")
}
if rt == nil || rt.ArmNextModelMode == nil {
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(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 {
return Definition{
Name: "paid",
Description: "Use the paid model for this session",
Description: "Legacy alias for the heavy routed model",
Usage: "/paid",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
paidModel, _ := sessionModelNames(rt)
if paidModel == "" {
return req.Reply("Paid mode unavailable: primary model is not configured.")
targets := sessionModeTargets(rt)
if targets.Heavy.Target == "" {
return req.Reply("Paid mode unavailable: heavy routing tier is not configured.")
}
if rt == nil || rt.SetSessionModelMode == nil {
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(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 {
return Definition{
Name: "free",
Description: "Use the free model for this session",
Description: "Use the manual free tier for this session",
Usage: "/free",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
_, freeModel := sessionModelNames(rt)
if freeModel == "" {
return req.Reply("Free mode unavailable: light model is not configured.")
targets := sessionModeTargets(rt)
if targets.Free.Target == "" {
return req.Reply("Free mode unavailable: free tier is not configured.")
}
if rt == nil || rt.SetSessionModelMode == nil {
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(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()
}
paidModel, freeModel := sessionModelNames(rt)
targets := sessionModeTargets(rt)
workMode := ""
if rt.GetSessionWorkMode != nil {
workMode = strings.TrimSpace(rt.GetSessionWorkMode())
}
persistent, pending := "", ""
if rt.GetSessionModelMode != nil {
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("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 != "" {
lines = append(lines, fmt.Sprintf("Pending Boost: %s", pending))
lines = append(lines, fmt.Sprintf("Pending Boost: %s", sessionModeLabel(pending, targets)))
} else {
lines = append(lines, "Pending Boost: none")
}
if paidModel != "" {
lines = append(lines, fmt.Sprintf("Paid Model: %s", paidModel))
if targets.Fast.Label != "" {
lines = append(lines, fmt.Sprintf("Fast Model: %s", targets.Fast.Label))
}
if freeModel != "" {
lines = append(lines, fmt.Sprintf("Free Model: %s", freeModel))
if targets.Heavy.Label != "" {
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"))
},
}
}
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 {
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 "", ""
}
paidModel = strings.TrimSpace(rt.Config.Agents.Defaults.ModelName)
if rt.Config.Agents.Defaults.Routing != nil {
freeModel = strings.TrimSpace(rt.Config.Agents.Defaults.Routing.LightModel)
for _, mode := range modes {
target := ""
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 != "" {
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 == "" {
return "route (default)"
}
if paidModel != "" && strings.EqualFold(persistent, paidModel) {
return fmt.Sprintf("paid (%s)", persistent)
if targets.Heavy.Target != "" && strings.EqualFold(persistent, targets.Heavy.Target) {
return fmt.Sprintf("heavy (%s)", targets.Heavy.Label)
}
if freeModel != "" && strings.EqualFold(persistent, freeModel) {
return fmt.Sprintf("free (%s)", persistent)
if targets.Tools.Target != "" && strings.EqualFold(persistent, targets.Tools.Target) {
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)
}

View file

@ -13,9 +13,36 @@ func newModeTestRuntime() *Runtime {
Config: &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-5.4-mini",
ModelName: "gpt-5-mini",
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 {
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 armed != "tier:heavy" {
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)
}
}
@ -55,10 +82,15 @@ func TestBoostCommand_ArmsNextModel(t *testing.T) {
func TestPaidCommand_SetsPersistentModel(t *testing.T) {
rt := newModeTestRuntime()
var persistent string
workMode := "code"
rt.SetSessionModelMode = func(value string) error {
persistent = value
return nil
}
rt.ClearSessionWorkMode = func() error {
workMode = ""
return nil
}
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
@ -73,21 +105,65 @@ func TestPaidCommand_SetsPersistentModel(t *testing.T) {
if res.Outcome != OutcomeHandled {
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if persistent != "gpt-5.4-mini" {
t.Fatalf("persistent=%q, want %q", persistent, "gpt-5.4-mini")
if persistent != "tier:heavy" {
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)
}
}
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) {
rt := newModeTestRuntime()
var persistent string
workMode := "code"
rt.SetSessionModelMode = func(value string) error {
persistent = value
return nil
}
rt.ClearSessionWorkMode = func() error {
workMode = ""
return nil
}
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
@ -102,22 +178,95 @@ func TestFreeCommand_SetsPersistentModel(t *testing.T) {
if res.Outcome != OutcomeHandled {
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if persistent != "openrouter-free" {
t.Fatalf("persistent=%q, want %q", persistent, "openrouter-free")
if persistent != "tier: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)." {
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) {
rt := newModeTestRuntime()
rt.GetModelInfo = func() (string, string) {
return "gpt-5.4-mini", "openai"
}
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)
@ -134,9 +283,12 @@ func TestStatusCommand_ReportsPendingBoost(t *testing.T) {
}
if !containsAll(reply, []string{
"Current Model: gpt-5.4-mini (Provider: openai)",
"Session Mode: boost armed for next message (gpt-5.4-mini)",
"Pending Boost: gpt-5.4-mini",
"Paid Model: gpt-5.4-mini",
"Session Mode: boost armed for next message (gpt-5-mini)",
"Work Mode: code",
"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",
}) {
t.Fatalf("reply=%q, missing expected status content", reply)

View file

@ -68,6 +68,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
// Sub-command routing
subName := nthToken(req.Text, 1)
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())
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
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()))
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
}

View file

@ -1,6 +1,50 @@
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
// per-request by the agent loop so that per-request state (like session scope)
@ -18,7 +62,28 @@ type Runtime struct {
ClearHistory func() error
ReloadConfig func() error
GetSessionModelMode func() (persistent, pending string)
SetSessionModelMode func(value string) error
ArmNextModelMode func(value string) error
GetSessionModelMode func() (persistent, pending string)
SetSessionModelMode 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
}

View file

@ -200,16 +200,30 @@ type SessionConfig struct {
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.
// When enabled, each incoming message is scored against structural features
// (message length, code blocks, tool call history, conversation depth, attachments).
// Messages scoring below Threshold are sent to LightModel; all others use the
// agent's primary model. This reduces cost and latency for simple tasks without
// requiring any keyword matching — all scoring is language-agnostic.
// Legacy configs can continue to use LightModel/Threshold; newer configs can
// define named tiers with their own model chains and use FreeTier/PaidTier
// for the session mode commands.
type RoutingConfig struct {
Enabled bool `json:"enabled"`
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
Enabled bool `json:"enabled"`
LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks
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.
@ -805,6 +819,19 @@ type ExecConfig struct {
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 {
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"`
@ -855,6 +882,8 @@ type ToolsConfig struct {
Web WebToolsConfig `json:"web" yaml:"web,omitempty"`
Cron CronToolsConfig `json:"cron" 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"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"`
MCP MCPConfig `json:"mcp" yaml:"-"`
@ -1084,6 +1113,9 @@ func LoadConfig(path string) (*Config, error) {
if err = cfg.ValidateModelList(); err != nil {
return nil, err
}
if err = cfg.ValidateRouting(); err != nil {
return nil, err
}
// Ensure Workspace has a default if not set
if cfg.Agents.Defaults.Workspace == "" {
@ -1219,6 +1251,49 @@ func (c *Config) ValidateModelList() error {
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 {
return loadSecurityConfig(c, securityPath(path))
}
@ -1312,6 +1387,10 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.Cron.Enabled
case "exec":
return t.Exec.Enabled
case "git":
return t.Git.Enabled
case "github":
return t.Github.Enabled
case "skills":
return t.Skills.Enabled
case "media_cleanup":

View file

@ -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
func TestDefaultConfig_Temperature(t *testing.T) {
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()
path := filepath.Join(tmpDir, "config.json")
@ -362,8 +470,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
t.Fatalf("ReadFile failed: %v", err)
}
if !strings.Contains(string(data), `"model_name": ""`) {
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
if !strings.Contains(string(data), `"model_name": "gpt-5-mini"`) {
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) {
cfg := DefaultConfig()
if !cfg.Tools.FilterSensitiveData {

View file

@ -22,12 +22,51 @@ func DefaultConfig() *Config {
Workspace: workspacePath,
RestrictToWorkspace: true,
Provider: "",
ModelName: "gpt-5-mini",
ModelFallbacks: []string{"gpt-5.4-mini"},
MaxTokens: 32768,
Temperature: nil, // nil means use provider default
MaxToolIterations: 50,
SummarizeMessageThreshold: 20,
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{
Enabled: false,
MaxArgsLength: 300,
@ -170,6 +209,26 @@ func DefaultConfig() *Config {
Model: "openai/gpt-5.4",
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
{
@ -231,6 +290,21 @@ func DefaultConfig() *Config {
Model: "openrouter/openai/gpt-5.4",
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/
{
@ -429,6 +503,19 @@ func DefaultConfig() *Config {
AllowRemote: true,
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{
ToolConfig: ToolConfig{
Enabled: true,

View file

@ -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) {
jsonData := `{
"model_name": "slow-local",

View file

@ -32,22 +32,9 @@ func (p *CodexCliProvider) Chat(
return nil, fmt.Errorf("codex command not configured")
}
prompt := p.buildPrompt(messages, tools)
prompt := BuildCodexCLIPrompt(messages, tools)
args := []string{
"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
args := BuildCodexCLIArgs(model, p.workspace)
cmd := exec.CommandContext(ctx, p.command, args...)
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)
// but still produces valid JSONL output.
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) {
return resp, nil
}
@ -78,7 +65,7 @@ func (p *CodexCliProvider) Chat(
return nil, fmt.Errorf("codex cli error: %w", err)
}
return p.parseJSONLEvents(stdout.String())
return ParseCodexCLIJSONLEvents(stdout.String())
}
// GetDefaultModel returns the default model identifier.
@ -86,9 +73,29 @@ func (p *CodexCliProvider) GetDefaultModel() string {
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.
func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
func BuildCodexCLIPrompt(messages []Message, tools []ToolDefinition) string {
var systemParts []string
var conversationParts []string
@ -128,6 +135,12 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
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`.
type codexEvent struct {
Type string `json:"type"`
@ -158,8 +171,8 @@ type codexEventErr struct {
Message string `json:"message"`
}
// parseJSONLEvents processes the JSONL output from codex exec --json.
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
// ParseCodexCLIJSONLEvents processes the JSONL output from codex exec --json.
func ParseCodexCLIJSONLEvents(output string) (*LLMResponse, error) {
var contentParts []string
var usage *UsageInfo
var lastError string
@ -221,3 +234,7 @@ func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error)
Usage: usage,
}, nil
}
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
return ParseCodexCLIJSONLEvents(output)
}

View file

@ -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) {
// Script that sleeps forever
tmpDir := t.TempDir()

View file

@ -21,6 +21,7 @@ type Classifier interface {
// 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
// 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 1-3 (recent): 0.10 — some tool activity
// 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 {
score += 0.40
}
if f.ToolIntent {
score += 0.30
}
// Recent tool call density — indicates an ongoing agentic workflow
switch {

View file

@ -1,6 +1,7 @@
package routing
import (
"regexp"
"strings"
"unicode/utf8"
@ -11,9 +12,9 @@ import (
// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).
const lookbackWindow = 6
// Features holds the structural signals extracted from a message and its session context.
// Every dimension is language-agnostic by construction — no keyword or pattern matching
// against natural-language content. This ensures consistent routing for all locales.
// Features holds the signals extracted from a message and its session context.
// Most dimensions are structural; ToolIntent adds a narrow set of repo/tool cues
// so routing can leave nano early for likely agentic turns.
type Features struct {
// TokenEstimate is a proxy for token count.
// 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,
// audio, video). Multi-modal inputs require vision-capable heavy models.
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.
// It is a pure function with no side effects and zero allocations beyond
// the returned struct.
@ -47,6 +65,7 @@ func ExtractFeatures(msg string, history []providers.Message) Features {
RecentToolCalls: countRecentToolCalls(history),
ConversationDepth: len(history),
HasAttachments: hasAttachments(msg),
ToolIntent: hasToolIntent(msg),
}
}
@ -125,3 +144,36 @@ func hasAttachments(msg string) bool {
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
}

View file

@ -1,6 +1,9 @@
package routing
import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"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.
type RouterConfig struct {
// 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
// Threshold is the complexity score cutoff in [0, 1].
// score >= Threshold → primary (heavy) model.
// score < Threshold → light model.
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.
@ -29,6 +38,13 @@ type Router struct {
classifier Classifier
}
const (
DefaultFastTierName = "fast"
DefaultHeavyTierName = "heavy"
DefaultToolsTierName = "tools"
DefaultFreeTierName = "free"
)
// New creates a Router with the given config and the default RuleClassifier.
// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used.
func New(cfg RouterConfig) *Router {
@ -63,14 +79,52 @@ func (r *Router) SelectModel(
history []providers.Message,
primaryModel string,
) (model string, usedLight bool, score float64) {
features := ExtractFeatures(msg, history)
score = r.classifier.Score(features)
if score < r.cfg.Threshold {
tier, score := r.SelectTier(msg, history)
if tier != "" {
return r.cfg.LightModel, true, 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.
func (r *Router) LightModel() string {
return r.cfg.LightModel
@ -80,3 +134,12 @@ func (r *Router) LightModel() string {
func (r *Router) Threshold() float64 {
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
}

View file

@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -26,6 +27,9 @@ func TestExtractFeatures_EmptyMessage(t *testing.T) {
if f.HasAttachments {
t.Error("HasAttachments: got true, want false")
}
if f.ToolIntent {
t.Error("ToolIntent: got true, want false")
}
}
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 ───────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────────────
func TestRouter_DefaultThreshold(t *testing.T) {
@ -304,9 +334,7 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
}
}
func TestRouter_SelectModel_DeepToolChainUsesLight(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.
func TestRouter_SelectModel_DeepToolChainUsesPrimary(t *testing.T) {
r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
history := []providers.Message{
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
@ -314,8 +342,17 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
}
msg := "ok"
_, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6")
if !usedLight {
t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)")
if usedLight {
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) {
// 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})
msg := "```go\nfmt.Println()\n```"
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight {
t.Error("very high threshold: code block (0.40) should route to light model")
if usedLight {
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) ─────────────────────────────────
type fixedScoreClassifier struct{ score float64 }

576
pkg/tools/git.go Normal file
View 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
View 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
View 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")
}
}