Fix search extraction and Windows test stability

- Support /WebAPI tool-call extraction and stripping alongside JSON wrapper format.
- Make codex CLI mock tests cross-platform (Windows .cmd + Unix shell).
- Stabilize auth store tests by setting effective home env per OS and skipping POSIX mode check on Windows.
- Keep web-search provider-required config test explicit by disabling legacy fallbacks in the test fixture.

Test results:
- runTests targeted reruns: passed (4/4)
- runTests full suite: passed (650/650)
This commit is contained in:
PhotoPortfolio Developer 2026-02-22 20:45:44 +08:00
parent c42012aaca
commit f668edf35e
4 changed files with 169 additions and 26 deletions

View file

@ -3,10 +3,28 @@ package auth
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
"time" "time"
) )
func setTestHome(t *testing.T, home string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
t.Setenv("HOMEDRIVE", filepath.VolumeName(home))
rel := home[len(filepath.VolumeName(home)):]
if rel == "" {
rel = `\`
}
t.Setenv("HOMEPATH", rel)
return
}
t.Setenv("HOME", home)
}
func TestAuthCredentialIsExpired(t *testing.T) { func TestAuthCredentialIsExpired(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -52,9 +70,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) {
func TestStoreRoundtrip(t *testing.T) { func TestStoreRoundtrip(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
origHome := os.Getenv("HOME") setTestHome(t, tmpDir)
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{ cred := &AuthCredential{
AccessToken: "test-access-token", AccessToken: "test-access-token",
@ -88,10 +104,12 @@ func TestStoreRoundtrip(t *testing.T) {
} }
func TestStoreFilePermissions(t *testing.T) { func TestStoreFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission bits are not enforced on Windows")
}
tmpDir := t.TempDir() tmpDir := t.TempDir()
origHome := os.Getenv("HOME") setTestHome(t, tmpDir)
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{ cred := &AuthCredential{
AccessToken: "secret-token", AccessToken: "secret-token",
@ -115,9 +133,7 @@ func TestStoreFilePermissions(t *testing.T) {
func TestStoreMultiProvider(t *testing.T) { func TestStoreMultiProvider(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
origHome := os.Getenv("HOME") setTestHome(t, tmpDir)
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
@ -148,9 +164,7 @@ func TestStoreMultiProvider(t *testing.T) {
func TestDeleteCredential(t *testing.T) { func TestDeleteCredential(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
origHome := os.Getenv("HOME") setTestHome(t, tmpDir)
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
if err := SetCredential("openai", cred); err != nil { if err := SetCredential("openai", cred); err != nil {
@ -172,9 +186,7 @@ func TestDeleteCredential(t *testing.T) {
func TestLoadStoreEmpty(t *testing.T) { func TestLoadStoreEmpty(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
origHome := os.Getenv("HOME") setTestHome(t, tmpDir)
t.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", origHome)
store, err := LoadStore() store, err := LoadStore()
if err != nil { if err != nil {

View file

@ -401,7 +401,7 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
func TestLoadConfig_WebSearchProviderRequired(t *testing.T) { func TestLoadConfig_WebSearchProviderRequired(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"search":{"provider":""}}}}`), 0o600); err != nil { if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"search":{"provider":""},"brave":{"enabled":false},"perplexity":{"enabled":false},"duckduckgo":{"enabled":false}}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err) t.Fatalf("WriteFile() error: %v", err)
} }

View file

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@ -401,12 +402,23 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) {
func createMockCodexCLI(t *testing.T, events []string) string { func createMockCodexCLI(t *testing.T, events []string) string {
t.Helper() t.Helper()
tmpDir := t.TempDir() tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "codex") scriptName := "codex"
if runtime.GOOS == "windows" {
scriptName = "codex.cmd"
}
scriptPath := filepath.Join(tmpDir, scriptName)
var sb strings.Builder var sb strings.Builder
sb.WriteString("#!/bin/bash\n") if runtime.GOOS == "windows" {
for _, event := range events { sb.WriteString("@echo off\r\n")
sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) for _, event := range events {
sb.WriteString(fmt.Sprintf("echo %s\r\n", event))
}
} else {
sb.WriteString("#!/bin/bash\n")
for _, event := range events {
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
}
} }
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil { if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
@ -473,12 +485,25 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) {
func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
// Mock script that captures args to verify model flag is passed // Mock script that captures args to verify model flag is passed
tmpDir := t.TempDir() tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "codex") scriptName := "codex"
script := `#!/bin/bash if runtime.GOOS == "windows" {
scriptName = "codex.cmd"
}
scriptPath := filepath.Join(tmpDir, scriptName)
var script string
if runtime.GOOS == "windows" {
script = "@echo off\r\n" +
"echo %* > \"" + filepath.Join(tmpDir, "args.txt") + "\"\r\n" +
"echo {\"type\":\"item.completed\",\"item\":{\"id\":\"1\",\"type\":\"agent_message\",\"text\":\"ok\"}}\r\n" +
"echo {\"type\":\"turn.completed\"}\r\n"
} else {
script = `#!/bin/bash
# Write args to a file for verification # Write args to a file for verification
echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
echo '{"type":"turn.completed"}'` echo '{"type":"turn.completed"}'`
}
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err) t.Fatal(err)
@ -519,8 +544,16 @@ echo '{"type":"turn.completed"}'`
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
// Script that sleeps forever // Script that sleeps forever
tmpDir := t.TempDir() tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "codex") scriptName := "codex"
if runtime.GOOS == "windows" {
scriptName = "codex.cmd"
}
scriptPath := filepath.Join(tmpDir, scriptName)
script := "#!/bin/bash\nsleep 60" script := "#!/bin/bash\nsleep 60"
if runtime.GOOS == "windows" {
script = "@echo off\r\ntimeout /t 60 /nobreak >nul\r\n"
}
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err) t.Fatal(err)

View file

@ -9,6 +9,18 @@ import (
// Both ClaudeCliProvider and CodexCliProvider use this to extract // Both ClaudeCliProvider and CodexCliProvider use this to extract
// tool calls that the model outputs in its response text. // tool calls that the model outputs in its response text.
func extractToolCallsFromText(text string) []ToolCall { func extractToolCallsFromText(text string) []ToolCall {
if calls := extractJSONWrapperToolCalls(text); len(calls) > 0 {
return calls
}
if call, ok := extractWebAPIToolCall(text); ok {
return []ToolCall{call}
}
return nil
}
func extractJSONWrapperToolCalls(text string) []ToolCall {
start := strings.Index(text, `{"tool_calls"`) start := strings.Index(text, `{"tool_calls"`)
if start == -1 { if start == -1 {
return nil return nil
@ -56,17 +68,103 @@ func extractToolCallsFromText(text string) []ToolCall {
return result return result
} }
func extractWebAPIToolCall(text string) (ToolCall, bool) {
start := strings.Index(text, "/WebAPI")
if start == -1 {
return ToolCall{}, false
}
jsonStart := strings.Index(text[start:], "{")
if jsonStart == -1 {
return ToolCall{}, false
}
jsonStart += start
jsonEnd := findMatchingBrace(text, jsonStart)
if jsonEnd == jsonStart {
return ToolCall{}, false
}
jsonStr := text[jsonStart:jsonEnd]
var webAPICall struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal([]byte(jsonStr), &webAPICall); err != nil {
return ToolCall{}, false
}
if strings.TrimSpace(webAPICall.Name) == "" {
return ToolCall{}, false
}
argBytes, err := json.Marshal(webAPICall.Arguments)
if err != nil {
argBytes = []byte("{}")
}
return ToolCall{
Type: "function",
Name: webAPICall.Name,
Arguments: webAPICall.Arguments,
Function: &FunctionCall{
Name: webAPICall.Name,
Arguments: string(argBytes),
},
}, true
}
// stripToolCallsFromText removes tool call JSON from response text. // stripToolCallsFromText removes tool call JSON from response text.
func stripToolCallsFromText(text string) string { func stripToolCallsFromText(text string) string {
if stripped, ok := stripJSONWrapperToolCalls(text); ok {
return stripped
}
if stripped, ok := stripWebAPIToolCall(text); ok {
return stripped
}
return text
}
func stripJSONWrapperToolCalls(text string) (string, bool) {
start := strings.Index(text, `{"tool_calls"`) start := strings.Index(text, `{"tool_calls"`)
if start == -1 { if start == -1 {
return text return "", false
} }
end := findMatchingBrace(text, start) end := findMatchingBrace(text, start)
if end == start { if end == start {
return text return "", false
} }
return strings.TrimSpace(text[:start] + text[end:]) return strings.TrimSpace(text[:start] + text[end:]), true
}
func stripWebAPIToolCall(text string) (string, bool) {
start := strings.Index(text, "/WebAPI")
if start == -1 {
return "", false
}
endTag := strings.Index(text[start:], "</tool_call>")
if endTag == -1 {
jsonStart := strings.Index(text[start:], "{")
if jsonStart == -1 {
return "", false
}
jsonStart += start
jsonEnd := findMatchingBrace(text, jsonStart)
if jsonEnd == jsonStart {
return "", false
}
return strings.TrimSpace(text[:start] + text[jsonEnd:]), true
}
endTag += start + len("</tool_call>")
return strings.TrimSpace(text[:start] + text[endTag:]), true
} }