753 lines
22 KiB
Go
753 lines
22 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func TestNew_ParsesRunAsUserAndRunAsEnv(t *testing.T) {
|
|
opts := map[string]any{
|
|
"work_dir": "/tmp/claudecode-test",
|
|
"run_as_user": "partseeker-coder",
|
|
"run_as_env": []any{"PGSSLROOTCERT", "PGSSLMODE"},
|
|
}
|
|
a, err := New(opts)
|
|
if err != nil {
|
|
t.Fatalf("New returned error: %v", err)
|
|
}
|
|
ag, ok := a.(*Agent)
|
|
if !ok {
|
|
t.Fatalf("agent is not *Agent: %T", a)
|
|
}
|
|
if ag.spawnOpts.RunAsUser != "partseeker-coder" {
|
|
t.Errorf("spawnOpts.RunAsUser = %q, want %q", ag.spawnOpts.RunAsUser, "partseeker-coder")
|
|
}
|
|
if got := ag.spawnOpts.EnvAllowlist; len(got) != 2 || got[0] != "PGSSLROOTCERT" || got[1] != "PGSSLMODE" {
|
|
t.Errorf("spawnOpts.EnvAllowlist = %v, want [PGSSLROOTCERT PGSSLMODE]", got)
|
|
}
|
|
}
|
|
|
|
func TestNew_RunAsUserSkipsClaudeLookPath(t *testing.T) {
|
|
// With run_as_user set, the supervisor's PATH lookup for "claude" is
|
|
// skipped because the target user's PATH is what matters. Verify that
|
|
// New() doesn't fail even when claude isn't on this test process's PATH.
|
|
opts := map[string]any{
|
|
"work_dir": "/tmp/claudecode-test",
|
|
"run_as_user": "target-that-definitely-exists",
|
|
}
|
|
// Note: this test relies on New() NOT calling exec.LookPath("claude")
|
|
// when run_as_user is set. If claude IS on PATH in the test env,
|
|
// either branch of the code returns success and the test still passes.
|
|
if _, err := New(opts); err != nil {
|
|
// The only other reason New() could fail for these opts is the
|
|
// LookPath check — fail loudly if that's what happened.
|
|
t.Errorf("New with run_as_user returned error (LookPath not skipped?): %v", err)
|
|
}
|
|
_ = core.AgentSystemPrompt // keep the core import used
|
|
}
|
|
|
|
func TestParseUserQuestions_ValidInput(t *testing.T) {
|
|
input := map[string]any{
|
|
"questions": []any{
|
|
map[string]any{
|
|
"question": "Which database?",
|
|
"header": "Setup",
|
|
"multiSelect": false,
|
|
"options": []any{
|
|
map[string]any{"label": "PostgreSQL", "description": "Production"},
|
|
map[string]any{"label": "SQLite", "description": "Dev"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
qs := parseUserQuestions(input)
|
|
if len(qs) != 1 {
|
|
t.Fatalf("expected 1 question, got %d", len(qs))
|
|
}
|
|
q := qs[0]
|
|
if q.Question != "Which database?" {
|
|
t.Errorf("question = %q", q.Question)
|
|
}
|
|
if q.Header != "Setup" {
|
|
t.Errorf("header = %q", q.Header)
|
|
}
|
|
if q.MultiSelect {
|
|
t.Error("expected multiSelect=false")
|
|
}
|
|
if len(q.Options) != 2 {
|
|
t.Fatalf("expected 2 options, got %d", len(q.Options))
|
|
}
|
|
if q.Options[0].Label != "PostgreSQL" {
|
|
t.Errorf("option[0].label = %q", q.Options[0].Label)
|
|
}
|
|
if q.Options[1].Description != "Dev" {
|
|
t.Errorf("option[1].description = %q", q.Options[1].Description)
|
|
}
|
|
}
|
|
|
|
func TestParseUserQuestions_EmptyInput(t *testing.T) {
|
|
qs := parseUserQuestions(map[string]any{})
|
|
if len(qs) != 0 {
|
|
t.Errorf("expected 0 questions, got %d", len(qs))
|
|
}
|
|
}
|
|
|
|
func TestParseUserQuestions_NoQuestionText(t *testing.T) {
|
|
input := map[string]any{
|
|
"questions": []any{
|
|
map[string]any{"header": "Setup"},
|
|
},
|
|
}
|
|
qs := parseUserQuestions(input)
|
|
if len(qs) != 0 {
|
|
t.Errorf("expected 0 questions (no question text), got %d", len(qs))
|
|
}
|
|
}
|
|
|
|
func TestParseUserQuestions_MultiSelect(t *testing.T) {
|
|
input := map[string]any{
|
|
"questions": []any{
|
|
map[string]any{
|
|
"question": "Select features",
|
|
"multiSelect": true,
|
|
"options": []any{
|
|
map[string]any{"label": "Auth"},
|
|
map[string]any{"label": "Logging"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
qs := parseUserQuestions(input)
|
|
if len(qs) != 1 {
|
|
t.Fatalf("expected 1 question, got %d", len(qs))
|
|
}
|
|
if !qs[0].MultiSelect {
|
|
t.Error("expected multiSelect=true")
|
|
}
|
|
}
|
|
|
|
func TestNormalizePermissionMode(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
// dontAsk aliases
|
|
{"dontAsk", "dontAsk"},
|
|
{"dontask", "dontAsk"},
|
|
{"dont-ask", "dontAsk"},
|
|
{"dont_ask", "dontAsk"},
|
|
// auto
|
|
{"auto", "auto"},
|
|
// bypassPermissions aliases
|
|
{"bypassPermissions", "bypassPermissions"},
|
|
{"yolo", "bypassPermissions"},
|
|
// acceptEdits aliases
|
|
{"acceptEdits", "acceptEdits"},
|
|
{"edit", "acceptEdits"},
|
|
// plan
|
|
{"plan", "plan"},
|
|
// default fallback
|
|
{"", "default"},
|
|
{"unknown", "default"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := normalizePermissionMode(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClaudeSessionSetLiveMode(t *testing.T) {
|
|
cs := &claudeSession{}
|
|
cs.setPermissionMode("default")
|
|
if cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() {
|
|
t.Fatal("expected default mode flags to be off")
|
|
}
|
|
|
|
if !cs.SetLiveMode("acceptEdits") {
|
|
t.Fatal("SetLiveMode(acceptEdits) = false, want true")
|
|
}
|
|
if !cs.acceptEditsOnly.Load() || cs.autoApprove.Load() || cs.dontAsk.Load() {
|
|
t.Fatal("acceptEdits flags not set correctly")
|
|
}
|
|
|
|
if cs.SetLiveMode("auto") {
|
|
t.Fatal("SetLiveMode(auto) = true, want false")
|
|
}
|
|
|
|
cs.SetLiveMode("dontAsk")
|
|
if !cs.dontAsk.Load() || cs.autoApprove.Load() || cs.acceptEditsOnly.Load() {
|
|
t.Fatal("dontAsk flags not set correctly")
|
|
}
|
|
|
|
cs.SetLiveMode("bypassPermissions")
|
|
if !cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() {
|
|
t.Fatal("bypassPermissions alias flags not set correctly")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSessionSetLiveMode_AutoSessionRequiresRestart(t *testing.T) {
|
|
cs := &claudeSession{}
|
|
cs.setPermissionMode("auto")
|
|
if cs.SetLiveMode("default") {
|
|
t.Fatal("SetLiveMode(default) from auto session = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestAgent_PermissionModes(t *testing.T) {
|
|
a := &Agent{}
|
|
modes := a.PermissionModes()
|
|
if len(modes) == 0 {
|
|
t.Fatal("PermissionModes() returned no modes")
|
|
}
|
|
|
|
foundAuto := false
|
|
foundBypass := false
|
|
for _, mode := range modes {
|
|
if mode.Key == "auto" {
|
|
foundAuto = true
|
|
}
|
|
if mode.Key == "bypassPermissions" {
|
|
foundBypass = true
|
|
}
|
|
}
|
|
if !foundAuto {
|
|
t.Fatal("PermissionModes() missing auto mode")
|
|
}
|
|
if !foundBypass {
|
|
t.Fatal("PermissionModes() missing bypassPermissions mode")
|
|
}
|
|
}
|
|
|
|
func TestIsClaudeEditTool(t *testing.T) {
|
|
for _, tool := range []string{"Edit", "Write", "NotebookEdit", "MultiEdit"} {
|
|
if !isClaudeEditTool(tool) {
|
|
t.Fatalf("isClaudeEditTool(%q) = false, want true", tool)
|
|
}
|
|
}
|
|
if isClaudeEditTool("Bash") {
|
|
t.Fatal("isClaudeEditTool(Bash) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestSummarizeInput_AskUserQuestion(t *testing.T) {
|
|
input := map[string]any{
|
|
"questions": []any{
|
|
map[string]any{
|
|
"question": "Which framework?",
|
|
"options": []any{
|
|
map[string]any{"label": "React"},
|
|
map[string]any{"label": "Vue"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := summarizeInput("AskUserQuestion", input)
|
|
if result == "" {
|
|
t.Error("expected non-empty summary for AskUserQuestion")
|
|
}
|
|
}
|
|
|
|
func TestAgent_Name(t *testing.T) {
|
|
a := &Agent{}
|
|
if got := a.Name(); got != "claudecode" {
|
|
t.Errorf("Name() = %q, want %q", got, "claudecode")
|
|
}
|
|
}
|
|
|
|
func TestAgent_CLIBinaryName(t *testing.T) {
|
|
a := &Agent{cliBin: "claude"}
|
|
if got := a.CLIBinaryName(); got != "claude" {
|
|
t.Errorf("CLIBinaryName() = %q, want %q", got, "claude")
|
|
}
|
|
|
|
a2 := &Agent{cliBin: "my-cli"}
|
|
if got := a2.CLIBinaryName(); got != "my-cli" {
|
|
t.Errorf("CLIBinaryName() = %q, want %q", got, "my-cli")
|
|
}
|
|
}
|
|
|
|
func TestAgent_CLIDisplayName(t *testing.T) {
|
|
a := &Agent{}
|
|
if got := a.CLIDisplayName(); got != "Claude" {
|
|
t.Errorf("CLIDisplayName() = %q, want %q", got, "Claude")
|
|
}
|
|
}
|
|
|
|
func TestAgent_SetWorkDir(t *testing.T) {
|
|
a := &Agent{}
|
|
a.SetWorkDir("/tmp/test")
|
|
if got := a.GetWorkDir(); got != "/tmp/test" {
|
|
t.Errorf("GetWorkDir() = %q, want %q", got, "/tmp/test")
|
|
}
|
|
}
|
|
|
|
func TestAgent_SetModel(t *testing.T) {
|
|
a := &Agent{}
|
|
a.SetModel("claude-sonnet-4-20250514")
|
|
if got := a.GetModel(); got != "claude-sonnet-4-20250514" {
|
|
t.Errorf("GetModel() = %q, want %q", got, "claude-sonnet-4-20250514")
|
|
}
|
|
}
|
|
|
|
func TestAgent_SetSessionEnv(t *testing.T) {
|
|
a := &Agent{}
|
|
a.SetSessionEnv([]string{"KEY=value"})
|
|
if len(a.sessionEnv) != 1 || a.sessionEnv[0] != "KEY=value" {
|
|
t.Errorf("sessionEnv = %v, want [KEY=value]", a.sessionEnv)
|
|
}
|
|
}
|
|
|
|
func TestAgent_SetPlatformPrompt(t *testing.T) {
|
|
a := &Agent{}
|
|
a.SetPlatformPrompt("You are a helpful assistant on Feishu.")
|
|
if a.platformPrompt != "You are a helpful assistant on Feishu." {
|
|
t.Errorf("platformPrompt = %q, want %q", a.platformPrompt, "You are a helpful assistant on Feishu.")
|
|
}
|
|
}
|
|
|
|
func TestAgent_SetMode(t *testing.T) {
|
|
a := &Agent{}
|
|
|
|
a.SetMode("auto")
|
|
if got := a.GetMode(); got != "auto" {
|
|
t.Fatalf("GetMode() after SetMode(auto) = %q, want auto", got)
|
|
}
|
|
|
|
a.SetMode("yolo")
|
|
if got := a.GetMode(); got != "bypassPermissions" {
|
|
t.Fatalf("GetMode() after SetMode(yolo) = %q, want bypassPermissions", got)
|
|
}
|
|
}
|
|
|
|
func TestStripXMLTags(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"<tag>content</tag>", "content"},
|
|
{"no tags", "no tags"},
|
|
{"<a>hello</a><b>world</b>", "helloworld"},
|
|
{"<nested><inner>text</inner></nested>", "text"},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := stripXMLTags(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("stripXMLTags(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// verify Agent implements core.Agent
|
|
var _ core.Agent = (*Agent)(nil)
|
|
|
|
func TestEncodeClaudeProjectKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple ASCII path",
|
|
input: "/Users/username/Documents/project",
|
|
expected: "-Users-username-Documents-project",
|
|
},
|
|
{
|
|
name: "path with Chinese characters",
|
|
input: "/Users/username/Documents/项目文件夹",
|
|
expected: "-Users-username-Documents------", // 6 hyphens: 1 for "/" + 5 for Chinese chars
|
|
},
|
|
{
|
|
name: "path with Japanese characters",
|
|
input: "/Users/username/Documents/プロジェクト",
|
|
expected: "-Users-username-Documents-------", // 6 hyphens: 1 for "/" + 5 for Japanese chars
|
|
},
|
|
{
|
|
name: "path with emoji",
|
|
input: "/Users/username/Documents/🎉project",
|
|
expected: "-Users-username-Documents--project", // 2 hyphens: 1 for "/" + 1 for emoji
|
|
},
|
|
{
|
|
name: "Windows path with colon",
|
|
input: "C:\\Users\\username\\Documents",
|
|
expected: "C--Users-username-Documents",
|
|
},
|
|
{
|
|
name: "path with underscore",
|
|
input: "/Users/username/my_project",
|
|
expected: "-Users-username-my-project",
|
|
},
|
|
{
|
|
name: "path with spaces",
|
|
input: "/Users/username/Mobile Documents/my project",
|
|
expected: "-Users-username-Mobile-Documents-my-project",
|
|
},
|
|
{
|
|
name: "path with tildes",
|
|
input: "/Users/username/com~apple~CloudDocs/project",
|
|
expected: "-Users-username-com-apple-CloudDocs-project",
|
|
},
|
|
{
|
|
name: "iCloud path with spaces and tildes",
|
|
input: "/Users/username/Library/Mobile Documents/com~apple~CloudDocs/my project",
|
|
expected: "-Users-username-Library-Mobile-Documents-com-apple-CloudDocs-my-project",
|
|
},
|
|
{
|
|
name: "mixed ASCII and non-ASCII",
|
|
input: "/Users/username/中文folder/english文件夹",
|
|
expected: "-Users-username---folder-english---", // "/中文" = 3 hyphens, "/文件夹" = 4 hyphens
|
|
},
|
|
{
|
|
name: "empty path",
|
|
input: "",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := encodeClaudeProjectKey(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("encodeClaudeProjectKey(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindProjectDir_NonASCIIPath(t *testing.T) {
|
|
// This test verifies that findProjectDir can handle non-ASCII paths
|
|
// by creating a mock projects directory structure
|
|
homeDir := t.TempDir()
|
|
projectsBase := filepath.Join(homeDir, ".claude", "projects")
|
|
|
|
// Test case: Chinese characters in path
|
|
chineseWorkDir := "/Users/test/Documents/项目文件夹"
|
|
expectedKey := encodeClaudeProjectKey(chineseWorkDir)
|
|
|
|
// Create the mock project directory
|
|
mockProjectDir := filepath.Join(projectsBase, expectedKey)
|
|
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
|
|
t.Fatalf("failed to create mock project dir: %v", err)
|
|
}
|
|
|
|
// Verify findProjectDir finds the directory
|
|
found := findProjectDir(homeDir, chineseWorkDir)
|
|
if found != mockProjectDir {
|
|
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, chineseWorkDir, found, mockProjectDir)
|
|
}
|
|
}
|
|
|
|
func TestFindProjectDir_ASCIIPath(t *testing.T) {
|
|
// Verify ASCII paths still work correctly
|
|
homeDir := t.TempDir()
|
|
projectsBase := filepath.Join(homeDir, ".claude", "projects")
|
|
|
|
asciiWorkDir := "/Users/test/Documents/project"
|
|
expectedKey := encodeClaudeProjectKey(asciiWorkDir)
|
|
|
|
mockProjectDir := filepath.Join(projectsBase, expectedKey)
|
|
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
|
|
t.Fatalf("failed to create mock project dir: %v", err)
|
|
}
|
|
|
|
found := findProjectDir(homeDir, asciiWorkDir)
|
|
if found != mockProjectDir {
|
|
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, asciiWorkDir, found, mockProjectDir)
|
|
}
|
|
}
|
|
|
|
func TestFindProjectDir_NotFound(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
// Don't create any project directories
|
|
|
|
workDir := "/Users/test/Documents/nonexistent"
|
|
found := findProjectDir(homeDir, workDir)
|
|
if found != "" {
|
|
t.Errorf("findProjectDir for nonexistent project = %q, want empty string", found)
|
|
}
|
|
}
|
|
|
|
func TestFindProjectDir_ICloudPath(t *testing.T) {
|
|
// Regression for issue #500: paths containing spaces and "~" (common in macOS
|
|
// iCloud Drive paths like "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...")
|
|
// must match the on-disk project key that Claude Code CLI generates, which
|
|
// collapses both spaces and "~" to "-".
|
|
homeDir := t.TempDir()
|
|
projectsBase := filepath.Join(homeDir, ".claude", "projects")
|
|
|
|
iCloudWorkDir := "/Users/test/Library/Mobile Documents/com~apple~CloudDocs/my project"
|
|
// The on-disk key Claude Code CLI actually writes (spaces and "~" → "-").
|
|
expectedKey := "-Users-test-Library-Mobile-Documents-com-apple-CloudDocs-my-project"
|
|
|
|
mockProjectDir := filepath.Join(projectsBase, expectedKey)
|
|
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
|
|
t.Fatalf("failed to create mock project dir: %v", err)
|
|
}
|
|
|
|
found := findProjectDir(homeDir, iCloudWorkDir)
|
|
if found != mockProjectDir {
|
|
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, iCloudWorkDir, found, mockProjectDir)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotCLIPath(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cliBin string
|
|
extraArgs []string
|
|
want string
|
|
}{
|
|
{"default-claude-skipped", "claude", nil, ""},
|
|
{"empty-binary-skipped", "", nil, ""},
|
|
{"custom-binary-only", "/usr/local/bin/claude", nil, "/usr/local/bin/claude"},
|
|
{"wrapper-with-args", "my-cli", []string{"code", "-t", "foo"}, "my-cli code -t foo"},
|
|
{"claude-with-add-dir", "claude", []string{"--add-dir", "/parent"}, "claude --add-dir /parent"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := snapshotCLIPath(tc.cliBin, tc.extraArgs)
|
|
if got != tc.want {
|
|
t.Errorf("snapshotCLIPath(%q, %v) = %q, want %q", tc.cliBin, tc.extraArgs, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) {
|
|
// Construct an Agent directly so we don't depend on `claude` being on
|
|
// PATH. WorkspaceAgentOptions only reads fields that the production
|
|
// New() also writes; this just verifies the snapshot shape.
|
|
a := &Agent{
|
|
cliBin: "my-cli",
|
|
cliExtraArgs: []string{"--add-dir", "/parent"},
|
|
cliArgsFlag: "-a",
|
|
model: "claude-opus-4-7",
|
|
reasoningEffort: "high",
|
|
mode: "acceptEdits",
|
|
allowedTools: []string{"Edit", "Read"},
|
|
disallowedTools: []string{"Bash"},
|
|
maxContextTokens: 200000,
|
|
routerURL: "http://127.0.0.1:3456",
|
|
routerAPIKey: "secret",
|
|
}
|
|
got := a.WorkspaceAgentOptions()
|
|
|
|
want := map[string]any{
|
|
"mode": "acceptEdits",
|
|
"cli_path": "my-cli --add-dir /parent",
|
|
"cli_args_flag": "-a",
|
|
"model": "claude-opus-4-7",
|
|
"reasoning_effort": "high",
|
|
"allowed_tools": []any{"Edit", "Read"},
|
|
"disallowed_tools": []any{"Bash"},
|
|
"max_context_tokens": 200000,
|
|
"router_url": "http://127.0.0.1:3456",
|
|
"router_api_key": "secret",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("snapshot len = %d, want %d (got=%v)", len(got), len(want), got)
|
|
}
|
|
for k, wv := range want {
|
|
gv, ok := got[k]
|
|
if !ok {
|
|
t.Errorf("snapshot missing key %q", k)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(gv, wv) {
|
|
t.Errorf("snapshot[%q] = %v (%T), want %v (%T)", k, gv, gv, wv, wv)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceAgentOptions_OmitsZeroValues(t *testing.T) {
|
|
// Default agent (only mode is always emitted, plus default cliBin
|
|
// "claude" should be skipped by snapshotCLIPath).
|
|
a := &Agent{cliBin: "claude", mode: "default"}
|
|
got := a.WorkspaceAgentOptions()
|
|
|
|
if len(got) != 1 {
|
|
t.Errorf("snapshot len = %d, want 1 (got=%v)", len(got), got)
|
|
}
|
|
if got["mode"] != "default" {
|
|
t.Errorf("snapshot[mode] = %v, want %q", got["mode"], "default")
|
|
}
|
|
for _, k := range []string{
|
|
"cli_path", "cli_args_flag", "model", "reasoning_effort",
|
|
"allowed_tools", "disallowed_tools", "max_context_tokens",
|
|
"router_url", "router_api_key",
|
|
} {
|
|
if _, ok := got[k]; ok {
|
|
t.Errorf("snapshot unexpectedly includes %q = %v", k, got[k])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceAgentOptions_RoundTripsThroughNew(t *testing.T) {
|
|
// End-to-end: snapshot → New() should reproduce every field. Use
|
|
// run_as_user to skip the supervisor-side LookPath check, since the
|
|
// fake "my-cli" binary doesn't exist on the test host's PATH.
|
|
//
|
|
// run_as_user only short-circuits LookPath on platforms where
|
|
// SpawnOptions.IsolationMode() can be true — i.e. Unix. On Windows
|
|
// it always returns false (see core/runas_windows.go), so the fake
|
|
// CLI would fail LookPath and New() would error out before the
|
|
// round-trip assertions run.
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("run_as_user-based LookPath bypass is Unix-only")
|
|
}
|
|
parent := &Agent{
|
|
cliBin: "my-cli",
|
|
cliExtraArgs: []string{"code", "--add-dir", "/parent"},
|
|
cliArgsFlag: "-a",
|
|
model: "claude-opus-4-7",
|
|
reasoningEffort: "high",
|
|
mode: "acceptEdits",
|
|
allowedTools: []string{"Edit", "Read"},
|
|
disallowedTools: []string{"Bash"},
|
|
maxContextTokens: 200000,
|
|
routerURL: "http://127.0.0.1:3456",
|
|
routerAPIKey: "secret",
|
|
}
|
|
opts := parent.WorkspaceAgentOptions()
|
|
opts["work_dir"] = "/tmp/claudecode-test"
|
|
opts["run_as_user"] = "skip-lookpath"
|
|
|
|
a, err := New(opts)
|
|
if err != nil {
|
|
t.Fatalf("New(snapshot) returned error: %v", err)
|
|
}
|
|
child := a.(*Agent)
|
|
|
|
if child.cliBin != "my-cli" {
|
|
t.Errorf("cliBin = %q, want %q", child.cliBin, "my-cli")
|
|
}
|
|
if !reflect.DeepEqual(child.cliExtraArgs, []string{"code", "--add-dir", "/parent"}) {
|
|
t.Errorf("cliExtraArgs = %v, want [code --add-dir /parent]", child.cliExtraArgs)
|
|
}
|
|
if child.cliArgsFlag != "-a" {
|
|
t.Errorf("cliArgsFlag = %q, want -a", child.cliArgsFlag)
|
|
}
|
|
if child.model != "claude-opus-4-7" {
|
|
t.Errorf("model = %q, want claude-opus-4-7", child.model)
|
|
}
|
|
if child.reasoningEffort != "high" {
|
|
t.Errorf("reasoningEffort = %q, want high", child.reasoningEffort)
|
|
}
|
|
if child.mode != "acceptEdits" {
|
|
t.Errorf("mode = %q, want acceptEdits", child.mode)
|
|
}
|
|
if !reflect.DeepEqual(child.allowedTools, []string{"Edit", "Read"}) {
|
|
t.Errorf("allowedTools = %v, want [Edit Read]", child.allowedTools)
|
|
}
|
|
if !reflect.DeepEqual(child.disallowedTools, []string{"Bash"}) {
|
|
t.Errorf("disallowedTools = %v, want [Bash]", child.disallowedTools)
|
|
}
|
|
if child.maxContextTokens != 200000 {
|
|
t.Errorf("maxContextTokens = %d, want 200000", child.maxContextTokens)
|
|
}
|
|
if child.routerURL != "http://127.0.0.1:3456" {
|
|
t.Errorf("routerURL = %q, want http://127.0.0.1:3456", child.routerURL)
|
|
}
|
|
if child.routerAPIKey != "secret" {
|
|
t.Errorf("routerAPIKey = %q, want secret", child.routerAPIKey)
|
|
}
|
|
}
|
|
|
|
func TestScanSessionMeta_ArrayContent(t *testing.T) {
|
|
// Regression test for: scanSessionMeta skips entries where content is a JSON array
|
|
// (e.g., assistant messages with thinking blocks, or user messages with tool results).
|
|
tmpDir := t.TempDir()
|
|
path := filepath.Join(tmpDir, "test.jsonl")
|
|
|
|
lines := []string{
|
|
`{"type": "queue-operation", "operation": "start"}`,
|
|
`{"type": "user", "message": {"content": "Hello world"}}`,
|
|
`{"type": "assistant", "message": {"content": [{"type": "thinking", "text": ""}, {"type": "text", "text": "Hi there"}]}}`,
|
|
`{"type": "user", "message": {"content": [{"tool_use_id": "call_abc", "type": "tool_result", "content": "result data"}]}}`,
|
|
`{"type": "assistant", "message": {"content": "Plain text reply"}}`,
|
|
`{"type": "last-prompt", "lastPrompt": "test"}`,
|
|
}
|
|
|
|
data := ""
|
|
for _, line := range lines {
|
|
data += line + "\n"
|
|
}
|
|
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
|
t.Fatalf("write test jsonl: %v", err)
|
|
}
|
|
|
|
summary, count := scanSessionMeta(path)
|
|
|
|
// Expected: 2 user + 2 assistant = 4 messages
|
|
if count != 4 {
|
|
t.Errorf("scanSessionMeta count = %d, want 4 (2 user + 2 assistant, array content should not be skipped)", count)
|
|
}
|
|
|
|
// Summary should come from the last user message with string content (line 2)
|
|
if summary != "Hello world" {
|
|
t.Errorf("scanSessionMeta summary = %q, want %q", summary, "Hello world")
|
|
}
|
|
}
|
|
|
|
func TestScanSessionMeta_AllArrayContent(t *testing.T) {
|
|
// When all user messages have array content, summary should remain empty
|
|
// and count should still be correct.
|
|
tmpDir := t.TempDir()
|
|
path := filepath.Join(tmpDir, "test.jsonl")
|
|
|
|
lines := []string{
|
|
`{"type": "user", "message": {"content": [{"type": "tool_result", "content": "data"}]}}`,
|
|
`{"type": "assistant", "message": {"content": [{"type": "text", "text": "reply"}]}}`,
|
|
}
|
|
|
|
data := ""
|
|
for _, line := range lines {
|
|
data += line + "\n"
|
|
}
|
|
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
|
t.Fatalf("write test jsonl: %v", err)
|
|
}
|
|
|
|
summary, count := scanSessionMeta(path)
|
|
|
|
if count != 2 {
|
|
t.Errorf("scanSessionMeta count = %d, want 2", count)
|
|
}
|
|
if summary != "" {
|
|
t.Errorf("scanSessionMeta summary = %q, want empty string when no string user content exists", summary)
|
|
}
|
|
}
|
|
|
|
func TestExtractStringContent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{"string", `"hello"`, "hello"},
|
|
{"empty", `""`, ""},
|
|
{"array", `[{"type": "text"}]`, ""},
|
|
{"object", `{"key": "val"}`, ""},
|
|
{"null", `null`, ""},
|
|
{"number", `42`, ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractStringContent([]byte(tt.raw))
|
|
if got != tt.want {
|
|
t.Errorf("extractStringContent(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|