diff --git a/Makefile b/Makefile index c5654c5a9..f5b610e10 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ PLATFORMS := \ 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_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 googlechat ALL_EXTRAS := web diff --git a/agent/dsh/dsh.go b/agent/dsh/dsh.go new file mode 100644 index 000000000..ae00cc298 --- /dev/null +++ b/agent/dsh/dsh.go @@ -0,0 +1,326 @@ +// 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 +// --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 +// +// 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) + 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. +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + provider, _ := opts["provider"].(string) + reasoningEffort, _ := opts["reasoning_effort"].(string) + if strings.TrimSpace(provider) == "" { + provider = readDefaultProvider() + } + 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, + provider: strings.TrimSpace(provider), + reasoningEffort: normalizeReasoningEffort(reasoningEffort), + 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 "" +} + +// 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. + 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 +} + +// ── 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) { + 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) { + 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 + 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() + 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) { + 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..44f4744ac --- /dev/null +++ b/agent/dsh/dsh_test.go @@ -0,0 +1,725 @@ +package dsh + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/klauspost/compress/zstd" +) + +// ── 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 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 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) + a.SetMode("yolo") + if got := a.GetMode(); got != "danger-full-access" { + t.Errorf("GetMode() = %q, want danger-full-access", got) + } +} + +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() + 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 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 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(` +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) + } +} + +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", "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", "--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) + 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 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 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) + 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 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 +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) + } + return script +} + +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) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + done := make(chan error, 1) + 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: + for { + select { + case evt := <-s.Events(): + 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 + 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) != 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" || result.Content != "final answer" { + 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", "--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 func() { _ = 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") + return + } + 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 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) + } +} + +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) + if err != nil { + t.Fatal(err) + } + defer func() { _ = 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) + } +} + +// ── 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 new file mode 100644 index 000000000..eec8f36c6 --- /dev/null +++ b/agent/dsh/models.go @@ -0,0 +1,535 @@ +package dsh + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/chenhg5/cc-connect/core" + "github.com/klauspost/compress/zstd" + "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"` + 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"` + ReasoningEfforts []string `json:"reasoningEfforts"` +} + +// 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 { + 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") + 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) + 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 && len(response.ReasoningEfforts) == 0 { + return nil + } + return &response +} + +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. +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 +} + +func readDefaultProvider() string { + s := readDSHSettings() + if s == nil { + return "" + } + 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 { + 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, Provider: provider} + 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 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 == "" { + 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 + } + title, msgCount := readDSHSessionSummary(filepath.Join(root, entry.Name())) + sessions = append(sessions, core.AgentSessionInfo{ + ID: entry.Name(), + Summary: title, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), + }) + } + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + 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/proc_unix.go b/agent/dsh/proc_unix.go new file mode 100644 index 000000000..950e042d9 --- /dev/null +++ b/agent/dsh/proc_unix.go @@ -0,0 +1,21 @@ +//go:build unix + +package dsh + +import ( + "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 +} diff --git a/agent/dsh/proc_windows.go b/agent/dsh/proc_windows.go new file mode 100644 index 000000000..3266dd5a4 --- /dev/null +++ b/agent/dsh/proc_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package dsh + +import ( + "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 +} diff --git a/agent/dsh/session.go b/agent/dsh/session.go new file mode 100644 index 000000000..5c08fd9eb --- /dev/null +++ b/agent/dsh/session.go @@ -0,0 +1,487 @@ +package dsh + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "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 --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 + 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 + stdin io.WriteCloser + pending map[string]struct{} // approval ids awaiting a human decision + +} + +// 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, 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, + 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) + + 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 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() + + 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 + + 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) + } + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + prepareCmdForKill(cmd) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("dsh: start: %w", err) + } + + 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) + + // 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 + + // 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() + s.stdin = nil + } + 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 + // cancelled run surfaces no spurious error turn to the engine. + slog.Error("dshSession: process error", "cmd", s.cmd, "error", err, "stderr", truncStr(stderrMsg, 1000)) + } + + 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 && *finalText == "" { + s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("dsh: %s", err)}) + } + + // 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 { + 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.provider != "" { + args = append(args, "--provider", s.provider) + } + 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) + } + s.runMu.Lock() + preset := s.preset + s.runMu.Unlock() + if preset != "" { + args = append(args, "--preset", preset) + } + args = append(args, "--jsonl") + 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() +} + +// 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() + 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 +} + +// ── Helpers ────────────────────────────────────────────────── + +// 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..05c3e9ac0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1918,6 +1918,59 @@ 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 / --provider / +# --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. +# +# 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) +# # 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. +# +# # 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 # ============================================================================= 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..bf4761099 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. @@ -3882,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 @@ -4088,7 +4106,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 +4125,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 +6289,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 +6501,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 +9284,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 +9556,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 +9572,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 +9591,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 +9619,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 +9655,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 +9768,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 +9956,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 +12077,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 +12094,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 +12177,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 +12198,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 +12273,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 +12288,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 +12815,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 +13060,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 +13083,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 +13152,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 +16004,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 +16018,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 { 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=