From 3cc8896d77731d73649db2dcc7b70cfe7830bfd1 Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Fri, 14 Aug 2026 21:14:34 +0800 Subject: [PATCH 1/7] feat(dsh): add DeepSeek Harness (dsh) agent support Bridges cc-connect to the DeepSeek Harness CLI (dsh) in headless profile: every user message spawns `dsh --profile headless` as a one-shot run that resumes the same persisted dsh session (context carries across turns), then prints the final assistant text back to the chat. The installed dsh must provide the --session-id / --model / --mode headless runner flags (companion change to the harness repo). Implements: - core.Agent / core.AgentSession (one-shot headless per Send) - core.ModelSwitcher (/model): reads dsh settings.yaml model catalog, passes --model to the next run - core.ModeSwitcher (/mode): read-only | workspace-write | danger-full-access | confirm, pinned via --mode - WorkspaceAgentOptionSnapshotter, MemoryFileProvider, SkillProvider, AgentDoctorInfo, session listing - plugin_agent_dsh.go (build tag !no_dsh), Makefile ALL_AGENTS, config example --- Makefile | 5 + agent/dsh/dsh.go | 222 +++++++++++++++++ agent/dsh/dsh_test.go | 386 +++++++++++++++++++++++++++++ agent/dsh/models.go | 159 ++++++++++++ agent/dsh/proc_unix.go | 36 +++ agent/dsh/proc_windows.go | 27 ++ agent/dsh/session.go | 281 +++++++++++++++++++++ cmd/cc-connect/plugin_agent_dsh.go | 5 + config.example.toml | 47 ++++ 9 files changed, 1168 insertions(+) create mode 100644 agent/dsh/dsh.go create mode 100644 agent/dsh/dsh_test.go create mode 100644 agent/dsh/models.go create mode 100644 agent/dsh/proc_unix.go create mode 100644 agent/dsh/proc_windows.go create mode 100644 agent/dsh/session.go create mode 100644 cmd/cc-connect/plugin_agent_dsh.go diff --git a/Makefile b/Makefile index c5654c5a9..5764c5c5f 100644 --- a/Makefile +++ b/Makefile @@ -33,10 +33,15 @@ PLATFORMS := \ # make build EXCLUDE=discord,dingtalk,qq,qqbot,line # --------------------------------------------------------------------------- +<<<<<<< HEAD ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder tmux ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex wps-agentspace tuitui ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui googlechat +======= +ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux dsh +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui +>>>>>>> bf12b06 (feat(dsh): add DeepSeek Harness (dsh) agent support) ALL_EXTRAS := web COMMA := , diff --git a/agent/dsh/dsh.go b/agent/dsh/dsh.go new file mode 100644 index 000000000..30be702b3 --- /dev/null +++ b/agent/dsh/dsh.go @@ -0,0 +1,222 @@ +// Package dsh bridges cc-connect to the DeepSeek Harness CLI (dsh). +// +// Interaction model: cc-connect drives `dsh --profile headless` as a one-shot +// process per user message. The headless runner (patched via the user's +// dsh.nix `headless-cc-connect.patch`) accepts: +// +// --session-id create-or-resume a persisted dsh session with this id +// --model override the default model for this run +// --mode pin sandbox/approval knobs for this run +// +// The session id is owned by cc-connect: the engine's StartSession(sessionID) +// is used directly as the dsh session id, so a multi-turn Feishu conversation +// maps to one persisted dsh session (context continuity across turns). +package dsh + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("dsh", New) +} + +// Agent drives the DeepSeek Harness CLI (dsh) in headless profile. +type Agent struct { + cmd string // path to dsh binary + cliExtraArgs []string // extra args from cmd after the binary name + configEnv []string // env vars from [projects.agent.options.env] + workDir string + model string // model override ("" = use dsh settings default) + mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" + sessionEnv []string + mu sync.Mutex +} + +// New creates the dsh agent from cc-connect config options. +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + + cmd, extraArgs := core.ParseCmdOpts(opts, "dsh") + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("dsh: '%s' not found in PATH, install via home-manager (agent/dsh/dsh.nix)", cmd) + } + + return &Agent{ + cmd: cmd, + cliExtraArgs: extraArgs, + configEnv: core.ParseConfigEnv(opts), + workDir: workDir, + model: model, + mode: mode, + }, nil +} + +// normalizeMode maps user-facing /mode values to the dsh permission modes. +// "confirm" mirrors the user's dsh web profile default preset +// (danger-full-access sandbox + ask approval); under headless (no approval +// answerer) it behaves like danger-full-access because full-access never +// needs sandbox escalation. +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "default", "confirm": + return "confirm" + case "read-only", "ro": + return "read-only" + case "workspace-write", "workspace": + return "workspace-write" + case "danger-full-access", "full-access", "full", "yolo", "auto", "never", "bypass": + return "danger-full-access" + default: + return "confirm" + } +} + +func (a *Agent) Name() string { return "dsh" } +func (a *Agent) CLIBinaryName() string { return a.cmd } +func (a *Agent) CLIDisplayName() string { return "DSH" } + +// WorkspaceAgentOptions implements core.WorkspaceAgentOptionSnapshotter. +// work_dir is omitted — the engine sets the target workspace. model and mode +// are copied by the engine via GetModel/GetMode, so we don't repeat them here. +func (a *Agent) WorkspaceAgentOptions() map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + opts := map[string]any{} + if a.cmd != "" && a.cmd != "dsh" { + opts["cmd"] = a.cmd + } + return opts +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +// ── ModelSwitcher ──────────────────────────────────────────── + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("dsh: model changed", "model", model) +} + +// GetModel returns the cc-connect-configured model, falling back to the +// model stored in dsh's own settings.yaml when none was configured here. +func (a *Agent) GetModel() string { + a.mu.Lock() + model := a.model + a.mu.Unlock() + if model != "" { + return model + } + if def, err := readDefaultModel(); err == nil && def != "" { + return def + } + return "" +} + +func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { + if models := readSettingsModels(); len(models) > 0 { + return models + } + // Fallback: the catalog dsh ships for the deepseek-official provider. + return []core.ModelOption{ + {Name: "deepseek-v4-flash", Desc: "DeepSeek-V4-Flash"}, + {Name: "deepseek-v4-pro", Desc: "DeepSeek-V4-Pro"}, + } +} + +// ── ModeSwitcher ───────────────────────────────────────────── + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("dsh: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "read-only", Name: "Read Only", NameZh: "只读", Desc: "Read-only file access", DescZh: "只读文件访问,不允许任何写入"}, + {Key: "workspace-write", Name: "Workspace Write", NameZh: "工作区写入", Desc: "Writes allowed under the workspace", DescZh: "仅允许在工作区内写入,工作区外写入需审批(无审批端时自动拒绝)"}, + {Key: "danger-full-access", Name: "Full Access", NameZh: "完全访问", Desc: "No sandbox restrictions, auto-approve", DescZh: "无沙箱限制,所有操作自动放行"}, + {Key: "confirm", Name: "Confirm", NameZh: "询问确认", Desc: "Full access with write approval", DescZh: "完全访问 + 写入询问(无审批端时等同完全访问)"}, + } +} + +// ── MemoryFileProvider ─────────────────────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "AGENTS.md") +} + +func (a *Agent) GlobalMemoryFile() string { + home := dshHomeDir() + if home == "" { + return "" + } + return filepath.Join(home, "AGENTS.md") +} + +// ── SkillProvider ──────────────────────────────────────────── + +func (a *Agent) SkillDirs() []string { + home := dshHomeDir() + dirs := make([]string, 0, 2) + if home != "" { + dirs = append(dirs, filepath.Join(home, "skills")) + } + if absDir, err := filepath.Abs(a.workDir); err == nil { + dirs = append(dirs, filepath.Join(absDir, ".dsh", "skills")) + } + return dirs +} + +// ── Session lifecycle ──────────────────────────────────────── + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + mode := a.mode + model := a.model + extraArgs := append([]string{}, a.cliExtraArgs...) + extraEnv := append([]string(nil), a.configEnv...) + extraEnv = append(extraEnv, a.sessionEnv...) + workDir := a.workDir + a.mu.Unlock() + return newDSHSession(ctx, a.cmd, extraArgs, workDir, model, mode, sessionID, extraEnv) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + return listDSHSessions(a.workDir) +} + +func (a *Agent) Stop() error { return nil } diff --git a/agent/dsh/dsh_test.go b/agent/dsh/dsh_test.go new file mode 100644 index 000000000..ec2b3cdea --- /dev/null +++ b/agent/dsh/dsh_test.go @@ -0,0 +1,386 @@ +package dsh + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// ── normalizeMode ──────────────────────────────────────────── + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", "confirm"}, + {"default", "confirm"}, + {"confirm", "confirm"}, + {"CONFIRM", "confirm"}, + {"read-only", "read-only"}, + {"Read-Only", "read-only"}, + {"workspace-write", "workspace-write"}, + {"workspace", "workspace-write"}, + {"danger-full-access", "danger-full-access"}, + {"full-access", "danger-full-access"}, + {"yolo", "danger-full-access"}, + {"auto", "danger-full-access"}, + {"never", "danger-full-access"}, + {" Yolo ", "danger-full-access"}, + {"unknown", "confirm"}, + } + for _, tt := range tests { + if got := normalizeMode(tt.in); got != tt.want { + t.Errorf("normalizeMode(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// ── Agent constructor ──────────────────────────────────────── + +func TestNew_DefaultValues(t *testing.T) { + ag, err := New(map[string]any{"cmd": "echo"}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := ag.(*Agent) + if a.cmd != "echo" { + t.Errorf("cmd = %q, want echo", a.cmd) + } + if a.workDir != "." { + t.Errorf("workDir = %q, want .", a.workDir) + } + if a.mode != "confirm" { + t.Errorf("mode = %q, want confirm", a.mode) + } + if a.model != "" { + t.Errorf("model = %q, want empty", a.model) + } + if a.Name() != "dsh" { + t.Errorf("Name() = %q, want dsh", a.Name()) + } +} + +func TestNew_WithOptions(t *testing.T) { + ag, err := New(map[string]any{ + "cmd": "echo", + "work_dir": "/tmp/proj", + "model": "deepseek-v4-pro", + "mode": "danger-full-access", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := ag.(*Agent) + if a.workDir != "/tmp/proj" { + t.Errorf("workDir = %q", a.workDir) + } + if a.model != "deepseek-v4-pro" { + t.Errorf("model = %q", a.model) + } + if a.mode != "danger-full-access" { + t.Errorf("mode = %q", a.mode) + } +} + +func TestNew_MissingBinary(t *testing.T) { + _, err := New(map[string]any{"cmd": "definitely-not-a-real-binary-xyz"}) + if err == nil { + t.Fatal("New() expected error for missing binary, got nil") + } +} + +// ── ModelSwitcher / ModeSwitcher ───────────────────────────── + +func TestSetGetModel(t *testing.T) { + ag, _ := New(map[string]any{"cmd": "echo"}) + a := ag.(*Agent) + a.SetModel("deepseek-v4-flash") + if got := a.GetModel(); got != "deepseek-v4-flash" { + t.Errorf("GetModel() = %q", got) + } +} + +func TestSetGetMode(t *testing.T) { + ag, _ := New(map[string]any{"cmd": "echo"}) + a := ag.(*Agent) + a.SetMode("yolo") + if got := a.GetMode(); got != "danger-full-access" { + t.Errorf("GetMode() = %q, want danger-full-access", got) + } +} + +func TestPermissionModes(t *testing.T) { + ag, _ := New(map[string]any{"cmd": "echo"}) + modes := ag.(*Agent).PermissionModes() + if len(modes) != 4 { + t.Fatalf("PermissionModes() len = %d, want 4", len(modes)) + } + keys := map[string]bool{} + for _, m := range modes { + keys[m.Key] = true + } + for _, want := range []string{"read-only", "workspace-write", "danger-full-access", "confirm"} { + if !keys[want] { + t.Errorf("PermissionModes missing %q", want) + } + } +} + +// ── AvailableModels / settings ─────────────────────────────── + +func TestAvailableModels_Fallback(t *testing.T) { + t.Setenv("DSH_HOME", t.TempDir()) // empty settings.yaml + ag, _ := New(map[string]any{"cmd": "echo"}) + models := ag.(*Agent).AvailableModels(context.Background()) + if len(models) != 2 { + t.Fatalf("AvailableModels fallback len = %d, want 2", len(models)) + } + if models[0].Name != "deepseek-v4-flash" || models[1].Name != "deepseek-v4-pro" { + t.Errorf("unexpected fallback models: %+v", models) + } +} + +func TestAvailableModels_FromSettings(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(` +agent-default-model: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: high +llm-deepseek: + models: + - id: deepseek-v4-flash + name: DeepSeek-V4-Flash + - id: deepseek-v4-pro + name: DeepSeek-V4-Pro +`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("DSH_HOME", home) + ag, _ := New(map[string]any{"cmd": "echo"}) + models := ag.(*Agent).AvailableModels(context.Background()) + if len(models) != 2 { + t.Fatalf("AvailableModels len = %d, want 2", len(models)) + } + if models[0].Name != "deepseek-v4-flash" || models[0].Desc != "DeepSeek-V4-Flash" { + t.Errorf("unexpected model: %+v", models[0]) + } +} + +func TestGetModel_FallsBackToSettings(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(` +agent-default-model: + provider: deepseek-official + model: deepseek-v4-pro +`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("DSH_HOME", home) + ag, _ := New(map[string]any{"cmd": "echo"}) + if got := ag.(*Agent).GetModel(); got != "deepseek-v4-pro" { + t.Errorf("GetModel() = %q, want deepseek-v4-pro", got) + } +} + +// ── buildArgs ──────────────────────────────────────────────── + +func TestBuildArgs(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "confirm", "session-abc", nil) + if err != nil { + t.Fatal(err) + } + args := s.buildArgs("hello world") + want := []string{"--profile", "headless", "--session-id", "session-abc", "--mode", "confirm", "hello world"} + if strings.Join(args, "\x00") != strings.Join(want, "\x00") { + t.Errorf("buildArgs() = %v, want %v", args, want) + } +} + +func TestBuildArgs_WithModel(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "deepseek-v4-pro", "danger-full-access", "session-abc", nil) + if err != nil { + t.Fatal(err) + } + args := s.buildArgs("task") + joined := strings.Join(args, " ") + for _, want := range []string{"--profile", "headless", "--session-id", "session-abc", "--model", "deepseek-v4-pro", "--mode", "danger-full-access", "task"} { + if !strings.Contains(joined, want) { + t.Errorf("buildArgs() = %v missing %q", args, want) + } + } +} + +func TestSessionID_GeneratedWhenEmpty(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", nil) + if err != nil { + t.Fatal(err) + } + id := s.CurrentSessionID() + if id == "" { + t.Fatal("generated session id is empty") + } + if !strings.HasPrefix(id, "session-cc-connect-") { + t.Errorf("generated session id = %q, want session-cc-connect- prefix", id) + } +} + +// ── Send with a fake dsh script ────────────────────────────── + +// fakeDSHScript writes a shell script that acts like the patched headless +// runner: it dumps its full argv to $1 (an env-provided file) and prints a +// canned final answer on stdout. When wantErr is set it exits non-zero with +// a message on stderr instead. +func fakeDSHScript(t *testing.T, argvFile string, wantErr bool) string { + t.Helper() + script := filepath.Join(t.TempDir(), "fake-dsh.sh") + body := `#!/bin/sh +echo "$@" > "` + argvFile + `" +if [ "` + fmt.Sprint(wantErr) + `" = "true" ]; then + echo "dsh: BOOM: test failure" >&2 + exit 1 +fi +printf 'final answer line 1\nfinal answer line 2\n' +` + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return script +} + +func TestSend_Success(t *testing.T) { + argvFile := filepath.Join(t.TempDir(), "argv.txt") + script := fakeDSHScript(t, argvFile, false) + + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "deepseek-v4-flash", "confirm", "session-test-1", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + done := make(chan error, 1) + go func() { done <- s.Send("please reply", "msg-1", nil, nil) }() + + var texts []string + var result *core.Event + timeout := time.After(15 * time.Second) +loop: + for { + select { + case evt := <-s.Events(): + switch evt.Type { + case core.EventText: + texts = append(texts, evt.Content) + case core.EventResult: + result = &evt + break loop + case core.EventError: + t.Fatalf("unexpected error event: %v", evt.Error) + } + case <-timeout: + t.Fatal("timed out waiting for result") + } + } + if err := <-done; err != nil { + t.Fatalf("Send() error = %v", err) + } + + if len(texts) != 1 || texts[0] != "final answer line 1\nfinal answer line 2" { + t.Errorf("EventText = %q", texts) + } + if result == nil || !result.Done || result.SessionID != "session-test-1" { + t.Errorf("EventResult = %+v", result) + } + + // The fake script must have been invoked with our session id + overrides. + argv, err := os.ReadFile(argvFile) + if err != nil { + t.Fatal(err) + } + joined := string(argv) + for _, want := range []string{"--profile", "headless", "--session-id", "session-test-1", "--model", "deepseek-v4-flash", "--mode", "confirm", "please reply"} { + if !strings.Contains(joined, want) { + t.Errorf("fake dsh argv %q missing %q", joined, want) + } + } +} + +func TestSend_Error(t *testing.T) { + argvFile := filepath.Join(t.TempDir(), "argv.txt") + script := fakeDSHScript(t, argvFile, true) + + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "session-test-2", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + done := make(chan error, 1) + go func() { done <- s.Send("boom", "msg-1", nil, nil) }() + + var sawError bool + var result *core.Event + timeout := time.After(15 * time.Second) +loop: + for { + select { + case evt := <-s.Events(): + switch evt.Type { + case core.EventError: + sawError = true + case core.EventResult: + result = &evt + break loop + } + case <-timeout: + t.Fatal("timed out waiting for result") + } + } + if err := <-done; err != nil { + t.Fatalf("Send() error = %v", err) + } + if !sawError { + t.Error("expected EventError on failing run") + } + if result == nil || !result.Done { + t.Errorf("EventResult = %+v", result) + } +} + +// ── ListSessions ───────────────────────────────────────────── + +func TestListDSHSessions(t *testing.T) { + home := t.TempDir() + t.Setenv("DSH_HOME", home) + workDir := t.TempDir() + root := dshSessionsRoot(workDir) + if root == "" { + t.Fatal("dshSessionsRoot empty") + } + if err := os.MkdirAll(filepath.Join(root, "session-aaa"), 0o755); err != nil { + t.Fatal(err) + } + time.Sleep(10 * time.Millisecond) + if err := os.MkdirAll(filepath.Join(root, "session-bbb"), 0o755); err != nil { + t.Fatal(err) + } + + sessions, err := listDSHSessions(workDir) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 2 { + t.Fatalf("listDSHSessions len = %d, want 2", len(sessions)) + } + // Newest first. + if sessions[0].ID != "session-bbb" { + t.Errorf("sessions[0].ID = %q, want session-bbb (newest first)", sessions[0].ID) + } +} diff --git a/agent/dsh/models.go b/agent/dsh/models.go new file mode 100644 index 000000000..22f2463ee --- /dev/null +++ b/agent/dsh/models.go @@ -0,0 +1,159 @@ +package dsh + +import ( + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/chenhg5/cc-connect/core" + "gopkg.in/yaml.v3" +) + +// dshHomeDir returns the DeepSeek Harness user-data root ($DSH_HOME or +// ~/.dsh), which hosts settings.yaml, .credentials.yaml and skills/. +func dshHomeDir() string { + if d := os.Getenv("DSH_HOME"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".dsh") +} + +// dshSettings models the relevant sections of dsh's settings.yaml. +type dshSettings struct { + AgentDefaultModel struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` + ReasoningEffort string `yaml:"reasoningEffort"` + } `yaml:"agent-default-model"` + LLMDeepSeek struct { + Models []struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + } `yaml:"models"` + } `yaml:"llm-deepseek"` +} + +// settingsPath returns $DSH_HOME/settings.yaml. +func settingsPath() string { + home := dshHomeDir() + if home == "" { + return "" + } + return filepath.Join(home, "settings.yaml") +} + +// readDSHSettings parses dsh's settings.yaml. Returns nil on any error +// (callers fall back to defaults). +func readDSHSettings() *dshSettings { + path := settingsPath() + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + slog.Debug("dsh: read settings", "path", path, "error", err) + return nil + } + var s dshSettings + if err := yaml.Unmarshal(data, &s); err != nil { + slog.Warn("dsh: parse settings", "path", path, "error", err) + return nil + } + return &s +} + +// readDefaultModel returns the model configured in dsh's +// agent-default-model settings (e.g. "deepseek-v4-flash"). +func readDefaultModel() (string, error) { + s := readDSHSettings() + if s == nil || s.AgentDefaultModel.Model == "" { + return "", nil + } + return s.AgentDefaultModel.Model, nil +} + +// readSettingsModels returns the advisory model catalog dsh knows about: +// the `llm-deepseek.models` section of settings.yaml when present, else nil. +func readSettingsModels() []core.ModelOption { + s := readDSHSettings() + if s == nil || len(s.LLMDeepSeek.Models) == 0 { + return nil + } + models := make([]core.ModelOption, 0, len(s.LLMDeepSeek.Models)) + for _, m := range s.LLMDeepSeek.Models { + if m.ID == "" { + continue + } + option := core.ModelOption{Name: m.ID} + if m.Name != "" { + option.Desc = m.Name + } + // Derive a short alias from the last segment after the final "/". + if idx := strings.LastIndex(m.ID, "/"); idx >= 0 && idx+1 < len(m.ID) { + option.Alias = m.ID[idx+1:] + } + models = append(models, option) + } + sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name }) + return models +} + +// ── Session listing ────────────────────────────────────────── + +// dshSessionsRoot returns $DSH_HOME/sessions/. +// dsh encodes the absolute workdir by replacing "/" with "-" and wrapping +// it in "--" (e.g. /home/user/project → --home-user-project--). +func dshSessionsRoot(workDir string) string { + home := dshHomeDir() + if home == "" { + return "" + } + absDir, err := filepath.Abs(workDir) + if err != nil { + return "" + } + encoded := "--" + strings.ReplaceAll(strings.TrimPrefix(absDir, "/"), "/", "-") + "--" + return filepath.Join(home, "sessions", encoded) +} + +// listDSHSessions lists persisted dsh sessions for the given workdir. +// Session summaries are not decoded (dsh stores them as zstd-compressed +// JSONL), so the session id is shown as the summary. +func listDSHSessions(workDir string) ([]core.AgentSessionInfo, error) { + root := dshSessionsRoot(workDir) + if root == "" { + return nil, nil + } + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var sessions []core.AgentSessionInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + sessions = append(sessions, core.AgentSessionInfo{ + ID: entry.Name(), + Summary: entry.Name(), + ModifiedAt: info.ModTime(), + }) + } + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + return sessions, nil +} diff --git a/agent/dsh/proc_unix.go b/agent/dsh/proc_unix.go new file mode 100644 index 000000000..b5a1b7c77 --- /dev/null +++ b/agent/dsh/proc_unix.go @@ -0,0 +1,36 @@ +//go:build unix + +package dsh + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +// prepareCmdForKill puts the spawned child into its own process group so that +// the entire descendant tree can be terminated with a single signal aimed at +// the negative PID (dsh spawns worker threads / tool subprocesses). +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +// forceKillCmd SIGKILLs the entire process group rooted at cmd. +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && + !errors.Is(err, os.ErrProcessDone) && + !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} diff --git a/agent/dsh/proc_windows.go b/agent/dsh/proc_windows.go new file mode 100644 index 000000000..502ce6e93 --- /dev/null +++ b/agent/dsh/proc_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package dsh + +import ( + "os" + "os/exec" + "syscall" +) + +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP +} + +// forceKillCmd delegates to exec.Cmd.Process.Kill on Windows. +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} diff --git a/agent/dsh/session.go b/agent/dsh/session.go new file mode 100644 index 000000000..2d1214045 --- /dev/null +++ b/agent/dsh/session.go @@ -0,0 +1,281 @@ +package dsh + +import ( + "bufio" + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// dshSession runs a multi-turn dsh conversation. Each Send() spawns +// `dsh --profile headless` as a one-shot process; the persisted dsh session +// (identified by the cc-connect-owned session id) is resumed on every run, +// so context carries across turns. +type dshSession struct { + cmd string + extraArgs []string // extra args from cmd, prepended before dsh args + workDir string + model string // "" = use dsh settings default + mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" + extraEnv []string + events chan core.Event + sessionID atomic.Value + ctx context.Context + cancel context.CancelFunc + sendWg sync.WaitGroup // tracks in-flight Send() calls + alive atomic.Bool +} + +// newDSHSession creates a session. sessionID is the cc-connect-persisted dsh +// session id (empty on the first message); when empty, one is generated so +// every run after the first resumes the same conversation. +func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, sessionID string, extraEnv []string) (*dshSession, error) { + ctx, cancel := context.WithCancel(ctx) + s := &dshSession{ + cmd: cmd, + extraArgs: extraArgs, + workDir: workDir, + model: model, + mode: mode, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: ctx, + cancel: cancel, + } + s.alive.Store(true) + + if sessionID != "" && sessionID != core.ContinueSession { + s.sessionID.Store(sessionID) + } else { + s.sessionID.Store(fmt.Sprintf("session-cc-connect-%d-%d", time.Now().UnixNano(), os.Getpid())) + } + + return s, nil +} + +// ── Send ───────────────────────────────────────────────────── + +// Send runs one headless dsh turn: spawns `dsh --profile headless +// --session-id [--model ] [--mode ] `, waits for the +// process to finish, then emits the final assistant text (stdout) and the +// turn result. stderr lines are surfaced as EventError when the run fails. +func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttachment, files []core.FileAttachment) error { + s.sendWg.Add(1) + defer s.sendWg.Done() + + if !s.alive.Load() { + return fmt.Errorf("session is closed") + } + + prompt := s.buildPrompt(msg, messageID, images, files) + args := s.buildArgs(prompt) + + slog.Debug("dshSession: launching headless run", "cmd", s.cmd, "sessionID", s.CurrentSessionID(), "mode", s.mode, "model", s.model) + + cmd := exec.CommandContext(s.ctx, s.cmd, args...) + cmd.Dir = s.workDir + env := os.Environ() + if len(s.extraEnv) > 0 { + env = core.MergeEnv(env, s.extraEnv) + } + cmd.Env = env + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("dsh: stdout pipe: %w", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + prepareCmdForKill(cmd) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("dsh: start: %w", err) + } + + // Read the final assistant text (headless prints it on stdout and exits). + output, readErr := readAllString(stdout) + + err = cmd.Wait() + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if err != nil { + // emit() drops events once s.ctx is done (Close/cancel), so a + // cancelled run surfaces no spurious error turn to the engine. + slog.Error("dshSession: process error", "cmd", s.cmd, "error", err, "stderr", truncStr(stderrMsg, 1000)) + } + + text := strings.TrimSpace(output) + if text != "" { + s.emit(core.Event{Type: core.EventText, Content: text}) + } + if readErr != nil { + s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: read stdout: %w", readErr)}) + } + if stderrMsg != "" { + // Surface actionable stderr (e.g. "dsh: MISSING_CREDENTIAL: ..."). + s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: %s", truncStr(stderrMsg, 2000))}) + } + if err != nil { + s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: %s", err)}) + } + + // Signal turn completion. + s.emit(core.Event{Type: core.EventResult, SessionID: s.CurrentSessionID(), Done: true}) + return nil +} + +// buildPrompt saves attachments to disk and appends file references so the +// dsh agent can read them with its own tools. +func (s *dshSession) buildPrompt(msg, messageID string, images []core.ImageAttachment, files []core.FileAttachment) string { + var paths []string + if len(files) > 0 { + paths = append(paths, core.SaveFilesToDisk(s.workDir, messageID, files)...) + } + if len(images) > 0 { + paths = append(paths, saveImages(s.workDir, messageID, images)...) + } + return core.AppendFileRefs(msg, paths) +} + +// buildArgs assembles the dsh headless invocation. The prompt is the task +// positional; options come first so commander parses them reliably. +func (s *dshSession) buildArgs(prompt string) []string { + args := append([]string{}, s.extraArgs...) + args = append(args, "--profile", "headless", "--session-id", s.CurrentSessionID()) + if s.model != "" { + args = append(args, "--model", s.model) + } + if s.mode != "" { + args = append(args, "--mode", s.mode) + } + return append(args, prompt) +} + +func (s *dshSession) emit(evt core.Event) { + select { + case s.events <- evt: + case <-s.ctx.Done(): + } +} + +// ── AgentSession interface ────────────────────────────────── + +func (s *dshSession) Events() <-chan core.Event { + return s.events +} + +func (s *dshSession) CurrentSessionID() string { + v, _ := s.sessionID.Load().(string) + return v +} + +func (s *dshSession) Alive() bool { + return s.alive.Load() +} + +func (s *dshSession) RespondPermission(_ string, _ core.PermissionResult) error { + // Headless runs are one-shot: there is no interactive permission channel, + // so permission decisions are not forwarded. dsh's own approval seam + // fails closed under headless (no answerer) — mode controls that behavior. + return nil +} + +func (s *dshSession) Close() error { + s.alive.Store(false) + s.cancel() + s.sendWg.Wait() + close(s.events) + return nil +} + +// ── Helpers ────────────────────────────────────────────────── + +func readAllString(r interface{ Read([]byte) (int, error) }) (string, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + var sb strings.Builder + for scanner.Scan() { + sb.WriteString(scanner.Text()) + sb.WriteString("\n") + } + if err := scanner.Err(); err != nil { + return sb.String(), err + } + return sb.String(), nil +} + +// saveImages writes image attachments under workDir/.cc-connect/images and +// returns their absolute paths (dsh cannot ingest image bytes through the +// headless task text, so the agent is pointed at the files instead). +func saveImages(workDir, messageID string, images []core.ImageAttachment) []string { + if len(images) == 0 { + return nil + } + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + imgDir := filepath.Join(absDir, ".cc-connect", "images") + if messageID != "" { + imgDir = filepath.Join(imgDir, sanitizeName(messageID)) + } + if err := os.MkdirAll(imgDir, 0o755); err != nil { + slog.Error("dsh: create images dir", "error", err) + return nil + } + var paths []string + for i, img := range images { + if len(img.Data) == 0 { + continue + } + ext := ".png" + switch img.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + name := sanitizeName(img.FileName) + if name == "" { + name = fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + } else if !strings.HasSuffix(strings.ToLower(name), ext) { + name = name + ext + } + p := filepath.Join(imgDir, name) + if err := os.WriteFile(p, img.Data, 0o644); err != nil { + slog.Error("dsh: save image failed", "error", err) + continue + } + paths = append(paths, p) + } + return paths +} + +func truncStr(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} + +func sanitizeName(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(name) + if name == "" || name == "." || name == ".." { + return "" + } + return name +} diff --git a/cmd/cc-connect/plugin_agent_dsh.go b/cmd/cc-connect/plugin_agent_dsh.go new file mode 100644 index 000000000..26384ed53 --- /dev/null +++ b/cmd/cc-connect/plugin_agent_dsh.go @@ -0,0 +1,5 @@ +//go:build !no_dsh + +package main + +import _ "github.com/chenhg5/cc-connect/agent/dsh" diff --git a/config.example.toml b/config.example.toml index e61aea925..f2ccda972 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1918,6 +1918,53 @@ app_secret = "your-feishu-app-secret" # [projects.platforms.options] # token = "your-telegram-bot-token" +# ============================================================================= +# Project: DeepSeek Harness (dsh) agent / 使用 DeepSeek Harness +# ============================================================================= +# Drives the DeepSeek Harness CLI (`dsh`) in headless profile: every user +# message spawns `dsh --profile headless` as a one-shot run that resumes the +# same persisted dsh session (context carries across turns), then prints the +# final assistant text back to the chat. +# +# The installed dsh must carry the headless-cc-connect patch (see the user's +# home-manager agent/dsh/dsh.nix), which adds --session-id / --model / --mode +# support to the headless runner. +# +# Prerequisites / 前置条件: +# 1. Install dsh per https://github.com/deepseek-ai/deepseek-harness +# (the user's setup: home-manager agent/dsh/dsh.nix, built from the +# deepseek-ai/deepseek-harness repo with the headless patch applied). +# 2. Configure a provider in dsh's settings ($DSH_HOME/settings.yaml: +# `agent-default-model` + credentials). dsh headless reads them itself; +# cc-connect never sees the API key. + +# [[projects]] +# name = "my-dsh-project" +# +# [projects.agent] +# type = "dsh" +# +# [projects.agent.options] +# work_dir = "/path/to/project" +# mode = "confirm" # "read-only" | "workspace-write" | "danger-full-access" | "confirm" +# # (confirm = full access + ask, matches the dsh web profile default; +# # headless 无审批端时 confirm 等同 danger-full-access) +# # model = "deepseek-v4-flash" # optional / 可选; 未配置时使用 dsh settings 中的默认模型 +# +# # Optional: point at a custom dsh binary (absolute path recommended under systemd). +# # 可选:自定义 dsh 可执行文件路径(systemd 环境下建议使用绝对路径) +# # cmd = "/home/me/.nix-profile/bin/dsh" +# +# # Optional: extra environment variables for the dsh process / 可选:传给 dsh 进程的环境变量 +# # [projects.agent.options.env] +# # DSH_HOME = "/home/me/.dsh" +# +# [[projects.platforms]] +# type = "telegram" +# +# [projects.platforms.options] +# token = "your-telegram-bot-token" + # ============================================================================= # Project: Devin CLI (Cognition, https://cli.devin.ai/) / 使用 Devin CLI # ============================================================================= From a774a97dd9d73adb509c64a4788bb07af79eb532 Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Fri, 14 Aug 2026 21:14:34 +0800 Subject: [PATCH 2/7] feat(dsh): stream tool events, relay approvals, and list session titles - Send now drives dsh headless in --jsonl mode: text/thinking deltas, tool calls and results stream to the chat, and approval requests (confirm mode write/execute interception + workspace-write sandbox escalations) render as permission cards answered via the runner's stdin. - RespondPermission writes the human's decision back to the dsh process. - /session shows dsh's real session titles (last session/title event, fallback first user message) parsed from the zstd-compressed session logs (new dep: klauspost/compress). --- Makefile | 7 +- agent/dsh/dsh_test.go | 209 ++++++++++++++++++++++++++++++++--- agent/dsh/models.go | 118 ++++++++++++++++++-- agent/dsh/session.go | 246 +++++++++++++++++++++++++++++++++++------- go.mod | 3 +- go.sum | 2 + 6 files changed, 520 insertions(+), 65 deletions(-) diff --git a/Makefile b/Makefile index 5764c5c5f..f5b610e10 100644 --- a/Makefile +++ b/Makefile @@ -33,15 +33,10 @@ PLATFORMS := \ # make build EXCLUDE=discord,dingtalk,qq,qqbot,line # --------------------------------------------------------------------------- -<<<<<<< HEAD ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder tmux ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex wps-agentspace tuitui -ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux -ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui googlechat -======= ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux dsh -ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui ->>>>>>> bf12b06 (feat(dsh): add DeepSeek Harness (dsh) agent support) +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web tuitui googlechat ALL_EXTRAS := web COMMA := , diff --git a/agent/dsh/dsh_test.go b/agent/dsh/dsh_test.go index ec2b3cdea..2305e10d1 100644 --- a/agent/dsh/dsh_test.go +++ b/agent/dsh/dsh_test.go @@ -1,6 +1,7 @@ package dsh import ( + "bytes" "context" "fmt" "os" @@ -10,6 +11,7 @@ import ( "time" "github.com/chenhg5/cc-connect/core" + "github.com/klauspost/compress/zstd" ) // ── normalizeMode ──────────────────────────────────────────── @@ -198,7 +200,7 @@ func TestBuildArgs(t *testing.T) { t.Fatal(err) } args := s.buildArgs("hello world") - want := []string{"--profile", "headless", "--session-id", "session-abc", "--mode", "confirm", "hello world"} + want := []string{"--profile", "headless", "--session-id", "session-abc", "--mode", "confirm", "--jsonl", "hello world"} if strings.Join(args, "\x00") != strings.Join(want, "\x00") { t.Errorf("buildArgs() = %v, want %v", args, want) } @@ -235,19 +237,38 @@ func TestSessionID_GeneratedWhenEmpty(t *testing.T) { // ── Send with a fake dsh script ────────────────────────────── // fakeDSHScript writes a shell script that acts like the patched headless -// runner: it dumps its full argv to $1 (an env-provided file) and prints a -// canned final answer on stdout. When wantErr is set it exits non-zero with -// a message on stderr instead. -func fakeDSHScript(t *testing.T, argvFile string, wantErr bool) string { +// runner in --jsonl mode: it dumps its full argv to $1 (an env-provided +// file), emits streaming JSONL events, and (optionally) handles an approval +// request over stdin. When wantErr is set it exits non-zero with a message +// on stderr instead. +// +// JSONL emitted: text deltas, a tool call, a tool result, the final result +// envelope and the done envelope. When wantApproval is set it emits an +// approval/request line and echoes the first stdin line back to stdout +// before finishing. +func fakeDSHScript(t *testing.T, argvFile string, wantErr, wantApproval bool) string { t.Helper() script := filepath.Join(t.TempDir(), "fake-dsh.sh") + approval := `` + if wantApproval { + approval = `echo '{"type":"approval/request","id":"ap-1","toolName":"bash","reason":"approve bash"}' +read -r RESPONSE +echo "got:$RESPONSE" +` + } body := `#!/bin/sh echo "$@" > "` + argvFile + `" if [ "` + fmt.Sprint(wantErr) + `" = "true" ]; then echo "dsh: BOOM: test failure" >&2 exit 1 fi -printf 'final answer line 1\nfinal answer line 2\n' +echo '{"type":"text","text":"hel"}' +echo '{"type":"text","text":"lo"}' +echo '{"type":"thinking","text":"thinking about it"}' +echo '{"type":"tool/call","callId":"c1","name":"bash","arguments":"{\"command\":\"ls\"}"}' +echo '{"type":"tool/result","callId":"c1","name":"bash","content":"ok"}' +` + approval + `echo '{"type":"result","text":"final answer"}' +echo '{"type":"done","success":true}' ` if err := os.WriteFile(script, []byte(body), 0o755); err != nil { t.Fatal(err) @@ -257,7 +278,7 @@ printf 'final answer line 1\nfinal answer line 2\n' func TestSend_Success(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") - script := fakeDSHScript(t, argvFile, false) + script := fakeDSHScript(t, argvFile, false, false) s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "deepseek-v4-flash", "confirm", "session-test-1", nil) if err != nil { @@ -269,6 +290,8 @@ func TestSend_Success(t *testing.T) { go func() { done <- s.Send("please reply", "msg-1", nil, nil) }() var texts []string + var thinks []string + var tools []string var result *core.Event timeout := time.After(15 * time.Second) loop: @@ -278,6 +301,10 @@ loop: switch evt.Type { case core.EventText: texts = append(texts, evt.Content) + case core.EventThinking: + thinks = append(thinks, evt.Content) + case core.EventToolUse: + tools = append(tools, evt.ToolName) case core.EventResult: result = &evt break loop @@ -292,10 +319,16 @@ loop: t.Fatalf("Send() error = %v", err) } - if len(texts) != 1 || texts[0] != "final answer line 1\nfinal answer line 2" { - t.Errorf("EventText = %q", texts) + if len(texts) != 2 || texts[0] != "hel" || texts[1] != "lo" { + t.Errorf("EventText = %q, want streaming hel/lo", texts) + } + if len(thinks) != 1 || thinks[0] != "thinking about it" { + t.Errorf("EventThinking = %q", thinks) + } + if len(tools) != 1 || tools[0] != "bash" { + t.Errorf("EventToolUse = %v, want [bash]", tools) } - if result == nil || !result.Done || result.SessionID != "session-test-1" { + if result == nil || !result.Done || result.SessionID != "session-test-1" || result.Content != "final answer" { t.Errorf("EventResult = %+v", result) } @@ -305,16 +338,84 @@ loop: t.Fatal(err) } joined := string(argv) - for _, want := range []string{"--profile", "headless", "--session-id", "session-test-1", "--model", "deepseek-v4-flash", "--mode", "confirm", "please reply"} { + for _, want := range []string{"--profile", "headless", "--session-id", "session-test-1", "--model", "deepseek-v4-flash", "--mode", "confirm", "--jsonl", "please reply"} { if !strings.Contains(joined, want) { t.Errorf("fake dsh argv %q missing %q", joined, want) } } } +func TestSend_Approval(t *testing.T) { + argvFile := filepath.Join(t.TempDir(), "argv.txt") + script := fakeDSHScript(t, argvFile, false, true) + + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "confirm", "session-approval", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + done := make(chan error, 1) + go func() { done <- s.Send("please reply", "msg-1", nil, nil) }() + + var result *core.Event + var permReq *core.Event + timeout := time.After(15 * time.Second) +loop: + for { + select { + case evt := <-s.Events(): + switch evt.Type { + case core.EventPermissionRequest: + permReq = &evt + // Answer the permission request (the engine would do this + // from the Feishu card buttons). + if err := s.RespondPermission(evt.RequestID, core.PermissionResult{Behavior: "allow"}); err != nil { + t.Fatalf("RespondPermission error: %v", err) + } + case core.EventResult: + result = &evt + break loop + case core.EventError: + t.Fatalf("unexpected error event: %v", evt.Error) + } + case <-timeout: + t.Fatal("timed out waiting for result") + } + } + if err := <-done; err != nil { + t.Fatalf("Send() error = %v", err) + } + + if permReq == nil { + t.Fatal("expected EventPermissionRequest") + } + if permReq.ToolName != "bash" || !strings.HasPrefix(permReq.RequestID, "dsh_") { + t.Errorf("EventPermissionRequest = %+v", permReq) + } + if permReq.ToolInput == "" { + t.Error("EventPermissionRequest.ToolInput is empty") + } + if result == nil || !result.Done || result.Content != "final answer" { + t.Errorf("EventResult = %+v", result) + } +} + +func TestRespondPermission_NoActiveRun(t *testing.T) { + s, err := newDSHSession(context.Background(), "echo", nil, t.TempDir(), "", "", "session-x", nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + // No run in flight: RespondPermission must be a harmless no-op. + if err := s.RespondPermission("dsh_unknown", core.PermissionResult{Behavior: "allow"}); err != nil { + t.Fatalf("RespondPermission on idle session: %v", err) + } +} + func TestSend_Error(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") - script := fakeDSHScript(t, argvFile, true) + script := fakeDSHScript(t, argvFile, true, false) s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "session-test-2", nil) if err != nil { @@ -384,3 +485,87 @@ func TestListDSHSessions(t *testing.T) { t.Errorf("sessions[0].ID = %q, want session-bbb (newest first)", sessions[0].ID) } } + +// ── Session titles ─────────────────────────────────────────── + +func TestListDSHSessions_WithTitles(t *testing.T) { + home := t.TempDir() + t.Setenv("DSH_HOME", home) + workDir := t.TempDir() + root := dshSessionsRoot(workDir) + if root == "" { + t.Fatal("dshSessionsRoot empty") + } + + // Write a session with a title event (dsh stores logs zstd-compressed). + sessDir := filepath.Join(root, "session-aaa") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + log := `{"type":"session","data":{"id":"session-aaa","version":1}} +{"type":"user/message","data":{"message":{"role":"user","content":[{"type":"text","text":"帮我配置 superpowers"}]}}} +{"type":"assistant/message","data":{"message":{"role":"assistant","content":[{"type":"text","text":"好的"}]}}} +{"type":"session/title","data":{"title":"配置superpowers到dsh"}} +` + var buf bytes.Buffer + w, err := zstd.NewWriter(&buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(log)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessDir, "session.jsonl.zstd"), buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + sessions, err := listDSHSessions(workDir) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 1 { + t.Fatalf("listDSHSessions len = %d, want 1", len(sessions)) + } + if sessions[0].Summary != "配置superpowers到dsh" { + t.Errorf("Summary = %q, want title", sessions[0].Summary) + } + if sessions[0].MessageCount != 1 { + t.Errorf("MessageCount = %d, want 1", sessions[0].MessageCount) + } +} + +func TestListDSHSessions_FallbackToFirstUserMessage(t *testing.T) { + home := t.TempDir() + t.Setenv("DSH_HOME", home) + workDir := t.TempDir() + root := dshSessionsRoot(workDir) + sessDir := filepath.Join(root, "session-bbb") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + log := `{"type":"session","data":{"id":"session-bbb","version":1}} +{"type":"user/message","data":{"message":{"role":"user","content":[{"type":"text","text":"这是一个很长很长的第一条用户消息,用来测试标题回退逻辑"}]}}} +{"type":"assistant/message","data":{"message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}} +` + var buf bytes.Buffer + w, _ := zstd.NewWriter(&buf) + _, _ = w.Write([]byte(log)) + _ = w.Close() + if err := os.WriteFile(filepath.Join(sessDir, "session.jsonl.zstd"), buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + sessions, err := listDSHSessions(workDir) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 1 { + t.Fatalf("len = %d, want 1", len(sessions)) + } + if !strings.Contains(sessions[0].Summary, "这是一个很长很长") { + t.Errorf("Summary = %q, want first-user-message fallback", sessions[0].Summary) + } +} diff --git a/agent/dsh/models.go b/agent/dsh/models.go index 22f2463ee..04ff2214e 100644 --- a/agent/dsh/models.go +++ b/agent/dsh/models.go @@ -1,6 +1,9 @@ package dsh import ( + "bufio" + "bytes" + "encoding/json" "log/slog" "os" "path/filepath" @@ -8,6 +11,7 @@ import ( "strings" "github.com/chenhg5/cc-connect/core" + "github.com/klauspost/compress/zstd" "gopkg.in/yaml.v3" ) @@ -122,9 +126,10 @@ func dshSessionsRoot(workDir string) string { return filepath.Join(home, "sessions", encoded) } -// listDSHSessions lists persisted dsh sessions for the given workdir. -// Session summaries are not decoded (dsh stores them as zstd-compressed -// JSONL), so the session id is shown as the summary. +// listDSHSessions lists persisted dsh sessions for the given workdir with the +// same titles dsh web shows: the last `session/title` event in the session +// log (fallback: the first user message). dsh stores each session as a +// zstd-compressed JSONL log under $DSH_HOME/sessions///. func listDSHSessions(workDir string) ([]core.AgentSessionInfo, error) { root := dshSessionsRoot(workDir) if root == "" { @@ -146,10 +151,12 @@ func listDSHSessions(workDir string) ([]core.AgentSessionInfo, error) { if err != nil { continue } + title, msgCount := readDSHSessionSummary(filepath.Join(root, entry.Name())) sessions = append(sessions, core.AgentSessionInfo{ - ID: entry.Name(), - Summary: entry.Name(), - ModifiedAt: info.ModTime(), + ID: entry.Name(), + Summary: title, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), }) } sort.Slice(sessions, func(i, j int) bool { @@ -157,3 +164,102 @@ func listDSHSessions(workDir string) ([]core.AgentSessionInfo, error) { }) return sessions, nil } + +// readDSHSessionSummary opens a dsh session directory's log file +// (session.jsonl.zstd or the uncompressed session.jsonl), decodes it, and +// returns the session title (last `session/title` event, falling back to the +// first user message) plus the number of assistant messages (≈ turns). +func readDSHSessionSummary(sessionDir string) (title string, msgCount int) { + zpath := filepath.Join(sessionDir, "session.jsonl.zstd") + plain := filepath.Join(sessionDir, "session.jsonl") + + var data []byte + var err error + if _, statErr := os.Stat(zpath); statErr == nil { + data, err = os.ReadFile(zpath) + if err == nil { + dec, decErr := zstd.NewReader(nil, zstd.WithDecoderMaxMemory(64<<20)) + if decErr == nil { + defer dec.Close() + if out, dErr := dec.DecodeAll(data, nil); dErr == nil { + data = out + } else { + slog.Debug("dsh: zstd decode session", "path", zpath, "error", dErr) + return "", 0 + } + } + } + } else if _, statErr := os.Stat(plain); statErr == nil { + data, err = os.ReadFile(plain) + } + if err != nil || len(data) == 0 { + return "", 0 + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + var lastTitle string + var firstUser string + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var evt struct { + Type string `json:"type"` + Data struct { + Title string `json:"title"` + } `json:"data"` + } + if err := json.Unmarshal(line, &evt); err != nil { + continue + } + switch evt.Type { + case "session/title": + if t := strings.TrimSpace(evt.Data.Title); t != "" { + lastTitle = t + } + case "assistant/message": + msgCount++ + case "user/message": + if firstUser == "" { + firstUser = firstUserMessageText(line) + } + } + } + if lastTitle != "" { + return truncStr(lastTitle, 80), msgCount + } + if firstUser != "" { + return truncStr(firstUser, 80), msgCount + } + return "", msgCount +} + +// firstUserMessageText extracts the concatenated text blocks of a +// user/message event line. +func firstUserMessageText(line []byte) string { + var evt struct { + Data struct { + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"data"` + } + if err := json.Unmarshal(line, &evt); err != nil { + return "" + } + var sb strings.Builder + for _, block := range evt.Data.Message.Content { + if block.Type == "text" && block.Text != "" { + if sb.Len() > 0 { + sb.WriteString(" ") + } + sb.WriteString(block.Text) + } + } + return strings.TrimSpace(sb.String()) +} diff --git a/agent/dsh/session.go b/agent/dsh/session.go index 2d1214045..42effad72 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -4,7 +4,9 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" + "io" "log/slog" "os" "os/exec" @@ -19,9 +21,14 @@ import ( ) // dshSession runs a multi-turn dsh conversation. Each Send() spawns -// `dsh --profile headless` as a one-shot process; the persisted dsh session -// (identified by the cc-connect-owned session id) is resumed on every run, -// so context carries across turns. +// `dsh --profile headless --jsonl` as a one-shot process; the persisted dsh +// session (identified by the cc-connect-owned session id) is resumed on every +// run, so context carries across turns. +// +// With --jsonl the dsh headless runner streams events on stdout (text / +// thinking deltas, tool calls, approval requests, result/done envelopes) and +// reads approval responses from stdin, so cc-connect can show tool progress +// in the chat and relay permission decisions from the human (Feishu card). type dshSession struct { cmd string extraArgs []string // extra args from cmd, prepended before dsh args @@ -35,6 +42,15 @@ type dshSession struct { cancel context.CancelFunc sendWg sync.WaitGroup // tracks in-flight Send() calls alive atomic.Bool + + // per-run process wiring (nil between runs) + runMu sync.Mutex // guards stdin + pendingApprovals + stdin io.WriteCloser + pending map[string]struct{} // approval ids awaiting a human decision + + // resultSent is set once the JSONL reader emits the terminal EventResult + // (on the `done` envelope), so Send does not emit a duplicate. + resultSent atomic.Bool } // newDSHSession creates a session. sessionID is the cc-connect-persisted dsh @@ -66,10 +82,10 @@ func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, // ── Send ───────────────────────────────────────────────────── -// Send runs one headless dsh turn: spawns `dsh --profile headless -// --session-id [--model ] [--mode ] `, waits for the -// process to finish, then emits the final assistant text (stdout) and the -// turn result. stderr lines are surfaced as EventError when the run fails. +// Send runs one headless dsh turn in --jsonl mode: spawns +// `dsh --profile headless --session-id [--model ] [--mode ] --jsonl `, +// streams events into the events channel (text/thinking/tool calls), relays +// approval requests as permission cards, and finishes with an EventResult. func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttachment, files []core.FileAttachment) error { s.sendWg.Add(1) defer s.sendWg.Done() @@ -91,6 +107,10 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac } cmd.Env = env + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("dsh: stdin pipe: %w", err) + } stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("dsh: stdout pipe: %w", err) @@ -104,10 +124,32 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac return fmt.Errorf("dsh: start: %w", err) } - // Read the final assistant text (headless prints it on stdout and exits). - output, readErr := readAllString(stdout) + s.runMu.Lock() + s.stdin = stdinPipe + s.pending = make(map[string]struct{}) + s.runMu.Unlock() + + // Reader goroutine: stream JSONL events until the process exits. + finalText := new(string) + resultSent := new(atomic.Bool) + readerDone := make(chan struct{}) + go s.readJSONL(stdout, finalText, resultSent, readerDone) err = cmd.Wait() + // The reader drains stdout; wait for it to finish emitting (it may have + // buffered the trailing result/done lines). + <-readerDone + + // The turn is over — close stdin so the runner's readline releases and + // the process can exit cleanly. + s.runMu.Lock() + if s.stdin != nil { + _ = s.stdin.Close() + s.stdin = nil + } + s.pending = nil + s.runMu.Unlock() + stderrMsg := strings.TrimSpace(stderrBuf.String()) if err != nil { // emit() drops events once s.ctx is done (Close/cancel), so a @@ -115,26 +157,163 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac slog.Error("dshSession: process error", "cmd", s.cmd, "error", err, "stderr", truncStr(stderrMsg, 1000)) } - text := strings.TrimSpace(output) - if text != "" { - s.emit(core.Event{Type: core.EventText, Content: text}) - } - if readErr != nil { - s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: read stdout: %w", readErr)}) - } if stderrMsg != "" { // Surface actionable stderr (e.g. "dsh: MISSING_CREDENTIAL: ..."). s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: %s", truncStr(stderrMsg, 2000))}) } - if err != nil { + if err != nil && *finalText == "" { s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: %s", err)}) } - // Signal turn completion. - s.emit(core.Event{Type: core.EventResult, SessionID: s.CurrentSessionID(), Done: true}) + // Signal turn completion (EventResult with the full final text; the + // engine prefers event.Content over the accumulated stream deltas). + if !resultSent.Load() { + s.emit(core.Event{Type: core.EventResult, Content: *finalText, SessionID: s.CurrentSessionID(), Done: true}) + } + return nil +} + +// readJSONL reads the dsh --jsonl event stream and maps it to core.Events. +// It emits a terminal EventResult (Done: true) when the `done` envelope +// arrives (marking resultSent), then closes readerDone. +func (s *dshSession) readJSONL(stdout io.ReadCloser, finalText *string, resultSent *atomic.Bool, done chan struct{}) { + defer close(done) + defer func() { _ = stdout.Close() }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + var thinkingBuf strings.Builder + flushThinking := func() { + if thinkingBuf.Len() == 0 { + return + } + s.emit(core.Event{Type: core.EventThinking, Content: thinkingBuf.String()}) + thinkingBuf.Reset() + } + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var evt struct { + Type string `json:"type"` + Text string `json:"text"` + Name string `json:"name"` + Arguments string `json:"arguments"` + CallID string `json:"callId"` + Content string `json:"content"` + IsError bool `json:"isError"` + ID string `json:"id"` + ToolName string `json:"toolName"` + Reason string `json:"reason"` + Success bool `json:"success"` + SessionID string `json:"sessionId"` + Extra json.RawMessage `json:"-"` + } + if err := json.Unmarshal([]byte(line), &evt); err != nil { + slog.Debug("dshSession: non-JSON line", "line", truncStr(line, 100)) + continue + } + + switch evt.Type { + case "text": + if evt.Text == "" { + continue + } + flushThinking() + s.emit(core.Event{Type: core.EventText, Content: evt.Text}) + + case "thinking": + if evt.Text == "" { + continue + } + thinkingBuf.WriteString(evt.Text) + + case "tool/call": + flushThinking() + s.emit(core.Event{Type: core.EventToolUse, ToolName: evt.Name, ToolInput: truncStr(evt.Arguments, 500)}) + + case "tool/result": + flushThinking() + s.emit(core.Event{Type: core.EventToolResult, ToolName: evt.Name, Content: truncStr(evt.Content, 500)}) + + case "approval/request": + flushThinking() + requestID := "dsh_" + evt.ID + s.runMu.Lock() + if s.pending != nil { + s.pending[requestID] = struct{}{} + } + s.runMu.Unlock() + reason := evt.Reason + if reason == "" { + reason = fmt.Sprintf("tool %q requires your approval", evt.ToolName) + } + s.emit(core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: evt.ToolName, + ToolInput: reason, + ToolInputRaw: map[string]any{ + "toolName": evt.ToolName, + "reason": evt.Reason, + "callId": evt.CallID, + }, + }) + + case "result": + *finalText = evt.Text + + case "done": + flushThinking() + resultSent.Store(true) + s.emit(core.Event{Type: core.EventResult, Content: *finalText, SessionID: s.CurrentSessionID(), Done: true}) + return + } + } + + // Process exited without a done envelope (e.g. hard failure). + flushThinking() +} + +// ── RespondPermission ──────────────────────────────────────── + +// RespondPermission writes the human's decision back to the running dsh +// process's stdin (the headless runner's approval answerer waits for it). +func (s *dshSession) RespondPermission(requestID string, result core.PermissionResult) error { + s.runMu.Lock() + defer s.runMu.Unlock() + if s.stdin == nil { + return nil // no active run; nothing to answer + } + id := strings.TrimPrefix(requestID, "dsh_") + if _, ok := s.pending[requestID]; !ok { + slog.Warn("dshSession: RespondPermission for unknown request", "requestID", requestID) + return nil + } + outcome := "rejected" + if result.Behavior == "allow" { + outcome = "allowed-once" + } + line, err := json.Marshal(map[string]string{ + "type": "approval/response", + "id": id, + "outcome": outcome, + }) + if err != nil { + return fmt.Errorf("dsh: marshal approval response: %w", err) + } + _, err = s.stdin.Write(append(line, '\n')) + if err != nil { + return fmt.Errorf("dsh: write approval response: %w", err) + } return nil } +// ── buildArgs / buildPrompt ───────────────────────────────── + // buildPrompt saves attachments to disk and appends file references so the // dsh agent can read them with its own tools. func (s *dshSession) buildPrompt(msg, messageID string, images []core.ImageAttachment, files []core.FileAttachment) string { @@ -159,6 +338,7 @@ func (s *dshSession) buildArgs(prompt string) []string { if s.mode != "" { args = append(args, "--mode", s.mode) } + args = append(args, "--jsonl") return append(args, prompt) } @@ -184,16 +364,16 @@ func (s *dshSession) Alive() bool { return s.alive.Load() } -func (s *dshSession) RespondPermission(_ string, _ core.PermissionResult) error { - // Headless runs are one-shot: there is no interactive permission channel, - // so permission decisions are not forwarded. dsh's own approval seam - // fails closed under headless (no answerer) — mode controls that behavior. - return nil -} - func (s *dshSession) Close() error { s.alive.Store(false) s.cancel() + s.runMu.Lock() + if s.stdin != nil { + _ = s.stdin.Close() + s.stdin = nil + } + s.pending = nil + s.runMu.Unlock() s.sendWg.Wait() close(s.events) return nil @@ -201,20 +381,6 @@ func (s *dshSession) Close() error { // ── Helpers ────────────────────────────────────────────────── -func readAllString(r interface{ Read([]byte) (int, error) }) (string, error) { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) - var sb strings.Builder - for scanner.Scan() { - sb.WriteString(scanner.Text()) - sb.WriteString("\n") - } - if err := scanner.Err(); err != nil { - return sb.String(), err - } - return sb.String(), nil -} - // saveImages writes image attachments under workDir/.cc-connect/images and // returns their absolute paths (dsh cannot ingest image bytes through the // headless task text, so the agent is pointed at the files instead). diff --git a/go.mod b/go.mod index 16ad572bf..84a531a78 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-telegram/bot v1.20.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 + github.com/klauspost/compress v1.18.0 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/line/line-bot-sdk-go/v8 v8.19.0 github.com/mdp/qrterminal/v3 v3.2.1 @@ -23,6 +24,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.272.0 + gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.27.0 modernc.org/sqlite v1.49.1 rsc.io/qr v0.2.0 @@ -99,7 +101,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3b1775fb7..ed1fd3597 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= From aa1a2b0ab4e33c7b27c4553f6b6fbd6c54ff32d4 Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Fri, 14 Aug 2026 21:31:41 +0800 Subject: [PATCH 3/7] fix(dsh): address golangci-lint findings - errcheck: check s.Close() in tests - unused: drop the unused forceKillCmd helpers and the resultSent struct field (Send uses a local flag) --- agent/dsh/dsh_test.go | 8 ++++---- agent/dsh/proc_unix.go | 15 --------------- agent/dsh/proc_windows.go | 9 --------- agent/dsh/session.go | 3 --- 4 files changed, 4 insertions(+), 31 deletions(-) diff --git a/agent/dsh/dsh_test.go b/agent/dsh/dsh_test.go index 2305e10d1..872c109c2 100644 --- a/agent/dsh/dsh_test.go +++ b/agent/dsh/dsh_test.go @@ -284,7 +284,7 @@ func TestSend_Success(t *testing.T) { if err != nil { t.Fatal(err) } - defer s.Close() + defer func() { _ = s.Close() }() done := make(chan error, 1) go func() { done <- s.Send("please reply", "msg-1", nil, nil) }() @@ -353,7 +353,7 @@ func TestSend_Approval(t *testing.T) { if err != nil { t.Fatal(err) } - defer s.Close() + defer func() { _ = s.Close() }() done := make(chan error, 1) go func() { done <- s.Send("please reply", "msg-1", nil, nil) }() @@ -406,7 +406,7 @@ func TestRespondPermission_NoActiveRun(t *testing.T) { if err != nil { t.Fatal(err) } - defer s.Close() + defer func() { _ = s.Close() }() // No run in flight: RespondPermission must be a harmless no-op. if err := s.RespondPermission("dsh_unknown", core.PermissionResult{Behavior: "allow"}); err != nil { t.Fatalf("RespondPermission on idle session: %v", err) @@ -421,7 +421,7 @@ func TestSend_Error(t *testing.T) { if err != nil { t.Fatal(err) } - defer s.Close() + defer func() { _ = s.Close() }() done := make(chan error, 1) go func() { done <- s.Send("boom", "msg-1", nil, nil) }() diff --git a/agent/dsh/proc_unix.go b/agent/dsh/proc_unix.go index b5a1b7c77..950e042d9 100644 --- a/agent/dsh/proc_unix.go +++ b/agent/dsh/proc_unix.go @@ -3,8 +3,6 @@ package dsh import ( - "errors" - "os" "os/exec" "syscall" ) @@ -21,16 +19,3 @@ func prepareCmdForKill(cmd *exec.Cmd) { } cmd.SysProcAttr.Setpgid = true } - -// forceKillCmd SIGKILLs the entire process group rooted at cmd. -func forceKillCmd(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && - !errors.Is(err, os.ErrProcessDone) && - !errors.Is(err, syscall.ESRCH) { - return err - } - return nil -} diff --git a/agent/dsh/proc_windows.go b/agent/dsh/proc_windows.go index 502ce6e93..3266dd5a4 100644 --- a/agent/dsh/proc_windows.go +++ b/agent/dsh/proc_windows.go @@ -3,7 +3,6 @@ package dsh import ( - "os" "os/exec" "syscall" ) @@ -17,11 +16,3 @@ func prepareCmdForKill(cmd *exec.Cmd) { } cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP } - -// forceKillCmd delegates to exec.Cmd.Process.Kill on Windows. -func forceKillCmd(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return cmd.Process.Kill() -} diff --git a/agent/dsh/session.go b/agent/dsh/session.go index 42effad72..abf09428f 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -48,9 +48,6 @@ type dshSession struct { stdin io.WriteCloser pending map[string]struct{} // approval ids awaiting a human decision - // resultSent is set once the JSONL reader emits the terminal EventResult - // (on the `done` envelope), so Send does not emit a duplicate. - resultSent atomic.Bool } // newDSHSession creates a session. sessionID is the cc-connect-persisted dsh From bbd709d436ec74d068ccceae65f83d7be486403c Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Fri, 14 Aug 2026 21:34:58 +0800 Subject: [PATCH 4/7] fix(dsh): drain stdout to EOF before cmd.Wait cmd.Wait() closes the stdout pipe; waiting on it before the reader goroutine finishes could discard the JSONL lines still buffered in the pipe (tool calls, result/done envelopes), silently dropping events under race-detector scheduling. The reader now drains to EOF first (the process closing its write end), then Wait reaps the child. --- agent/dsh/session.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/agent/dsh/session.go b/agent/dsh/session.go index abf09428f..3f64e3c9a 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -132,10 +132,12 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac readerDone := make(chan struct{}) go s.readJSONL(stdout, finalText, resultSent, readerDone) - err = cmd.Wait() - // The reader drains stdout; wait for it to finish emitting (it may have - // buffered the trailing result/done lines). + // Drain stdout to EOF BEFORE cmd.Wait(): Wait() closes the stdout pipe, + // and a concurrent close would discard the lines still buffered in the + // pipe (tool calls, the result/done envelopes). The reader reaches EOF + // when the process exits and closes its write end. <-readerDone + err = cmd.Wait() // The turn is over — close stdin so the runner's readline releases and // the process can exit cleanly. From 72419afc2bf279e88b588763c7bb18ba802c089e Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Sat, 15 Aug 2026 01:08:05 +0800 Subject: [PATCH 5/7] fix(dsh): close stdin before cmd.Wait so the turn never hangs After the turn's done envelope, the dsh process waits for the driver to close stdin before its approval readline sees EOF and the one-shot exits. Send held stdin open until cmd.Wait returned, so a completed turn left the session busy forever (second messages were queued until /stop killed the process). Close stdin right after the reader drains stdout, before Wait. --- agent/dsh/session.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/agent/dsh/session.go b/agent/dsh/session.go index 3f64e3c9a..b7cb16c41 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -137,10 +137,10 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac // pipe (tool calls, the result/done envelopes). The reader reaches EOF // when the process exits and closes its write end. <-readerDone - err = cmd.Wait() - // The turn is over — close stdin so the runner's readline releases and - // the process can exit cleanly. + // The turn is over — close stdin BEFORE cmd.Wait() so the dsh runner's + // approval readline sees EOF and the one-shot process can exit promptly + // (it would otherwise wait for the driver to close stdin). s.runMu.Lock() if s.stdin != nil { _ = s.stdin.Close() @@ -149,6 +149,8 @@ func (s *dshSession) Send(msg string, messageID string, images []core.ImageAttac s.pending = nil s.runMu.Unlock() + err = cmd.Wait() + stderrMsg := strings.TrimSpace(stderrBuf.String()) if err != nil { // emit() drops events once s.ctx is done (Close/cancel), so a From a08a0eef268e41e9edf5fa89f793ac9a3d939ff6 Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Wed, 19 Aug 2026 22:55:54 +0800 Subject: [PATCH 6/7] feat(dsh): add runtime model catalog and preset controls --- agent/dsh/dsh.go | 69 +++++++- agent/dsh/dsh_test.go | 115 +++++++++++- agent/dsh/models.go | 221 ++++++++++++++++++++++- agent/dsh/session.go | 38 +++- config.example.toml | 9 +- core/cuj_test.go | 53 ++++++ core/engine.go | 342 +++++++++++++++++++++++++++++++++--- core/i18n.go | 137 ++++++++++++--- core/interfaces.go | 48 ++++- core/model_provider_test.go | 45 +++++ core/preset_test.go | 156 ++++++++++++++++ core/session.go | 41 +++-- 12 files changed, 1189 insertions(+), 85 deletions(-) create mode 100644 core/model_provider_test.go create mode 100644 core/preset_test.go diff --git a/agent/dsh/dsh.go b/agent/dsh/dsh.go index 30be702b3..dc6946300 100644 --- a/agent/dsh/dsh.go +++ b/agent/dsh/dsh.go @@ -5,8 +5,10 @@ // dsh.nix `headless-cc-connect.patch`) accepts: // // --session-id create-or-resume a persisted dsh session with this id +// --provider override the provider route for this run // --model override the default model for this run // --mode pin sandbox/approval knobs for this run +// --preset select/recompose a blank session's agent preset // // The session id is owned by cc-connect: the engine's StartSession(sessionID) // is used directly as the dsh session id, so a multi-turn Feishu conversation @@ -36,6 +38,7 @@ type Agent struct { configEnv []string // env vars from [projects.agent.options.env] workDir string model string // model override ("" = use dsh settings default) + provider string // provider route override ("" = use dsh settings default) mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" sessionEnv []string mu sync.Mutex @@ -48,6 +51,10 @@ func New(opts map[string]any) (core.Agent, error) { workDir = "." } model, _ := opts["model"].(string) + provider, _ := opts["provider"].(string) + if strings.TrimSpace(provider) == "" { + provider = readDefaultProvider() + } mode, _ := opts["mode"].(string) mode = normalizeMode(mode) @@ -63,6 +70,7 @@ func New(opts map[string]any) (core.Agent, error) { configEnv: core.ParseConfigEnv(opts), workDir: workDir, model: model, + provider: strings.TrimSpace(provider), mode: mode, }, nil } @@ -134,15 +142,56 @@ func (a *Agent) GetModel() string { return "" } -func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { - if models := readSettingsModels(); len(models) > 0 { +// GetModelProvider returns the provider route used by the next dsh run. +func (a *Agent) GetModelProvider() string { + a.mu.Lock() + provider := a.provider + a.mu.Unlock() + if provider != "" { + return provider + } + return readDefaultProvider() +} + +// SetModelForProvider switches both halves of a dsh model selection. dsh's +// native runner keeps provider and model separate, so changing only the model +// would send a valid id to the wrong route when catalogs overlap. +func (a *Agent) SetModelForProvider(provider, model string) { + a.mu.Lock() + a.provider = strings.TrimSpace(provider) + a.model = strings.TrimSpace(model) + a.mu.Unlock() + slog.Info("dsh: model route changed", "provider", provider, "model", model) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.readRuntimeModelCatalog(ctx); len(models) > 0 { + return appendDeepSeekFallback(models, readSettingsModels("deepseek-official")) + } + if models := readSettingsModels("deepseek-official"); len(models) > 0 { return models } // Fallback: the catalog dsh ships for the deepseek-official provider. - return []core.ModelOption{ - {Name: "deepseek-v4-flash", Desc: "DeepSeek-V4-Flash"}, - {Name: "deepseek-v4-pro", Desc: "DeepSeek-V4-Pro"}, + models := []core.ModelOption{ + {Name: "deepseek-v4-flash", Desc: "DeepSeek-V4-Flash", Provider: "deepseek-official"}, + {Name: "deepseek-v4-pro", Desc: "DeepSeek-V4-Pro", Provider: "deepseek-official"}, } + if current := a.GetModel(); current != "" { + seen := false + for _, model := range models { + if model.Name == current { + seen = true + break + } + } + if !seen { + models = append(models, core.ModelOption{ + Name: current, + Provider: a.GetModelProvider(), + }) + } + } + return models } // ── ModeSwitcher ───────────────────────────────────────────── @@ -204,15 +253,23 @@ func (a *Agent) SkillDirs() []string { // ── Session lifecycle ──────────────────────────────────────── func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + return a.StartSessionWithPreset(ctx, sessionID, "") +} + +// StartSessionWithPreset carries cc-connect's per-session preset choice to the +// headless runner. dsh itself owns composition and durable selection events; +// this adapter only forwards the requested id. +func (a *Agent) StartSessionWithPreset(ctx context.Context, sessionID, preset string) (core.AgentSession, error) { a.mu.Lock() mode := a.mode model := a.model + provider := a.provider extraArgs := append([]string{}, a.cliExtraArgs...) extraEnv := append([]string(nil), a.configEnv...) extraEnv = append(extraEnv, a.sessionEnv...) workDir := a.workDir a.mu.Unlock() - return newDSHSession(ctx, a.cmd, extraArgs, workDir, model, mode, sessionID, extraEnv) + return newDSHSession(ctx, a.cmd, extraArgs, workDir, provider, model, mode, preset, sessionID, extraEnv) } func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { diff --git a/agent/dsh/dsh_test.go b/agent/dsh/dsh_test.go index 872c109c2..d707800d4 100644 --- a/agent/dsh/dsh_test.go +++ b/agent/dsh/dsh_test.go @@ -109,6 +109,18 @@ func TestSetGetModel(t *testing.T) { } } +func TestSetModelForProvider(t *testing.T) { + ag, _ := New(map[string]any{"cmd": "echo"}) + a := ag.(*Agent) + a.SetModelForProvider("openrouter", "deepseek/deepseek-v4-pro") + if got := a.GetModel(); got != "deepseek/deepseek-v4-pro" { + t.Errorf("GetModel() = %q", got) + } + if got := a.GetModelProvider(); got != "openrouter" { + t.Errorf("GetModelProvider() = %q", got) + } +} + func TestSetGetMode(t *testing.T) { ag, _ := New(map[string]any{"cmd": "echo"}) a := ag.(*Agent) @@ -118,6 +130,25 @@ func TestSetGetMode(t *testing.T) { } } +func TestSessionPreset(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "session-preset", nil) + if err != nil { + t.Fatal(err) + } + if got := s.GetPreset(); got != "" { + t.Fatalf("initial preset = %q, want empty", got) + } + if err := s.SetPreset("minimal"); err != nil { + t.Fatal(err) + } + if got := s.GetPreset(); got != "minimal" { + t.Fatalf("preset = %q, want minimal", got) + } + if err := s.SetPreset(" "); err == nil { + t.Fatal("SetPreset with empty name should fail") + } +} + func TestPermissionModes(t *testing.T) { ag, _ := New(map[string]any{"cmd": "echo"}) modes := ag.(*Agent).PermissionModes() @@ -176,6 +207,30 @@ llm-deepseek: } } +func TestAvailableModels_FromRuntimeCatalog(t *testing.T) { + home := t.TempDir() + script := filepath.Join(home, "fake-dsh") + content := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"models\",\"models\":[{\"provider\":\"openrouter\",\"id\":\"deepseek/deepseek-v4-pro\",\"name\":\"DeepSeek V4 Pro\"},{\"provider\":\"openai\",\"id\":\"gpt-5.6-luna\",\"name\":\"GPT 5.6 Luna\"}]}'\n" + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("DSH_HOME", filepath.Join(home, "dsh-home")) + ag, err := New(map[string]any{"cmd": script}) + if err != nil { + t.Fatal(err) + } + models := ag.(*Agent).AvailableModels(context.Background()) + if len(models) != 4 { + t.Fatalf("AvailableModels len = %d, want runtime catalog plus DeepSeek fallback: %+v", len(models), models) + } + if models[0].Provider != "openrouter" || models[0].Name != "deepseek/deepseek-v4-pro" { + t.Errorf("unexpected first runtime model: %+v", models[0]) + } + if !strings.Contains(models[1].Desc, "openai") { + t.Errorf("runtime model description = %q, want provider label", models[1].Desc) + } +} + func TestGetModel_FallsBackToSettings(t *testing.T) { home := t.TempDir() if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(` @@ -192,22 +247,51 @@ agent-default-model: } } +func TestAvailablePresets_FromUserRoot(t *testing.T) { + home := t.TempDir() + userRoot := filepath.Join(home, ".agent-presets", "minimal") + if err := os.MkdirAll(userRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte("agent-presets:\n default: minimal\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userRoot, "agent.cordis.yml"), []byte("[]\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userRoot, "preset.yml"), []byte("name: Minimal\ndescription: Small surface\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("DSH_HOME", home) + ag, err := New(map[string]any{"cmd": "echo"}) + if err != nil { + t.Fatal(err) + } + presets := ag.(*Agent).AvailablePresets(context.Background()) + if len(presets) != 1 { + t.Fatalf("presets = %+v, want one entry", presets) + } + if presets[0].ID != "minimal" || !presets[0].Default || presets[0].Name != "Minimal" { + t.Fatalf("preset = %+v", presets[0]) + } +} + // ── buildArgs ──────────────────────────────────────────────── func TestBuildArgs(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "confirm", "session-abc", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "confirm", "codex", "session-abc", nil) if err != nil { t.Fatal(err) } args := s.buildArgs("hello world") - want := []string{"--profile", "headless", "--session-id", "session-abc", "--mode", "confirm", "--jsonl", "hello world"} + want := []string{"--profile", "headless", "--session-id", "session-abc", "--mode", "confirm", "--preset", "codex", "--jsonl", "hello world"} if strings.Join(args, "\x00") != strings.Join(want, "\x00") { t.Errorf("buildArgs() = %v, want %v", args, want) } } func TestBuildArgs_WithModel(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "deepseek-v4-pro", "danger-full-access", "session-abc", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "deepseek-v4-pro", "danger-full-access", "", "session-abc", nil) if err != nil { t.Fatal(err) } @@ -220,8 +304,22 @@ func TestBuildArgs_WithModel(t *testing.T) { } } +func TestBuildArgs_WithProvider(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "openrouter", "deepseek/deepseek-v4-pro", "", "", "session-abc", nil) + if err != nil { + t.Fatal(err) + } + args := s.buildArgs("task") + joined := strings.Join(args, " ") + for _, want := range []string{"--provider openrouter", "--model deepseek/deepseek-v4-pro"} { + if !strings.Contains(joined, want) { + t.Errorf("buildArgs() = %v missing %q", args, want) + } + } +} + func TestSessionID_GeneratedWhenEmpty(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "", nil) if err != nil { t.Fatal(err) } @@ -280,7 +378,7 @@ func TestSend_Success(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, false, false) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "deepseek-v4-flash", "confirm", "session-test-1", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "deepseek-v4-flash", "confirm", "", "session-test-1", nil) if err != nil { t.Fatal(err) } @@ -349,7 +447,7 @@ func TestSend_Approval(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, false, true) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "confirm", "session-approval", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "confirm", "", "session-approval", nil) if err != nil { t.Fatal(err) } @@ -389,6 +487,7 @@ loop: if permReq == nil { t.Fatal("expected EventPermissionRequest") + return } if permReq.ToolName != "bash" || !strings.HasPrefix(permReq.RequestID, "dsh_") { t.Errorf("EventPermissionRequest = %+v", permReq) @@ -402,7 +501,7 @@ loop: } func TestRespondPermission_NoActiveRun(t *testing.T) { - s, err := newDSHSession(context.Background(), "echo", nil, t.TempDir(), "", "", "session-x", nil) + s, err := newDSHSession(context.Background(), "echo", nil, t.TempDir(), "", "", "", "", "session-x", nil) if err != nil { t.Fatal(err) } @@ -417,7 +516,7 @@ func TestSend_Error(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, true, false) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "session-test-2", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "", "", "session-test-2", nil) if err != nil { t.Fatal(err) } diff --git a/agent/dsh/models.go b/agent/dsh/models.go index 04ff2214e..3418b9263 100644 --- a/agent/dsh/models.go +++ b/agent/dsh/models.go @@ -3,9 +3,11 @@ package dsh import ( "bufio" "bytes" + "context" "encoding/json" "log/slog" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -41,6 +43,213 @@ type dshSettings struct { Name string `yaml:"name"` } `yaml:"models"` } `yaml:"llm-deepseek"` + AgentPresets struct { + Default string `yaml:"default"` + } `yaml:"agent-presets"` +} + +type dshModelCatalogEntry struct { + Provider string `json:"provider"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type dshModelCatalogResponse struct { + Type string `json:"type"` + Models []dshModelCatalogEntry `json:"models"` +} + +// readRuntimeModelCatalog asks the running dsh composition for the same model +// directory that Web uses. This deliberately avoids reimplementing pi-ai's +// built-in catalogs, Nix-injected model rows, and settings/profile merge rules +// in Go. +func (a *Agent) readRuntimeModelCatalog(ctx context.Context) []core.ModelOption { + a.mu.Lock() + cmdName := a.cmd + extraArgs := append([]string(nil), a.cliExtraArgs...) + configEnv := append([]string(nil), a.configEnv...) + workDir := a.workDir + a.mu.Unlock() + + args := append(extraArgs, "--profile", "headless", "--list-models") + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = workDir + cmd.Env = core.MergeEnv(os.Environ(), configEnv) + output, err := cmd.Output() + if err != nil { + slog.Debug("dsh: runtime model catalog unavailable", "error", err) + return nil + } + + var response dshModelCatalogResponse + for _, line := range bytes.Split(output, []byte{'\n'}) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var candidate dshModelCatalogResponse + if json.Unmarshal(line, &candidate) == nil && candidate.Type == "models" { + response = candidate + break + } + } + if len(response.Models) == 0 { + return nil + } + return modelOptionsFromCatalog(response.Models) +} + +func modelOptionsFromCatalog(entries []dshModelCatalogEntry) []core.ModelOption { + seen := make(map[string]struct{}, len(entries)) + aliasCounts := make(map[string]int) + aliases := make([]string, len(entries)) + for i, entry := range entries { + id := strings.TrimSpace(entry.ID) + provider := strings.TrimSpace(entry.Provider) + if id == "" || provider == "" { + continue + } + alias := id + if idx := strings.LastIndex(alias, "/"); idx >= 0 && idx+1 < len(alias) { + alias = alias[idx+1:] + } + aliases[i] = alias + aliasCounts[strings.ToLower(alias)]++ + } + + models := make([]core.ModelOption, 0, len(entries)) + for i, entry := range entries { + id := strings.TrimSpace(entry.ID) + provider := strings.TrimSpace(entry.Provider) + if id == "" || provider == "" { + continue + } + key := provider + "\x00" + id + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + desc := strings.TrimSpace(entry.Name) + if desc == "" { + desc = strings.TrimSpace(entry.Description) + } + if desc == "" { + desc = id + } + desc += " [" + provider + "]" + option := core.ModelOption{Name: id, Desc: desc, Provider: provider} + if alias := aliases[i]; alias != "" && aliasCounts[strings.ToLower(alias)] == 1 && alias != id { + option.Alias = alias + } + models = append(models, option) + } + return models +} + +func appendDeepSeekFallback(models, configured []core.ModelOption) []core.ModelOption { + for _, model := range models { + if model.Provider == "deepseek-official" { + return models + } + } + if len(configured) == 0 { + configured = []core.ModelOption{ + {Name: "deepseek-v4-flash", Desc: "DeepSeek-V4-Flash", Provider: "deepseek-official"}, + {Name: "deepseek-v4-pro", Desc: "DeepSeek-V4-Pro", Provider: "deepseek-official"}, + } + } + return append(models, configured...) +} + +// dshPresetMetadata is the display-only part of a native dsh preset. +type dshPresetMetadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Order int `yaml:"order"` +} + +// AvailablePresets discovers the same two roots used by dsh's native roster: +// the installed deployment presets first, then the user's ~/.dsh presets. +// The Go side only presents the roster to cc-connect; dsh remains the source +// of truth for parsing and mounting a selected composition. +func (a *Agent) AvailablePresets(_ context.Context) []core.PresetOption { + defaultID := "" + if settings := readDSHSettings(); settings != nil { + defaultID = strings.TrimSpace(settings.AgentPresets.Default) + } + + roots := []struct { + path string + trust string + }{ + {path: dshInstallPresetRoot(a.cmd), trust: "system"}, + {path: filepath.Join(dshHomeDir(), ".agent-presets"), trust: "user"}, + } + + seen := make(map[string]struct{}) + var presets []core.PresetOption + for _, root := range roots { + entries, err := os.ReadDir(root.path) + if err != nil { + continue + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + id := entry.Name() + if !entry.IsDir() || !validPresetID(id) { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + + option := core.PresetOption{ID: id, Trust: root.trust, Default: id == defaultID} + composition := filepath.Join(root.path, id, "agent.cordis.yml") + if info, statErr := os.Stat(composition); statErr != nil || !info.Mode().IsRegular() { + option.Broken = "agent.cordis.yml is missing" + } else if data, readErr := os.ReadFile(filepath.Join(root.path, id, "preset.yml")); readErr == nil { + var metadata dshPresetMetadata + if yamlErr := yaml.Unmarshal(data, &metadata); yamlErr == nil { + option.Name = strings.TrimSpace(metadata.Name) + option.Description = strings.TrimSpace(metadata.Description) + } + } + presets = append(presets, option) + } + } + return presets +} + +func validPresetID(id string) bool { + if id == "" { + return false + } + first := id[0] + if (first < 'a' || first > 'z') && (first < '0' || first > '9') { + return false + } + for _, r := range id[1:] { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + return true +} + +func dshInstallPresetRoot(cmd string) string { + resolved, err := exec.LookPath(cmd) + if err != nil { + return "" + } + resolved, err = filepath.EvalSymlinks(resolved) + if err != nil { + return "" + } + // Installed dsh layout: /apps/cli/config/agent-presets. + return filepath.Join(filepath.Dir(filepath.Dir(resolved)), "apps", "cli", "config", "agent-presets") } // settingsPath returns $DSH_HOME/settings.yaml. @@ -82,9 +291,17 @@ func readDefaultModel() (string, error) { return s.AgentDefaultModel.Model, nil } +func readDefaultProvider() string { + s := readDSHSettings() + if s == nil { + return "" + } + return strings.TrimSpace(s.AgentDefaultModel.Provider) +} + // readSettingsModels returns the advisory model catalog dsh knows about: // the `llm-deepseek.models` section of settings.yaml when present, else nil. -func readSettingsModels() []core.ModelOption { +func readSettingsModels(provider string) []core.ModelOption { s := readDSHSettings() if s == nil || len(s.LLMDeepSeek.Models) == 0 { return nil @@ -94,7 +311,7 @@ func readSettingsModels() []core.ModelOption { if m.ID == "" { continue } - option := core.ModelOption{Name: m.ID} + option := core.ModelOption{Name: m.ID, Provider: provider} if m.Name != "" { option.Desc = m.Name } diff --git a/agent/dsh/session.go b/agent/dsh/session.go index b7cb16c41..e6545da89 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -21,7 +21,8 @@ import ( ) // dshSession runs a multi-turn dsh conversation. Each Send() spawns -// `dsh --profile headless --jsonl` as a one-shot process; the persisted dsh +// `dsh --profile headless --provider ... --model ... --jsonl` as a one-shot process; +// the persisted dsh // session (identified by the cc-connect-owned session id) is resumed on every // run, so context carries across turns. // @@ -33,8 +34,10 @@ type dshSession struct { cmd string extraArgs []string // extra args from cmd, prepended before dsh args workDir string + provider string // provider route override ("" = use dsh settings default) model string // "" = use dsh settings default mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" + preset string // dsh agent preset requested for this session's next run extraEnv []string events chan core.Event sessionID atomic.Value @@ -53,14 +56,16 @@ type dshSession struct { // newDSHSession creates a session. sessionID is the cc-connect-persisted dsh // session id (empty on the first message); when empty, one is generated so // every run after the first resumes the same conversation. -func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, sessionID string, extraEnv []string) (*dshSession, error) { +func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, provider, model, mode, preset, sessionID string, extraEnv []string) (*dshSession, error) { ctx, cancel := context.WithCancel(ctx) s := &dshSession{ cmd: cmd, extraArgs: extraArgs, workDir: workDir, + provider: strings.TrimSpace(provider), model: model, mode: mode, + preset: strings.TrimSpace(preset), extraEnv: extraEnv, events: make(chan core.Event, 64), ctx: ctx, @@ -333,12 +338,21 @@ func (s *dshSession) buildPrompt(msg, messageID string, images []core.ImageAttac func (s *dshSession) buildArgs(prompt string) []string { args := append([]string{}, s.extraArgs...) args = append(args, "--profile", "headless", "--session-id", s.CurrentSessionID()) + if s.provider != "" { + args = append(args, "--provider", s.provider) + } if s.model != "" { args = append(args, "--model", s.model) } if s.mode != "" { args = append(args, "--mode", s.mode) } + s.runMu.Lock() + preset := s.preset + s.runMu.Unlock() + if preset != "" { + args = append(args, "--preset", preset) + } args = append(args, "--jsonl") return append(args, prompt) } @@ -365,6 +379,26 @@ func (s *dshSession) Alive() bool { return s.alive.Load() } +// SetPreset stores the preset to apply before the next headless turn. The +// dsh runner performs the blank-session check and writes its durable selection +// event; this object only carries the command choice across process launches. +func (s *dshSession) SetPreset(preset string) error { + preset = strings.TrimSpace(preset) + if preset == "" { + return fmt.Errorf("dsh: preset name must not be empty") + } + s.runMu.Lock() + s.preset = preset + s.runMu.Unlock() + return nil +} + +func (s *dshSession) GetPreset() string { + s.runMu.Lock() + defer s.runMu.Unlock() + return s.preset +} + func (s *dshSession) Close() error { s.alive.Store(false) s.cancel() diff --git a/config.example.toml b/config.example.toml index f2ccda972..fbf80f536 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1927,8 +1927,10 @@ app_secret = "your-feishu-app-secret" # final assistant text back to the chat. # # The installed dsh must carry the headless-cc-connect patch (see the user's -# home-manager agent/dsh/dsh.nix), which adds --session-id / --model / --mode -# support to the headless runner. +# home-manager agent/dsh/dsh.nix), which adds --session-id / --provider / +# --model / --mode / --preset / --list-models / --jsonl support to the +# headless runner. `--list-models` must return the dsh runtime provider/model +# catalog so `/model` does not fall back to only the two native DeepSeek models. # # Prerequisites / 前置条件: # 1. Install dsh per https://github.com/deepseek-ai/deepseek-harness @@ -1949,7 +1951,10 @@ app_secret = "your-feishu-app-secret" # mode = "confirm" # "read-only" | "workspace-write" | "danger-full-access" | "confirm" # # (confirm = full access + ask, matches the dsh web profile default; # # headless 无审批端时 confirm 等同 danger-full-access) +# # provider = "openrouter" # optional / 可选; `/model` switches this together with the model # # model = "deepseek-v4-flash" # optional / 可选; 未配置时使用 dsh settings 中的默认模型 +# # `/model` reads dsh's live catalog; `/preset` lists/selects a blank-session preset +# # and `/mode` selects the permission mode. Preset changes are locked after history starts. # # # Optional: point at a custom dsh binary (absolute path recommended under systemd). # # 可选:自定义 dsh 可执行文件路径(systemd 环境下建议使用绝对路径) diff --git a/core/cuj_test.go b/core/cuj_test.go index f62495f91..9ea0172ba 100644 --- a/core/cuj_test.go +++ b/core/cuj_test.go @@ -1745,6 +1745,59 @@ func TestCUJ_F2_ModelSwitchLinkedToAgent(t *testing.T) { t.Log("CUJ-F2: model switching is per-agent; covered by agent/*_test.go model tests") } +// CUJ-F5 · /preset returns a card and the card action changes the blank session. +func TestCUJ_F5_PresetCardSwitchesBlankSession(t *testing.T) { + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + agent := &presetTestAgent{options: []PresetOption{ + {ID: "standard", Name: "Standard", Default: true}, + {ID: "minimal", Name: "Minimal"}, + }} + e := NewEngine("test", agent, []Platform{p}, filepath.Join(t.TempDir(), "sessions.json"), LangEnglish) + sessionKey := "feishu:chat:user1" + + // Action 1: the user sends /preset through the normal platform entrypoint. + e.ReceiveMessage(p, &Message{ + SessionKey: sessionKey, + Platform: "feishu", + MessageID: "preset-1", + UserID: "user1", + UserName: "user1", + Content: "/preset", + ReplyCtx: "ctx", + }) + if len(p.repliedCards) != 1 { + t.Fatalf("user-visible preset cards = %d, want 1", len(p.repliedCards)) + } + var hasSelect bool + for _, element := range p.repliedCards[0].Elements { + if _, ok := element.(CardSelect); ok { + hasSelect = true + break + } + } + if !hasSelect { + t.Fatal("/preset card has no selectable preset control") + } + + // Action 2: the user selects minimal from the card. + result := e.handleCardNav("act:/preset minimal", sessionKey) + if result == nil || !strings.Contains(result.RenderText(), "Preset switched to `minimal`") { + t.Fatalf("preset selection result card = %#v", result) + } + if got := e.sessions.GetOrCreateActive(sessionKey).GetAgentPreset(); got != "minimal" { + t.Fatalf("session preset = %q, want minimal", got) + } + + // Action 3: the user opens the change control again from the result card. + card := e.handleCardNav("nav:/preset", sessionKey) + if card == nil { + t.Fatal("preset result card did not return to preset selector") + } + if text := card.RenderText(); !strings.Contains(text, "minimal") { + t.Fatalf("returned preset card = %q, want current minimal", text) + } +} + // CUJ-F3 · /lang switches i18n locale; next reply uses new language. func TestCUJ_F3_LangSwitchChangesReplyLanguage(t *testing.T) { env := newCUJEnv(t) diff --git a/core/engine.go b/core/engine.go index 9b8c5e74f..bfa0d2289 100644 --- a/core/engine.go +++ b/core/engine.go @@ -657,9 +657,10 @@ type deleteModeState struct { } type modelSwitchState struct { - phase string - target string - result string + phase string + target string + provider string + result string } // pendingPermission represents a permission request waiting for user response. @@ -4088,7 +4089,13 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, } isResume := startSessionID != "" startAt := time.Now() - agentSession, err := agent.StartSession(e.ctx, startSessionID) + var agentSession AgentSession + var err error + if starter, ok := agent.(AgentPresetStarter); ok { + agentSession, err = starter.StartSessionWithPreset(e.ctx, startSessionID, session.GetAgentPreset()) + } else { + agentSession, err = agent.StartSession(e.ctx, startSessionID) + } startElapsed := time.Since(startAt) if err != nil { // If resume/continue failed, try a fresh session as fallback. @@ -4101,7 +4108,11 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, session.SetAgentSessionID("", agent.Name()) sessions.Save() startAt = time.Now() - agentSession, err = agent.StartSession(e.ctx, "") + if starter, ok := agent.(AgentPresetStarter); ok { + agentSession, err = starter.StartSessionWithPreset(e.ctx, "", session.GetAgentPreset()) + } else { + agentSession, err = agent.StartSession(e.ctx, "") + } startElapsed = time.Since(startAt) if err == nil { slog.Info("fresh session started after resume failure", @@ -6261,6 +6272,7 @@ var builtinCommands = []struct { {[]string{"model"}, "model"}, {[]string{"reasoning", "effort"}, "reasoning"}, {[]string{"mode"}, "mode"}, + {[]string{"preset"}, "preset"}, {[]string{"lang"}, "lang"}, {[]string{"quiet"}, "quiet"}, {[]string{"provider"}, "provider"}, @@ -6472,6 +6484,8 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { e.cmdReasoning(p, msg, args) case "mode": e.cmdMode(p, msg, args) + case "preset": + e.cmdPreset(p, msg, args) case "lang": e.cmdLang(p, msg, args) case "quiet": @@ -9253,6 +9267,7 @@ func helpCardGroups() []helpCardGroup { {command: "/model", action: "nav:/model"}, {command: "/reasoning", action: "nav:/reasoning"}, {command: "/mode", action: "nav:/mode"}, + {command: "/preset", action: "nav:/preset"}, {command: "/lang", action: "nav:/lang"}, {command: "/provider", action: "nav:/provider"}, {command: "/memory", action: "cmd:/memory"}, @@ -9524,6 +9539,10 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { var sb strings.Builder current := switcher.GetModel() + currentProvider := "" + if providerAware, ok := agent.(ProviderModelSwitcher); ok { + currentProvider = providerAware.GetModelProvider() + } if current == "" { sb.WriteString(e.i18n.T(MsgModelDefault)) } else { @@ -9536,7 +9555,7 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { var row []ButtonOption for i, m := range models { marker := " " - if m.Name == current { + if modelOptionIsCurrent(m, current, currentProvider) { marker = "> " } var line string @@ -9555,7 +9574,7 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { if m.Alias != "" { label = m.Alias } - if m.Name == current { + if modelOptionIsCurrent(m, current, currentProvider) { label = "▶ " + label } row = append(row, ButtonOption{Text: label, Data: fmt.Sprintf("cmd:/model switch %d", i+1)}) @@ -9583,14 +9602,15 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { } target := strings.TrimSpace(targetInput) + targetProvider := "" if modelSwitchNeedsLookup(target) { fetchCtx, cancel := context.WithTimeout(e.ctx, 10*time.Second) defer cancel() models := switcher.AvailableModels(fetchCtx) - target = resolveModelSwitchTarget(target, models) + target, targetProvider = resolveModelSwitchSelection(target, models) } - target, err = e.switchModelOnAgent(agent, target, agent == e.agent) + target, err = e.switchModelSelectionOnAgent(agent, target, targetProvider, agent == e.agent) if err != nil { e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgModelChangeFailed, err)) return @@ -9618,20 +9638,33 @@ func resolveModelAlias(models []ModelOption, input string) string { return input } -func resolveModelSwitchTarget(input string, models []ModelOption) string { +// resolveModelSwitchSelection resolves both the model id and its provider. +// Provider is carried by catalog entries so selecting by number/alias does not +// accidentally send a model id to a different route with the same name. +func resolveModelSwitchSelection(input string, models []ModelOption) (string, string) { input = strings.TrimSpace(input) if idx, err := strconv.Atoi(input); err == nil && idx >= 1 && idx <= len(models) { - return models[idx-1].Name + model := models[idx-1] + return model.Name, model.Provider } - if resolved := resolveModelAlias(models, input); resolved != input { - return resolved + for _, model := range models { + if model.Alias != "" && strings.EqualFold(model.Alias, input) { + return model.Name, model.Provider + } } - for _, m := range models { - if strings.EqualFold(m.Name, input) { - return m.Name + for _, model := range models { + if strings.EqualFold(model.Name, input) { + return model.Name, model.Provider } } - return input + return input, "" +} + +func modelOptionIsCurrent(option ModelOption, current, provider string) bool { + if option.Name != current { + return false + } + return provider == "" || option.Provider == "" || option.Provider == provider } func modelSwitchNeedsLookup(input string) bool { @@ -9718,6 +9751,19 @@ func (e *Engine) switchModelOnAgent(agent Agent, target string, persistConfig bo return target, nil } +// switchModelSelectionOnAgent applies a model selection that may carry an +// explicit provider route. Agents without the optional provider-aware seam +// retain the historical model-only behavior. +func (e *Engine) switchModelSelectionOnAgent(agent Agent, target, provider string, persistConfig bool) (string, error) { + if provider != "" { + if switcher, ok := agent.(ProviderModelSwitcher); ok { + switcher.SetModelForProvider(provider, target) + return target, nil + } + } + return e.switchModelOnAgent(agent, target, persistConfig) +} + func (e *Engine) cmdReasoning(p Platform, msg *Message, args []string) { agent, sessions, _, err := e.commandContext(p, msg) if err != nil { @@ -9893,6 +9939,145 @@ func (e *Engine) cmdMode(p Platform, msg *Message, args []string) { e.reply(p, msg.ReplyCtx, reply) } +func (e *Engine) presetView(agent Agent, sessions *SessionManager, interactiveKey, sessionKey string) ([]PresetOption, string, AgentPresetSwitcher, *Session) { + provider, _ := agent.(AgentPresetProvider) + options := provider.AvailablePresets(e.ctx) + session := sessions.GetOrCreateActive(sessionKey) + current := session.GetAgentPreset() + + e.interactiveMu.Lock() + liveState := e.interactiveStates[interactiveKey] + var livePreset AgentPresetSwitcher + if liveState != nil && liveState.agentSession != nil { + livePreset, _ = liveState.agentSession.(AgentPresetSwitcher) + } + e.interactiveMu.Unlock() + if current == "" && livePreset != nil { + current = livePreset.GetPreset() + } + if current == "" { + for _, option := range options { + if option.Default { + current = option.ID + break + } + } + } + return options, current, livePreset, session +} + +func (e *Engine) applyPresetSelection(agent Agent, sessions *SessionManager, interactiveKey, sessionKey, target string) (PresetOption, bool, error) { + _, ok := agent.(AgentPresetProvider) + if !ok { + return PresetOption{}, false, fmt.Errorf("%s", e.i18n.T(MsgPresetNotSupported)) + } + options, current, livePreset, session := e.presetView(agent, sessions, interactiveKey, sessionKey) + target = strings.TrimSpace(target) + var selected PresetOption + found := false + for _, option := range options { + if strings.EqualFold(option.ID, target) { + selected = option + found = true + break + } + } + if !found || selected.Broken != "" { + return PresetOption{}, false, fmt.Errorf("%s", e.i18n.Tf(MsgPresetNotFound, target)) + } + if session.Busy() || session.HistoryLen() > 0 { + return selected, false, fmt.Errorf("%s", e.i18n.T(MsgPresetLocked)) + } + if strings.EqualFold(current, selected.ID) { + return selected, false, nil + } + if livePreset != nil { + if err := livePreset.SetPreset(selected.ID); err != nil { + return selected, false, err + } + } + session.SetAgentPreset(selected.ID) + sessions.Save() + return selected, true, nil +} + +// cmdPreset lists or selects an agent's per-session composition. Presets alter +// the model-facing tool/prompt surface, so a non-blank session must not be +// changed underneath its existing history. The selected id is stored on the +// cc-connect session until the next agent turn, where a capable backend (DSH) +// applies the native session-level switch and persists its own selection event. +func (e *Engine) cmdPreset(p Platform, msg *Message, args []string) { + agent, sessions, interactiveKey, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + _, ok := agent.(AgentPresetProvider) + if !ok { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPresetNotSupported)) + return + } + + options, current, _, _ := e.presetView(agent, sessions, interactiveKey, msg.SessionKey) + + if len(args) == 0 { + if len(options) == 0 { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPresetListEmpty)) + return + } + if supportsCards(p) { + e.replyWithCard(p, msg.ReplyCtx, e.renderPresetCard(msg.SessionKey)) + return + } + var sb strings.Builder + sb.WriteString(e.i18n.T(MsgPresetListTitle)) + for _, option := range options { + label := option.Name + if label == "" { + label = option.ID + } + fmt.Fprintf(&sb, "• `%s`", option.ID) + if label != option.ID { + sb.WriteString(" — ") + sb.WriteString(label) + } + if option.Default { + sb.WriteString(" (default)") + } + if option.Description != "" { + sb.WriteString("\n ") + sb.WriteString(option.Description) + } + if option.Broken != "" { + sb.WriteString("\n ⚠️ ") + sb.WriteString(option.Broken) + } + sb.WriteByte('\n') + } + if current != "" { + sb.WriteByte('\n') + sb.WriteString(e.i18n.Tf(MsgPresetCurrent, current)) + } + e.reply(p, msg.ReplyCtx, strings.TrimSpace(sb.String())) + return + } + if len(args) != 1 { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPresetUsage)) + return + } + + selected, changed, err := e.applyPresetSelection(agent, sessions, interactiveKey, msg.SessionKey, args[0]) + if err != nil { + e.reply(p, msg.ReplyCtx, err.Error()) + return + } + if !changed { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgPresetCurrent, selected.ID)) + return + } + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgPresetChanged, selected.ID)) +} + func (e *Engine) modeUsageText(modes []PermissionModeInfo) string { keys := make([]string, 0, len(modes)) for _, mode := range modes { @@ -11875,6 +12060,9 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { if prefix == "act" && cmd == "/model" { return e.handleModelCardAction(args, sessionKey) } + if prefix == "act" && cmd == "/preset" { + return e.handlePresetCardAction(args, sessionKey) + } if prefix == "act" { e.executeCardAction(cmd, args, sessionKey) @@ -11889,6 +12077,8 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { return e.renderReasoningCard() case "/mode": return e.renderModeCard() + case "/preset": + return e.renderPresetCard(sessionKey) case "/lang": return e.renderLangCard() case "/status": @@ -11970,14 +12160,15 @@ func (e *Engine) handleModelCardAction(args, sessionKey string) *Card { return e.renderModelCard(sessionKey) } target = strings.TrimSpace(target) + targetProvider := "" if modelSwitchNeedsLookup(target) { fetchCtx, cancel := context.WithTimeout(e.ctx, 3*time.Second) models := switcher.AvailableModels(fetchCtx) - target = resolveModelSwitchTarget(target, models) + target, targetProvider = resolveModelSwitchSelection(target, models) cancel() } - resolved, err := e.switchModelOnAgent(agent, target, agent == e.agent) + resolved, err := e.switchModelSelectionOnAgent(agent, target, targetProvider, agent == e.agent) interactiveKey := e.interactiveKeyForSessionKey(sessionKey) if err == nil { e.persistWorkspaceModelOverride(interactiveKey, sessionKey, agent, resolved) @@ -11990,6 +12181,24 @@ func (e *Engine) handleModelCardAction(args, sessionKey string) *Card { return e.renderModelSwitchResultCard(resolved, err) } +func (e *Engine) handlePresetCardAction(args, sessionKey string) *Card { + agent, sessions := e.sessionContextForKey(sessionKey) + if _, ok := agent.(AgentPresetProvider); !ok { + return e.simpleCard(e.i18n.T(MsgCardTitlePreset), "violet", e.i18n.T(MsgPresetNotSupported)) + } + fields := strings.Fields(args) + if len(fields) != 1 { + return e.renderPresetCard(sessionKey) + } + + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + selected, changed, err := e.applyPresetSelection(agent, sessions, interactiveKey, sessionKey, fields[0]) + if err != nil { + return e.renderPresetResultCard("", false, err) + } + return e.renderPresetResultCard(selected.ID, changed, nil) +} + func (e *Engine) persistWorkspaceModelOverride(interactiveKey, sessionKey string, agent Agent, model string) { if e.projectState == nil || !e.multiWorkspace || model == "" { return @@ -12047,9 +12256,10 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { return } target = strings.TrimSpace(target) + targetProvider := "" if modelSwitchNeedsLookup(target) { models := switcher.AvailableModels(fetchCtx) - target = resolveModelSwitchTarget(target, models) + target, targetProvider = resolveModelSwitchSelection(target, models) } cancel() e.cleanupInteractiveState(interactiveKey) @@ -12061,9 +12271,9 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { } e.interactiveMu.Unlock() state.mu.Lock() - state.modelSwitch = &modelSwitchState{phase: "switching", target: target} + state.modelSwitch = &modelSwitchState{phase: "switching", target: target, provider: targetProvider} state.mu.Unlock() - go e.performModelSwitchAsync(sessionKey, state, agent, sessions, target) + go e.performModelSwitchAsync(sessionKey, state, agent, sessions, target, targetProvider) case "/reasoning": if args == "" { @@ -12588,8 +12798,8 @@ func (e *Engine) pushDeleteModeResultCard(sessionKey string) { e.sendWithCard(targetPlatform, rctx, card) } -func (e *Engine) performModelSwitchAsync(sessionKey string, state *interactiveState, agent Agent, sessions *SessionManager, target string) { - resolved, err := e.switchModelOnAgent(agent, target, agent == e.agent) +func (e *Engine) performModelSwitchAsync(sessionKey string, state *interactiveState, agent Agent, sessions *SessionManager, target, provider string) { + resolved, err := e.switchModelSelectionOnAgent(agent, target, provider, agent == e.agent) if err == nil { interactiveKey := e.interactiveKeyForSessionKey(sessionKey) e.persistWorkspaceModelOverride(interactiveKey, sessionKey, agent, resolved) @@ -12833,6 +13043,10 @@ func (e *Engine) renderModelCard(sessionKey string) *Card { defer cancel() models := switcher.AvailableModels(fetchCtx) current := switcher.GetModel() + currentProvider := "" + if providerAware, ok := agent.(ProviderModelSwitcher); ok { + currentProvider = providerAware.GetModelProvider() + } var sb strings.Builder if current == "" { @@ -12852,7 +13066,7 @@ func (e *Engine) renderModelCard(sessionKey string) *Card { } val := fmt.Sprintf("act:/model switch %d", i+1) opts = append(opts, CardSelectOption{Text: label, Value: val}) - if m.Name == current { + if modelOptionIsCurrent(m, current, currentProvider) { initVal = val } } @@ -12921,6 +13135,71 @@ func (e *Engine) renderReasoningCard() *Card { return cb.Build() } +func (e *Engine) renderPresetCard(sessionKey string) *Card { + agent, sessions := e.sessionContextForKey(sessionKey) + _, ok := agent.(AgentPresetProvider) + if !ok { + return e.simpleCard(e.i18n.T(MsgCardTitlePreset), "violet", e.i18n.T(MsgPresetNotSupported)) + } + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + options, current, _, _ := e.presetView(agent, sessions, interactiveKey, sessionKey) + if len(options) == 0 { + return e.simpleCard(e.i18n.T(MsgCardTitlePreset), "violet", e.i18n.T(MsgPresetListEmpty)) + } + + var body strings.Builder + if current != "" { + body.WriteString(e.i18n.Tf(MsgPresetCurrent, current)) + body.WriteString("\n\n") + } + var opts []CardSelectOption + initValue := "" + for _, option := range options { + label := option.Name + if label == "" { + label = option.ID + } + if option.Default { + label += " (default)" + } + marker := "◻" + if option.ID == current { + marker = "▶" + initValue = "act:/preset " + option.ID + } + fmt.Fprintf(&body, "%s `%s` — %s\n", marker, option.ID, label) + if option.Description != "" { + body.WriteString(" " + option.Description + "\n") + } + opts = append(opts, CardSelectOption{ + Text: label, + Value: "act:/preset " + option.ID, + }) + } + + cb := NewCard().Title(e.i18n.T(MsgCardTitlePreset), "violet").Markdown(strings.TrimSpace(body.String())) + cb.Select(e.i18n.T(MsgPresetSelectPlaceholder), opts, initValue) + cb.Buttons(e.cardBackButton()) + return cb.Build() +} + +func (e *Engine) renderPresetResultCard(target string, changed bool, err error) *Card { + if err != nil { + return NewCard().Title(e.i18n.T(MsgCardTitlePreset), "red"). + Markdown(err.Error()). + Buttons(DefaultBtn(e.i18n.T(MsgPresetChangeAnother), "nav:/preset"), e.cardBackButton()). + Build() + } + body := e.i18n.Tf(MsgPresetCurrent, target) + if changed { + body = e.i18n.Tf(MsgPresetChanged, target) + } + return NewCard().Title(e.i18n.T(MsgCardTitlePreset), "green"). + Markdown(body). + Buttons(DefaultBtn(e.i18n.T(MsgPresetChangeAnother), "nav:/preset"), e.cardBackButton()). + Build() +} + func (e *Engine) renderModeCard() *Card { switcher, ok := e.agent.(ModeSwitcher) if !ok { @@ -15708,7 +15987,12 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey, // Use the engine context (not the relay timeout context) so that the // agent process is not killed when the relay deadline fires. The relay // timeout only controls how long we *wait* for the response. - agentSession, err := agent.StartSession(e.ctx, session.GetAgentSessionID()) + var agentSession AgentSession + if starter, ok := agent.(AgentPresetStarter); ok { + agentSession, err = starter.StartSessionWithPreset(e.ctx, session.GetAgentSessionID(), session.GetAgentPreset()) + } else { + agentSession, err = agent.StartSession(e.ctx, session.GetAgentSessionID()) + } if err != nil { // Resume failed — fall back to a fresh session so the relay is not // permanently broken by a corrupted/stale session ID. @@ -15717,7 +16001,11 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey, "relay_key", relaySessionKey, "error", err) session.SetAgentSessionID("", agent.Name()) sessions.Save() - agentSession, err = agent.StartSession(e.ctx, "") + if starter, ok := agent.(AgentPresetStarter); ok { + agentSession, err = starter.StartSessionWithPreset(e.ctx, "", session.GetAgentPreset()) + } else { + agentSession, err = agent.StartSession(e.ctx, "") + } } if err != nil { return "", fmt.Errorf("start relay session: %w", err) diff --git a/core/i18n.go b/core/i18n.go index 682245153..ec45a280c 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -197,6 +197,14 @@ const ( MsgQuietGlobalOff MsgKey = "quiet_global_off" MsgModeChanged MsgKey = "mode_changed" MsgModeNotSupported MsgKey = "mode_not_supported" + MsgPresetNotSupported MsgKey = "preset_not_supported" + MsgPresetUsage MsgKey = "preset_usage" + MsgPresetListTitle MsgKey = "preset_list_title" + MsgPresetListEmpty MsgKey = "preset_list_empty" + MsgPresetCurrent MsgKey = "preset_current" + MsgPresetChanged MsgKey = "preset_changed" + MsgPresetLocked MsgKey = "preset_locked" + MsgPresetNotFound MsgKey = "preset_not_found" MsgSessionRestarting MsgKey = "session_restarting" MsgSessionNotStarted MsgKey = "session_not_started" MsgUntitled MsgKey = "untitled" @@ -347,6 +355,8 @@ const ( MsgLangSelectPlaceholder MsgKey = "lang_select_placeholder" MsgModelSelectPlaceholder MsgKey = "model_select_placeholder" MsgModeSelectPlaceholder MsgKey = "mode_select_placeholder" + MsgPresetSelectPlaceholder MsgKey = "preset_select_placeholder" + MsgPresetChangeAnother MsgKey = "preset_change_another" MsgProviderSelectPlaceholder MsgKey = "provider_select_placeholder" MsgProviderClearOption MsgKey = "provider_clear_option" MsgCardBack MsgKey = "card_back" @@ -357,6 +367,7 @@ const ( MsgCardTitleModel MsgKey = "card_title_model" MsgCardTitleReasoning MsgKey = "card_title_reasoning" MsgCardTitleMode MsgKey = "card_title_mode" + MsgCardTitlePreset MsgKey = "card_title_preset" MsgCardTitleSessions MsgKey = "card_title_sessions" MsgCardTitleSessionsPaged MsgKey = "card_title_sessions_paged" MsgCardTitleCurrentSession MsgKey = "card_title_current_session" @@ -378,31 +389,31 @@ const ( MsgCronIDLabel MsgKey = "cron_id_label" MsgCronFailedSuffix MsgKey = "cron_failed_suffix" - MsgTimerNotAvailable MsgKey = "timer_not_available" - MsgTimerUsage MsgKey = "timer_usage" - MsgTimerAddUsage MsgKey = "timer_add_usage" - MsgTimerAdded MsgKey = "timer_added" - MsgTimerAddedExec MsgKey = "timer_added_exec" - MsgTimerAddExecUsage MsgKey = "timer_addexec_usage" - MsgTimerEmpty MsgKey = "timer_empty" - MsgTimerListTitle MsgKey = "timer_list_title" - MsgTimerListFooter MsgKey = "timer_list_footer" - MsgTimerDelUsage MsgKey = "timer_del_usage" - MsgTimerMuteUsage MsgKey = "timer_mute_usage" - MsgTimerDeleted MsgKey = "timer_deleted" - MsgTimerNotFound MsgKey = "timer_not_found" - MsgTimerMuted MsgKey = "timer_muted" - MsgTimerUnmuted MsgKey = "timer_unmuted" - MsgTimerCardHint MsgKey = "timer_card_hint" - MsgTimerBtnMute MsgKey = "timer_btn_mute" - MsgTimerBtnUnmute MsgKey = "timer_btn_unmute" - MsgTimerBtnDelete MsgKey = "timer_btn_delete" - MsgTimerIDLabel MsgKey = "timer_id_label" - MsgTimerScheduledLabel MsgKey = "timer_scheduled_label" - MsgTimerFailedSuffix MsgKey = "timer_failed_suffix" - MsgCommandsTagAgent MsgKey = "commands_tag_agent" - MsgCommandsTagShell MsgKey = "commands_tag_shell" - MsgUpgradeTimeoutSuffix MsgKey = "upgrade_timeout_suffix" + MsgTimerNotAvailable MsgKey = "timer_not_available" + MsgTimerUsage MsgKey = "timer_usage" + MsgTimerAddUsage MsgKey = "timer_add_usage" + MsgTimerAdded MsgKey = "timer_added" + MsgTimerAddedExec MsgKey = "timer_added_exec" + MsgTimerAddExecUsage MsgKey = "timer_addexec_usage" + MsgTimerEmpty MsgKey = "timer_empty" + MsgTimerListTitle MsgKey = "timer_list_title" + MsgTimerListFooter MsgKey = "timer_list_footer" + MsgTimerDelUsage MsgKey = "timer_del_usage" + MsgTimerMuteUsage MsgKey = "timer_mute_usage" + MsgTimerDeleted MsgKey = "timer_deleted" + MsgTimerNotFound MsgKey = "timer_not_found" + MsgTimerMuted MsgKey = "timer_muted" + MsgTimerUnmuted MsgKey = "timer_unmuted" + MsgTimerCardHint MsgKey = "timer_card_hint" + MsgTimerBtnMute MsgKey = "timer_btn_mute" + MsgTimerBtnUnmute MsgKey = "timer_btn_unmute" + MsgTimerBtnDelete MsgKey = "timer_btn_delete" + MsgTimerIDLabel MsgKey = "timer_id_label" + MsgTimerScheduledLabel MsgKey = "timer_scheduled_label" + MsgTimerFailedSuffix MsgKey = "timer_failed_suffix" + MsgCommandsTagAgent MsgKey = "commands_tag_agent" + MsgCommandsTagShell MsgKey = "commands_tag_shell" + MsgUpgradeTimeoutSuffix MsgKey = "upgrade_timeout_suffix" MsgCronScheduleLabel MsgKey = "cron_schedule_label" MsgCronNextRunLabel MsgKey = "cron_next_run_label" @@ -562,6 +573,7 @@ const ( MsgBuiltinCmdModel MsgKey = "model" MsgBuiltinCmdReasoning MsgKey = "reasoning" MsgBuiltinCmdMode MsgKey = "mode" + MsgBuiltinCmdPreset MsgKey = "preset" MsgBuiltinCmdLang MsgKey = "lang" MsgBuiltinCmdQuiet MsgKey = "quiet" MsgBuiltinCmdCompress MsgKey = "compress" @@ -919,6 +931,62 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "このエージェントは権限モードの切り替えをサポートしていません。", LangSpanish: "Este agente no soporta el cambio de modo de permisos.", }, + MsgPresetNotSupported: { + LangEnglish: "This agent does not support preset switching.", + LangChinese: "当前 Agent 不支持 preset 切换。", + LangTraditionalChinese: "當前 Agent 不支援 preset 切換。", + LangJapanese: "このエージェントはプリセットの切り替えをサポートしていません。", + LangSpanish: "Este agente no soporta el cambio de presets.", + }, + MsgPresetUsage: { + LangEnglish: "Usage: `/preset` to list presets or `/preset ` to select one.", + LangChinese: "用法:`/preset` 列出 preset,或使用 `/preset <名称>` 选择。", + LangTraditionalChinese: "用法:`/preset` 列出 preset,或使用 `/preset <名稱>` 選擇。", + LangJapanese: "使い方: `/preset` で一覧表示、`/preset <名前>` で選択します。", + LangSpanish: "Uso: `/preset` lista los presets o `/preset ` selecciona uno.", + }, + MsgPresetListTitle: { + LangEnglish: "Available presets:\n", + LangChinese: "可用 preset:\n", + LangTraditionalChinese: "可用 preset:\n", + LangJapanese: "利用可能なプリセット:\n", + LangSpanish: "Presets disponibles:\n", + }, + MsgPresetListEmpty: { + LangEnglish: "No agent presets are available.", + LangChinese: "没有可用的 agent preset。", + LangTraditionalChinese: "沒有可用的 agent preset。", + LangJapanese: "利用可能なエージェントプリセットはありません。", + LangSpanish: "No hay presets de agente disponibles.", + }, + MsgPresetCurrent: { + LangEnglish: "Current preset: `%s`", + LangChinese: "当前 preset:`%s`", + LangTraditionalChinese: "目前 preset:`%s`", + LangJapanese: "現在のプリセット: `%s`", + LangSpanish: "Preset actual: `%s`", + }, + MsgPresetChanged: { + LangEnglish: "Preset switched to `%s`. It will apply on the next turn while this session is blank.", + LangChinese: "preset 已切换为 `%s`,当前空白会话将在下一轮应用。", + LangTraditionalChinese: "preset 已切換為 `%s`,目前空白會話將在下一輪套用。", + LangJapanese: "プリセットを `%s` に切り替えました。空のセッションでは次のターンから適用されます。", + LangSpanish: "Preset cambiado a `%s`. Se aplicará en el próximo turno mientras la sesión esté vacía.", + }, + MsgPresetLocked: { + LangEnglish: "This session already has history, so its preset is locked. Use `/new` before switching presets.", + LangChinese: "当前会话已有历史,preset 已锁定;请先使用 `/new` 再切换。", + LangTraditionalChinese: "目前會話已有歷史,preset 已鎖定;請先使用 `/new` 再切換。", + LangJapanese: "このセッションには履歴があるため、プリセットは固定されています。切り替える前に `/new` を使ってください。", + LangSpanish: "La sesión ya tiene historial y su preset está bloqueado. Usa `/new` antes de cambiarlo.", + }, + MsgPresetNotFound: { + LangEnglish: "Preset `%s` was not found. Use `/preset` to list available presets.", + LangChinese: "找不到 preset `%s`,请使用 `/preset` 查看可用列表。", + LangTraditionalChinese: "找不到 preset `%s`,請使用 `/preset` 查看可用列表。", + LangJapanese: "プリセット `%s` が見つかりません。`/preset` で一覧を確認してください。", + LangSpanish: "No se encontró el preset `%s`. Usa `/preset` para ver la lista.", + }, MsgSessionRestarting: { LangEnglish: "🔄 Session process exited, restarting...", LangChinese: "🔄 会话进程已退出,正在重启...", @@ -2512,6 +2580,14 @@ var messages = map[MsgKey]map[Language]string{ LangEnglish: "Select mode", LangChinese: "选择模式", LangTraditionalChinese: "選擇模式", LangJapanese: "モードを選択", LangSpanish: "Seleccionar modo", }, + MsgPresetSelectPlaceholder: { + LangEnglish: "Select preset", LangChinese: "选择 preset", LangTraditionalChinese: "選擇 preset", + LangJapanese: "プリセットを選択", LangSpanish: "Seleccionar preset", + }, + MsgPresetChangeAnother: { + LangEnglish: "Change preset", LangChinese: "切换 preset", LangTraditionalChinese: "切換 preset", + LangJapanese: "プリセットを変更", LangSpanish: "Cambiar preset", + }, MsgProviderSelectPlaceholder: { LangEnglish: "Select provider", LangChinese: "选择 Provider", LangTraditionalChinese: "選擇 Provider", LangJapanese: "プロバイダーを選択", LangSpanish: "Seleccionar proveedor", @@ -2552,6 +2628,10 @@ var messages = map[MsgKey]map[Language]string{ LangEnglish: "Permission Mode", LangChinese: "权限模式", LangTraditionalChinese: "權限模式", LangJapanese: "権限モード", LangSpanish: "Modo de permisos", }, + MsgCardTitlePreset: { + LangEnglish: "Agent Preset", LangChinese: "Agent preset", LangTraditionalChinese: "Agent preset", + LangJapanese: "エージェントプリセット", LangSpanish: "Preset del agente", + }, MsgCardTitleSessions: { LangEnglish: "%s Sessions (%d)", LangChinese: "%s 会话列表 (%d)", LangTraditionalChinese: "%s 會話列表 (%d)", LangJapanese: "%s セッション (%d)", LangSpanish: "Sesiones de %s (%d)", @@ -3637,6 +3717,13 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "権限モードの表示/切り替え、引数: [名前]", LangSpanish: "Ver/cambiar modo de permisos, arg: [nombre]", }, + MsgBuiltinCmdPreset: { + LangEnglish: "View/switch agent preset, arg: [name]", + LangChinese: "查看/切换 Agent preset,参数: [名称]", + LangTraditionalChinese: "查看/切換 Agent preset,參數: [名稱]", + LangJapanese: "エージェントプリセットの表示/切り替え、引数: [名前]", + LangSpanish: "Ver/cambiar preset del agente, arg: [nombre]", + }, MsgBuiltinCmdLang: { LangEnglish: "View/switch language, arg: [en|zh|zh-TW|ja|es|auto]", LangChinese: "查看/切换语言,参数: [en|zh|zh-TW|ja|es|auto]", diff --git a/core/interfaces.go b/core/interfaces.go index 81f4d1c14..988e38660 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -485,6 +485,15 @@ type ModelSwitcher interface { AvailableModels(ctx context.Context) []ModelOption } +// ProviderModelSwitcher is an optional extension for agents whose model +// catalog spans multiple provider routes. The ordinary ModelSwitcher remains +// the compatibility path; callers use this interface when a catalog entry +// carries an explicit provider so duplicate model ids stay unambiguous. +type ProviderModelSwitcher interface { + SetModelForProvider(provider, model string) + GetModelProvider() string +} + // ReasoningEffortSwitcher is an optional interface for agents that support // runtime switching of reasoning effort. type ReasoningEffortSwitcher interface { @@ -495,9 +504,10 @@ type ReasoningEffortSwitcher interface { // ModelOption describes a selectable model. type ModelOption struct { - Name string // model identifier passed to CLI - Desc string // short description (display_name or empty) - Alias string // optional short alias for the /model command (e.g. "codex" for "gpt-5.3-codex") + Name string // model identifier passed to CLI + Desc string // short description (display_name or empty) + Alias string // optional short alias for the /model command (e.g. "codex" for "gpt-5.3-codex") + Provider string // provider route, when the catalog spans multiple routes } // UsageReporter is an optional interface for agents that can report account or @@ -636,6 +646,38 @@ type ModeSwitcher interface { PermissionModes() []PermissionModeInfo } +// PresetOption describes one selectable agent composition. Presets are an +// optional agent capability because only some agents (currently DSH) expose +// per-session model-facing compositions. +type PresetOption struct { + ID string + Name string + Description string + Trust string + Default bool + Broken string +} + +// AgentPresetProvider lists the compositions an agent can use. +type AgentPresetProvider interface { + AvailablePresets(ctx context.Context) []PresetOption +} + +// AgentPresetStarter lets the engine pass a session's pending preset choice +// when it creates or resumes an agent session. The ordinary StartSession +// method remains the compatibility path for agents without this capability. +type AgentPresetStarter interface { + StartSessionWithPreset(ctx context.Context, sessionID, preset string) (AgentSession, error) +} + +// AgentPresetSwitcher changes the pending composition for one agent session. +// The agent applies it before the next turn; it must not change a completed +// conversation's tool surface in place. +type AgentPresetSwitcher interface { + SetPreset(preset string) error + GetPreset() string +} + // WorkspaceAgentOptionSnapshotter is an optional interface for agents that can // export reusable constructor options needed to recreate an equivalent agent in // a different workspace. Snapshot values should omit work_dir; the caller is diff --git a/core/model_provider_test.go b/core/model_provider_test.go new file mode 100644 index 000000000..75d3d7cec --- /dev/null +++ b/core/model_provider_test.go @@ -0,0 +1,45 @@ +package core + +import ( + "context" + "testing" +) + +type providerAwareModelAgent struct { + stubAgent + model string + provider string + models []ModelOption +} + +func (a *providerAwareModelAgent) SetModel(model string) { a.model = model } + +func (a *providerAwareModelAgent) GetModel() string { return a.model } + +func (a *providerAwareModelAgent) AvailableModels(context.Context) []ModelOption { + return append([]ModelOption(nil), a.models...) +} + +func (a *providerAwareModelAgent) SetModelForProvider(provider, model string) { + a.provider = provider + a.model = model +} + +func (a *providerAwareModelAgent) GetModelProvider() string { return a.provider } + +func TestCmdModel_PreservesCatalogProvider(t *testing.T) { + agent := &providerAwareModelAgent{models: []ModelOption{ + {Name: "same-model", Provider: "openai"}, + {Name: "same-model", Provider: "openrouter"}, + }} + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} + + if !e.handleCommand(p, msg, "/model 2") { + t.Fatal("/model 2 should be handled") + } + if agent.model != "same-model" || agent.provider != "openrouter" { + t.Fatalf("selection = %s/%s, want openrouter/same-model", agent.provider, agent.model) + } +} diff --git a/core/preset_test.go b/core/preset_test.go new file mode 100644 index 000000000..aff368cc0 --- /dev/null +++ b/core/preset_test.go @@ -0,0 +1,156 @@ +package core + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +type presetTestAgent struct { + stubAgent + options []PresetOption +} + +func (a *presetTestAgent) AvailablePresets(context.Context) []PresetOption { + return append([]PresetOption(nil), a.options...) +} + +type presetStarterTestAgent struct { + presetTestAgent + startedPreset string +} + +func (a *presetStarterTestAgent) StartSessionWithPreset(_ context.Context, _ string, preset string) (AgentSession, error) { + a.startedPreset = preset + return &stubAgentSession{}, nil +} + +func TestCmdPreset_ListsAndSelectsBlankSession(t *testing.T) { + agent := &presetTestAgent{options: []PresetOption{ + {ID: "standard", Name: "Standard", Description: "full surface", Default: true}, + {ID: "minimal", Name: "Minimal", Description: "small surface"}, + }} + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} + + if !e.handleCommand(p, msg, "/preset") { + t.Fatal("/preset should be handled") + } + sent := p.getSent() + if len(sent) != 1 || sent[0] == "" { + t.Fatalf("list reply = %#v", sent) + } + if len(sent) == 1 && !strings.HasPrefix(sent[0], "Available presets:") { + t.Fatalf("list reply = %q, want available preset list", sent[0]) + } + + p.clearSent() + if !e.handleCommand(p, msg, "/preset minimal") { + t.Fatal("/preset minimal should be handled") + } + session := e.sessions.GetOrCreateActive(msg.SessionKey) + if got := session.GetAgentPreset(); got != "minimal" { + t.Fatalf("AgentPreset = %q, want minimal", got) + } + if sent := p.getSent(); len(sent) != 1 || sent[0] == "" { + t.Fatalf("switch reply = %#v", sent) + } +} + +func TestCmdPreset_UsesCardOnCardPlatform(t *testing.T) { + agent := &presetTestAgent{options: []PresetOption{ + {ID: "standard", Name: "Standard", Default: true}, + {ID: "minimal", Name: "Minimal"}, + }} + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "feishu:chat:user1", ReplyCtx: "ctx"} + + if !e.handleCommand(p, msg, "/preset") { + t.Fatal("/preset should be handled") + } + if len(p.repliedCards) != 1 { + t.Fatalf("replied cards = %d, want 1", len(p.repliedCards)) + } + var foundSelect bool + for _, element := range p.repliedCards[0].Elements { + if _, ok := element.(CardSelect); ok { + foundSelect = true + break + } + } + if !foundSelect { + t.Fatalf("preset card elements = %#v, want CardSelect", p.repliedCards[0].Elements) + } +} + +func TestHandleCardNav_PresetSwitchesBlankSession(t *testing.T) { + agent := &presetTestAgent{options: []PresetOption{ + {ID: "standard", Default: true}, + {ID: "minimal"}, + }} + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + sessionKey := "feishu:chat:user1" + + card := e.handleCardNav("act:/preset minimal", sessionKey) + if card == nil { + t.Fatal("expected preset result card") + } + if got := e.sessions.GetOrCreateActive(sessionKey).GetAgentPreset(); got != "minimal" { + t.Fatalf("AgentPreset = %q, want minimal", got) + } + if text := card.RenderText(); !strings.Contains(text, "Preset switched to `minimal`") { + t.Fatalf("result card = %q", text) + } +} + +func TestCmdPreset_RejectsStartedSession(t *testing.T) { + agent := &presetTestAgent{options: []PresetOption{ + {ID: "standard", Default: true}, + {ID: "minimal"}, + }} + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} + session := e.sessions.GetOrCreateActive(msg.SessionKey) + session.AddHistory("user", "already started") + + if !e.handleCommand(p, msg, "/preset minimal") { + t.Fatal("/preset minimal should be handled") + } + if got := session.GetAgentPreset(); got != "" { + t.Fatalf("AgentPreset = %q after locked switch, want empty", got) + } + if sent := p.getSent(); len(sent) != 1 || sent[0] == "" { + t.Fatalf("locked reply = %#v", sent) + } +} + +func TestInteractiveStartPassesPendingPreset(t *testing.T) { + agent := &presetStarterTestAgent{presetTestAgent: presetTestAgent{options: []PresetOption{{ID: "minimal"}}}} + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + session := &Session{AgentPreset: "minimal"} + + e.getOrCreateInteractiveStateWith("test:user1", p, "ctx", session, e.sessions, nil, "") + if agent.startedPreset != "minimal" { + t.Fatalf("started preset = %q, want minimal", agent.startedPreset) + } +} + +func TestSessionManager_PersistsAgentPreset(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + manager := NewSessionManager(path) + session := manager.GetOrCreateActive("test:user1") + session.SetAgentPreset("minimal") + manager.Save() + + reloaded := NewSessionManager(path) + got := reloaded.GetOrCreateActive("test:user1").GetAgentPreset() + if got != "minimal" { + t.Fatalf("reloaded AgentPreset = %q, want minimal", got) + } +} diff --git a/core/session.go b/core/session.go index b69943e93..1f2e693f9 100644 --- a/core/session.go +++ b/core/session.go @@ -18,21 +18,26 @@ const ContinueSession = "__continue__" // Session tracks one conversation between a user and the agent. type Session struct { - ID string `json:"id"` - Name string `json:"name"` - AgentSessionID string `json:"agent_session_id"` - AgentType string `json:"agent_type,omitempty"` - PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + AgentSessionID string `json:"agent_session_id"` + AgentType string `json:"agent_type,omitempty"` + PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"` // ActiveProvider is the agent provider name that was active when this // session last took a turn. It is restored before --resume so that a // cc-connect process restart does not silently drop a user's // `/provider switch` (the agent_session_id survives on disk while the // in-memory active provider does not). Empty means "no explicit choice // — use whatever the agent's default is". - ActiveProvider string `json:"active_provider,omitempty"` - History []HistoryEntry `json:"history"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ActiveProvider string `json:"active_provider,omitempty"` + // AgentPreset is the pending per-session DSH preset. It is intentionally + // kept on cc-connect's session record so a choice made before the first + // agent turn survives a daemon restart; dsh records the durable selection + // in its own session log when the next turn starts. + AgentPreset string `json:"agent_preset,omitempty"` + History []HistoryEntry `json:"history"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // LastUserActivity records when a real user message was last received. // Unlike UpdatedAt (bumped by every session.Unlock including heartbeats and // unsolicited agent output), this field is only updated when the engine @@ -183,6 +188,21 @@ func (s *Session) GetActiveProvider() string { return s.ActiveProvider } +// SetAgentPreset records the preset selected for this cc-connect session. +// An empty value means use the dsh deployment default. +func (s *Session) SetAgentPreset(preset string) { + s.mu.Lock() + defer s.mu.Unlock() + s.AgentPreset = preset +} + +// GetAgentPreset atomically reads the selected per-session preset. +func (s *Session) GetAgentPreset() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.AgentPreset +} + // SetAgentSessionID atomically sets the agent session ID and agent type. // The ContinueSession sentinel is never persisted — it is only used transiently // when starting an agent (see engine); storing it on disk breaks resume (#255). @@ -642,6 +662,7 @@ func (sm *SessionManager) saveLocked() { AgentSessionID: agentSID, AgentType: s.AgentType, PastAgentSessionIDs: append([]string(nil), s.PastAgentSessionIDs...), + AgentPreset: s.AgentPreset, History: append([]HistoryEntry(nil), s.History...), CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, @@ -828,7 +849,7 @@ func (sm *SessionManager) PruneDuplicateSessions(mergeHistory bool) PruneResult defer sm.mu.Unlock() // Group sessions by baseChat - chatSessions := make(map[string][]*Session) // baseChat -> sessions + chatSessions := make(map[string][]*Session) // baseChat -> sessions sessionToBaseChat := make(map[string]string) // session.ID -> baseChat for userKey, sessionIDs := range sm.userSessions { From 5436fca8bead04091157861b03233cf24b913c20 Mon Sep 17 00:00:00 2001 From: baizhu945 Date: Thu, 20 Aug 2026 11:16:31 +0800 Subject: [PATCH 7/7] fix(dsh): support reasoning effort switching --- agent/dsh/dsh.go | 81 ++++++++++++++++++++++++++++++++++--------- agent/dsh/dsh_test.go | 73 +++++++++++++++++++++++++++++++++----- agent/dsh/models.go | 63 ++++++++++++++++++++++++++++++--- agent/dsh/session.go | 65 ++++++++++++++++++---------------- config.example.toml | 3 +- core/engine.go | 17 +++++++++ 6 files changed, 240 insertions(+), 62 deletions(-) diff --git a/agent/dsh/dsh.go b/agent/dsh/dsh.go index dc6946300..ae00cc298 100644 --- a/agent/dsh/dsh.go +++ b/agent/dsh/dsh.go @@ -7,6 +7,7 @@ // --session-id create-or-resume a persisted dsh session with this id // --provider override the provider route for this run // --model override the default model for this run +// --reasoning-effort override the reasoning effort for this run // --mode pin sandbox/approval knobs for this run // --preset select/recompose a blank session's agent preset // @@ -33,15 +34,16 @@ func init() { // Agent drives the DeepSeek Harness CLI (dsh) in headless profile. type Agent struct { - cmd string // path to dsh binary - cliExtraArgs []string // extra args from cmd after the binary name - configEnv []string // env vars from [projects.agent.options.env] - workDir string - model string // model override ("" = use dsh settings default) - provider string // provider route override ("" = use dsh settings default) - mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" - sessionEnv []string - mu sync.Mutex + cmd string // path to dsh binary + cliExtraArgs []string // extra args from cmd after the binary name + configEnv []string // env vars from [projects.agent.options.env] + workDir string + model string // model override ("" = use dsh settings default) + provider string // provider route override ("" = use dsh settings default) + reasoningEffort string // reasoning effort override ("" = use dsh settings default) + mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" + sessionEnv []string + mu sync.Mutex } // New creates the dsh agent from cc-connect config options. @@ -52,6 +54,7 @@ func New(opts map[string]any) (core.Agent, error) { } model, _ := opts["model"].(string) provider, _ := opts["provider"].(string) + reasoningEffort, _ := opts["reasoning_effort"].(string) if strings.TrimSpace(provider) == "" { provider = readDefaultProvider() } @@ -65,13 +68,14 @@ func New(opts map[string]any) (core.Agent, error) { } return &Agent{ - cmd: cmd, - cliExtraArgs: extraArgs, - configEnv: core.ParseConfigEnv(opts), - workDir: workDir, - model: model, - provider: strings.TrimSpace(provider), - mode: mode, + cmd: cmd, + cliExtraArgs: extraArgs, + configEnv: core.ParseConfigEnv(opts), + workDir: workDir, + model: model, + provider: strings.TrimSpace(provider), + reasoningEffort: normalizeReasoningEffort(reasoningEffort), + mode: mode, }, nil } @@ -194,6 +198,45 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { return models } +// ── ReasoningEffortSwitcher ───────────────────────────────── + +func normalizeReasoningEffort(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "off", "minimal", "low", "medium", "high", "xhigh", "max": + return strings.ToLower(strings.TrimSpace(raw)) + default: + return "" + } +} + +func (a *Agent) SetReasoningEffort(effort string) { + effort = normalizeReasoningEffort(effort) + a.mu.Lock() + a.reasoningEffort = effort + a.mu.Unlock() + slog.Info("dsh: reasoning effort changed", "reasoning_effort", effort) +} + +func (a *Agent) GetReasoningEffort() string { + a.mu.Lock() + effort := a.reasoningEffort + a.mu.Unlock() + if effort != "" { + return effort + } + return readDefaultReasoningEffort() +} + +func (a *Agent) AvailableReasoningEfforts() []string { + if levels := a.readRuntimeReasoningEfforts(context.Background()); len(levels) > 0 { + return levels + } + // dsh providers expose different subsets; this union keeps the selector + // useful when the live catalog cannot resolve the current route (for + // example, an OAuth-only route is not mounted in the headless profile). + return []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"} +} + // ── ModeSwitcher ───────────────────────────────────────────── func (a *Agent) SetMode(mode string) { @@ -264,12 +307,16 @@ func (a *Agent) StartSessionWithPreset(ctx context.Context, sessionID, preset st mode := a.mode model := a.model provider := a.provider + reasoningEffort := a.reasoningEffort extraArgs := append([]string{}, a.cliExtraArgs...) extraEnv := append([]string(nil), a.configEnv...) extraEnv = append(extraEnv, a.sessionEnv...) workDir := a.workDir a.mu.Unlock() - return newDSHSession(ctx, a.cmd, extraArgs, workDir, provider, model, mode, preset, sessionID, extraEnv) + if reasoningEffort == "" { + reasoningEffort = readDefaultReasoningEffort() + } + return newDSHSession(ctx, a.cmd, extraArgs, workDir, provider, model, reasoningEffort, mode, preset, sessionID, extraEnv) } func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { diff --git a/agent/dsh/dsh_test.go b/agent/dsh/dsh_test.go index d707800d4..44f4744ac 100644 --- a/agent/dsh/dsh_test.go +++ b/agent/dsh/dsh_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -121,6 +122,29 @@ func TestSetModelForProvider(t *testing.T) { } } +func TestSetGetReasoningEffort(t *testing.T) { + t.Setenv("DSH_HOME", t.TempDir()) + ag, _ := New(map[string]any{"cmd": "echo"}) + a := ag.(*Agent) + a.SetReasoningEffort("HIGH") + if got := a.GetReasoningEffort(); got != "high" { + t.Errorf("GetReasoningEffort() = %q, want high", got) + } + a.SetReasoningEffort("not-a-level") + if got := a.GetReasoningEffort(); got != "" { + t.Errorf("GetReasoningEffort(invalid) = %q, want empty", got) + } +} + +func TestAvailableReasoningEfforts_Fallback(t *testing.T) { + t.Setenv("DSH_HOME", t.TempDir()) + ag, _ := New(map[string]any{"cmd": "echo"}) + got := ag.(*Agent).AvailableReasoningEfforts() + if len(got) != 7 || got[0] != "off" || got[len(got)-1] != "max" { + t.Fatalf("AvailableReasoningEfforts() = %v, want standard dsh union", got) + } +} + func TestSetGetMode(t *testing.T) { ag, _ := New(map[string]any{"cmd": "echo"}) a := ag.(*Agent) @@ -131,7 +155,7 @@ func TestSetGetMode(t *testing.T) { } func TestSessionPreset(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "session-preset", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "", "session-preset", nil) if err != nil { t.Fatal(err) } @@ -231,6 +255,25 @@ func TestAvailableModels_FromRuntimeCatalog(t *testing.T) { } } +func TestAvailableReasoningEfforts_FromRuntimeCatalog(t *testing.T) { + home := t.TempDir() + script := filepath.Join(home, "fake-dsh") + content := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"models\",\"models\":[],\"reasoningEfforts\":[\"minimal\",\"high\",\"high\",\"max\"]}'\n" + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("DSH_HOME", filepath.Join(home, "dsh-home")) + ag, err := New(map[string]any{"cmd": script}) + if err != nil { + t.Fatal(err) + } + got := ag.(*Agent).AvailableReasoningEfforts() + want := []string{"minimal", "high", "max"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("AvailableReasoningEfforts() = %v, want %v", got, want) + } +} + func TestGetModel_FallsBackToSettings(t *testing.T) { home := t.TempDir() if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(` @@ -279,7 +322,7 @@ func TestAvailablePresets_FromUserRoot(t *testing.T) { // ── buildArgs ──────────────────────────────────────────────── func TestBuildArgs(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "confirm", "codex", "session-abc", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "confirm", "codex", "session-abc", nil) if err != nil { t.Fatal(err) } @@ -291,7 +334,7 @@ func TestBuildArgs(t *testing.T) { } func TestBuildArgs_WithModel(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "deepseek-v4-pro", "danger-full-access", "", "session-abc", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "deepseek-v4-pro", "", "danger-full-access", "", "session-abc", nil) if err != nil { t.Fatal(err) } @@ -305,7 +348,7 @@ func TestBuildArgs_WithModel(t *testing.T) { } func TestBuildArgs_WithProvider(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "openrouter", "deepseek/deepseek-v4-pro", "", "", "session-abc", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "openrouter", "deepseek/deepseek-v4-pro", "", "", "", "session-abc", nil) if err != nil { t.Fatal(err) } @@ -318,8 +361,20 @@ func TestBuildArgs_WithProvider(t *testing.T) { } } +func TestBuildArgs_WithReasoningEffort(t *testing.T) { + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "openrouter", "deepseek/deepseek-v4-pro", "high", "", "", "session-abc", nil) + if err != nil { + t.Fatal(err) + } + args := s.buildArgs("task") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--reasoning-effort high") { + t.Errorf("buildArgs() = %v missing reasoning effort", args) + } +} + func TestSessionID_GeneratedWhenEmpty(t *testing.T) { - s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "", nil) + s, err := newDSHSession(context.Background(), "dsh", nil, "/tmp", "", "", "", "", "", "", nil) if err != nil { t.Fatal(err) } @@ -378,7 +433,7 @@ func TestSend_Success(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, false, false) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "deepseek-v4-flash", "confirm", "", "session-test-1", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "deepseek-v4-flash", "", "confirm", "", "session-test-1", nil) if err != nil { t.Fatal(err) } @@ -447,7 +502,7 @@ func TestSend_Approval(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, false, true) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "confirm", "", "session-approval", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "", "confirm", "", "session-approval", nil) if err != nil { t.Fatal(err) } @@ -501,7 +556,7 @@ loop: } func TestRespondPermission_NoActiveRun(t *testing.T) { - s, err := newDSHSession(context.Background(), "echo", nil, t.TempDir(), "", "", "", "", "session-x", nil) + s, err := newDSHSession(context.Background(), "echo", nil, t.TempDir(), "", "", "", "", "", "session-x", nil) if err != nil { t.Fatal(err) } @@ -516,7 +571,7 @@ func TestSend_Error(t *testing.T) { argvFile := filepath.Join(t.TempDir(), "argv.txt") script := fakeDSHScript(t, argvFile, true, false) - s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "", "", "session-test-2", nil) + s, err := newDSHSession(context.Background(), script, nil, t.TempDir(), "", "", "", "", "", "session-test-2", nil) if err != nil { t.Fatal(err) } diff --git a/agent/dsh/models.go b/agent/dsh/models.go index 3418b9263..eec8f36c6 100644 --- a/agent/dsh/models.go +++ b/agent/dsh/models.go @@ -56,8 +56,9 @@ type dshModelCatalogEntry struct { } type dshModelCatalogResponse struct { - Type string `json:"type"` - Models []dshModelCatalogEntry `json:"models"` + Type string `json:"type"` + Models []dshModelCatalogEntry `json:"models"` + ReasoningEfforts []string `json:"reasoningEfforts"` } // readRuntimeModelCatalog asks the running dsh composition for the same model @@ -65,14 +66,58 @@ type dshModelCatalogResponse struct { // built-in catalogs, Nix-injected model rows, and settings/profile merge rules // in Go. func (a *Agent) readRuntimeModelCatalog(ctx context.Context) []core.ModelOption { + response := a.readRuntimeModelCatalogResponse(ctx) + if response == nil || len(response.Models) == 0 { + return nil + } + return modelOptionsFromCatalog(response.Models) +} + +func (a *Agent) readRuntimeReasoningEfforts(ctx context.Context) []string { + response := a.readRuntimeModelCatalogResponse(ctx) + if response == nil { + return nil + } + seen := make(map[string]struct{}, len(response.ReasoningEfforts)) + levels := make([]string, 0, len(response.ReasoningEfforts)) + for _, raw := range response.ReasoningEfforts { + level := normalizeReasoningEffort(raw) + if level == "" { + continue + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + levels = append(levels, level) + } + return levels +} + +func (a *Agent) readRuntimeModelCatalogResponse(ctx context.Context) *dshModelCatalogResponse { a.mu.Lock() cmdName := a.cmd extraArgs := append([]string(nil), a.cliExtraArgs...) configEnv := append([]string(nil), a.configEnv...) + provider := a.provider + model := a.model workDir := a.workDir a.mu.Unlock() + if provider == "" { + provider = readDefaultProvider() + } + if model == "" { + model, _ = readDefaultModel() + } - args := append(extraArgs, "--profile", "headless", "--list-models") + args := append(extraArgs, "--profile", "headless") + if provider != "" { + args = append(args, "--provider", provider) + } + if model != "" { + args = append(args, "--model", model) + } + args = append(args, "--list-models") cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = workDir cmd.Env = core.MergeEnv(os.Environ(), configEnv) @@ -94,10 +139,10 @@ func (a *Agent) readRuntimeModelCatalog(ctx context.Context) []core.ModelOption break } } - if len(response.Models) == 0 { + if len(response.Models) == 0 && len(response.ReasoningEfforts) == 0 { return nil } - return modelOptionsFromCatalog(response.Models) + return &response } func modelOptionsFromCatalog(entries []dshModelCatalogEntry) []core.ModelOption { @@ -299,6 +344,14 @@ func readDefaultProvider() string { return strings.TrimSpace(s.AgentDefaultModel.Provider) } +func readDefaultReasoningEffort() string { + s := readDSHSettings() + if s == nil { + return "" + } + return normalizeReasoningEffort(s.AgentDefaultModel.ReasoningEffort) +} + // readSettingsModels returns the advisory model catalog dsh knows about: // the `llm-deepseek.models` section of settings.yaml when present, else nil. func readSettingsModels(provider string) []core.ModelOption { diff --git a/agent/dsh/session.go b/agent/dsh/session.go index e6545da89..5c08fd9eb 100644 --- a/agent/dsh/session.go +++ b/agent/dsh/session.go @@ -21,30 +21,31 @@ import ( ) // dshSession runs a multi-turn dsh conversation. Each Send() spawns -// `dsh --profile headless --provider ... --model ... --jsonl` as a one-shot process; -// the persisted dsh -// session (identified by the cc-connect-owned session id) is resumed on every -// run, so context carries across turns. +// `dsh --profile headless --provider ... --model ... --reasoning-effort ... +// --jsonl` as a one-shot process; the persisted dsh session (identified by the +// cc-connect-owned session id) is resumed on every run, so context carries +// across turns. // // With --jsonl the dsh headless runner streams events on stdout (text / // thinking deltas, tool calls, approval requests, result/done envelopes) and // reads approval responses from stdin, so cc-connect can show tool progress // in the chat and relay permission decisions from the human (Feishu card). type dshSession struct { - cmd string - extraArgs []string // extra args from cmd, prepended before dsh args - workDir string - provider string // provider route override ("" = use dsh settings default) - model string // "" = use dsh settings default - mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" - preset string // dsh agent preset requested for this session's next run - extraEnv []string - events chan core.Event - sessionID atomic.Value - ctx context.Context - cancel context.CancelFunc - sendWg sync.WaitGroup // tracks in-flight Send() calls - alive atomic.Bool + cmd string + extraArgs []string // extra args from cmd, prepended before dsh args + workDir string + provider string // provider route override ("" = use dsh settings default) + model string // "" = use dsh settings default + reasoningEffort string // "" = use dsh settings default + mode string // "read-only" | "workspace-write" | "danger-full-access" | "confirm" + preset string // dsh agent preset requested for this session's next run + extraEnv []string + events chan core.Event + sessionID atomic.Value + ctx context.Context + cancel context.CancelFunc + sendWg sync.WaitGroup // tracks in-flight Send() calls + alive atomic.Bool // per-run process wiring (nil between runs) runMu sync.Mutex // guards stdin + pendingApprovals @@ -56,20 +57,21 @@ type dshSession struct { // newDSHSession creates a session. sessionID is the cc-connect-persisted dsh // session id (empty on the first message); when empty, one is generated so // every run after the first resumes the same conversation. -func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, provider, model, mode, preset, sessionID string, extraEnv []string) (*dshSession, error) { +func newDSHSession(ctx context.Context, cmd string, extraArgs []string, workDir, provider, model, reasoningEffort, mode, preset, sessionID string, extraEnv []string) (*dshSession, error) { ctx, cancel := context.WithCancel(ctx) s := &dshSession{ - cmd: cmd, - extraArgs: extraArgs, - workDir: workDir, - provider: strings.TrimSpace(provider), - model: model, - mode: mode, - preset: strings.TrimSpace(preset), - extraEnv: extraEnv, - events: make(chan core.Event, 64), - ctx: ctx, - cancel: cancel, + cmd: cmd, + extraArgs: extraArgs, + workDir: workDir, + provider: strings.TrimSpace(provider), + model: model, + reasoningEffort: normalizeReasoningEffort(reasoningEffort), + mode: mode, + preset: strings.TrimSpace(preset), + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: ctx, + cancel: cancel, } s.alive.Store(true) @@ -344,6 +346,9 @@ func (s *dshSession) buildArgs(prompt string) []string { if s.model != "" { args = append(args, "--model", s.model) } + if s.reasoningEffort != "" { + args = append(args, "--reasoning-effort", s.reasoningEffort) + } if s.mode != "" { args = append(args, "--mode", s.mode) } diff --git a/config.example.toml b/config.example.toml index fbf80f536..05c3e9ac0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1928,7 +1928,7 @@ app_secret = "your-feishu-app-secret" # # The installed dsh must carry the headless-cc-connect patch (see the user's # home-manager agent/dsh/dsh.nix), which adds --session-id / --provider / -# --model / --mode / --preset / --list-models / --jsonl support to the +# --model / --reasoning-effort / --mode / --preset / --list-models / --jsonl support to the # headless runner. `--list-models` must return the dsh runtime provider/model # catalog so `/model` does not fall back to only the two native DeepSeek models. # @@ -1952,6 +1952,7 @@ app_secret = "your-feishu-app-secret" # # (confirm = full access + ask, matches the dsh web profile default; # # headless 无审批端时 confirm 等同 danger-full-access) # # provider = "openrouter" # optional / 可选; `/model` switches this together with the model +# # reasoning_effort = "high" # optional / 可选; `/reasoning` changes it for the next session # # model = "deepseek-v4-flash" # optional / 可选; 未配置时使用 dsh settings 中的默认模型 # # `/model` reads dsh's live catalog; `/preset` lists/selects a blank-session preset # # and `/mode` selects the permission mode. Preset changes are locked after history starts. diff --git a/core/engine.go b/core/engine.go index bfa0d2289..bf4761099 100644 --- a/core/engine.go +++ b/core/engine.go @@ -3883,6 +3883,23 @@ func (e *Engine) getOrCreateWorkspaceAgent(workspace string) (Agent, *SessionMan } } } + // Copy provider route and reasoning effort for agents that expose the + // optional model-control capabilities. Without these, a newly created + // workspace agent silently reverts to the deployment defaults. + if _, ok := opts["provider"]; !ok { + if ma, ok := e.agent.(ProviderModelSwitcher); ok { + if provider := ma.GetModelProvider(); provider != "" { + opts["provider"] = provider + } + } + } + if _, ok := opts["reasoning_effort"]; !ok { + if ma, ok := e.agent.(ReasoningEffortSwitcher); ok { + if effort := ma.GetReasoningEffort(); effort != "" { + opts["reasoning_effort"] = effort + } + } + } // Copy run_as_user (and run_as_env) for OS-level isolation. Without // this, per-workspace agents silently bypass the project-level // run_as_user config because their opts map is freshly constructed