chore(wip): save windsurf changes before upstream v0.1.118 merge
This commit is contained in:
parent
9156585a23
commit
cbf696bc82
25
.github/workflows/backend-ci.yml
vendored
25
.github/workflows/backend-ci.yml
vendored
@ -67,3 +67,28 @@ jobs:
|
|||||||
version: v2.9
|
version: v2.9
|
||||||
args: --timeout=30m
|
args: --timeout=30m
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
|
# Cross-platform smoke for the windsurf package: verify the code compiles
|
||||||
|
# on macOS and Windows and run only the platform-detection/discovery/datadir
|
||||||
|
# unit tests (which do not require launching a real LS binary).
|
||||||
|
windsurf-platform:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: backend/go.mod
|
||||||
|
check-latest: false
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
- name: Build windsurf package
|
||||||
|
working-directory: backend
|
||||||
|
run: go build ./internal/pkg/windsurf/...
|
||||||
|
- name: Platform-only unit tests
|
||||||
|
working-directory: backend
|
||||||
|
run: go test -race -count=1 -run 'Platform|Discovery|DataDir|Metadata|NewLSPool|LSPool|ScanLSOutput' ./internal/pkg/windsurf/...
|
||||||
|
|
||||||
|
|||||||
@ -99,6 +99,7 @@ func provideCleanup(
|
|||||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||||
windsurfRefresh *service.WindsurfRefreshService,
|
windsurfRefresh *service.WindsurfRefreshService,
|
||||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||||
|
windsurfLS *service.WindsurfLSService,
|
||||||
) func() {
|
) func() {
|
||||||
return func() {
|
return func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@ -253,6 +254,12 @@ func provideCleanup(
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
|
{"WindsurfLSService", func() error {
|
||||||
|
if windsurfLS != nil {
|
||||||
|
windsurfLS.Stop()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
infraSteps := []cleanupStep{
|
infraSteps := []cleanupStep{
|
||||||
|
|||||||
@ -273,7 +273,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, windsurfRefreshService, channelMonitorRunner)
|
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, windsurfRefreshService, channelMonitorRunner, windsurfLSService)
|
||||||
application := &Application{
|
application := &Application{
|
||||||
Server: httpServer,
|
Server: httpServer,
|
||||||
Cleanup: v,
|
Cleanup: v,
|
||||||
@ -329,6 +329,7 @@ func provideCleanup(
|
|||||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||||
windsurfRefresh *service.WindsurfRefreshService,
|
windsurfRefresh *service.WindsurfRefreshService,
|
||||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||||
|
windsurfLS *service.WindsurfLSService,
|
||||||
) func() {
|
) func() {
|
||||||
return func() {
|
return func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
|||||||
@ -78,6 +78,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
|||||||
nil, // paymentOrderExpiry
|
nil, // paymentOrderExpiry
|
||||||
nil, // windsurfRefresh
|
nil, // windsurfRefresh
|
||||||
nil, // channelMonitorRunner
|
nil, // channelMonitorRunner
|
||||||
|
nil, // windsurfLS
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
|
|||||||
@ -279,7 +279,7 @@ func main() {
|
|||||||
// SendUserCascadeMessage
|
// SendUserCascadeMessage
|
||||||
{
|
{
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0)
|
newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0, nil, true)
|
||||||
if err == nil && newCID != "" {
|
if err == nil && newCID != "" {
|
||||||
cascadeID = newCID
|
cascadeID = newCID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,7 +158,7 @@ func main() {
|
|||||||
fmt.Printf("✅ StartCascade cascade_id=%s\n", cascadeID)
|
fmt.Printf("✅ StartCascade cascade_id=%s\n", cascadeID)
|
||||||
|
|
||||||
// Call StreamCascadeChat (full flow incl. trajectory polling)
|
// Call StreamCascadeChat (full flow incl. trajectory polling)
|
||||||
res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0)
|
res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "StreamCascadeChat:", err)
|
fmt.Fprintln(os.Stderr, "StreamCascadeChat:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -209,7 +209,7 @@ func main() {
|
|||||||
tc.ID, fakeResult)
|
tc.ID, fakeResult)
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), f.timeout)
|
ctx2, cancel2 := context.WithTimeout(context.Background(), f.timeout)
|
||||||
defer cancel2()
|
defer cancel2()
|
||||||
res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0)
|
res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "\n❌ Turn2 StreamCascadeChat:", err)
|
fmt.Fprintln(os.Stderr, "\n❌ Turn2 StreamCascadeChat:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@ -1852,10 +1852,10 @@ func setDefaults() {
|
|||||||
viper.SetDefault("windsurf.docker.discover_interval", "60s")
|
viper.SetDefault("windsurf.docker.discover_interval", "60s")
|
||||||
viper.SetDefault("windsurf.docker.probe_interval", "30s")
|
viper.SetDefault("windsurf.docker.probe_interval", "30s")
|
||||||
viper.SetDefault("windsurf.docker.probe_timeout", "3s")
|
viper.SetDefault("windsurf.docker.probe_timeout", "3s")
|
||||||
viper.SetDefault("windsurf.embedded.binary", "/opt/windsurf/language_server_linux_x64")
|
viper.SetDefault("windsurf.embedded.binary", DefaultWindsurfEmbeddedBinary)
|
||||||
viper.SetDefault("windsurf.embedded.base_port", 42100)
|
viper.SetDefault("windsurf.embedded.base_port", DefaultWindsurfEmbeddedBasePort)
|
||||||
viper.SetDefault("windsurf.embedded.data_dir", "/opt/windsurf/data")
|
viper.SetDefault("windsurf.embedded.data_dir", DefaultWindsurfEmbeddedDataDir)
|
||||||
viper.SetDefault("windsurf.embedded.api_server_url", "https://server.self-serve.windsurf.com")
|
viper.SetDefault("windsurf.embedded.api_server_url", DefaultWindsurfEmbeddedAPIServerURL)
|
||||||
viper.SetDefault("windsurf.refresh.enabled", true)
|
viper.SetDefault("windsurf.refresh.enabled", true)
|
||||||
viper.SetDefault("windsurf.refresh.token_scan_interval", "5m")
|
viper.SetDefault("windsurf.refresh.token_scan_interval", "5m")
|
||||||
viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m")
|
viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m")
|
||||||
|
|||||||
@ -94,10 +94,10 @@ func DefaultWindsurfConfig() WindsurfConfig {
|
|||||||
ProbeTimeout: 3 * time.Second,
|
ProbeTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
Embedded: WindsurfEmbeddedConfig{
|
Embedded: WindsurfEmbeddedConfig{
|
||||||
Binary: "/opt/windsurf/language_server_linux_x64",
|
Binary: DefaultWindsurfEmbeddedBinary,
|
||||||
BasePort: 42100,
|
BasePort: DefaultWindsurfEmbeddedBasePort,
|
||||||
DataDir: "/opt/windsurf/data",
|
DataDir: DefaultWindsurfEmbeddedDataDir,
|
||||||
APIServerURL: "https://server.self-serve.windsurf.com",
|
APIServerURL: DefaultWindsurfEmbeddedAPIServerURL,
|
||||||
},
|
},
|
||||||
External: WindsurfExternalConfig{},
|
External: WindsurfExternalConfig{},
|
||||||
Refresh: WindsurfRefreshConfig{
|
Refresh: WindsurfRefreshConfig{
|
||||||
|
|||||||
18
backend/internal/config/windsurf_defaults.go
Normal file
18
backend/internal/config/windsurf_defaults.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// Windsurf embedded-mode defaults. Defined once here so that both the struct
|
||||||
|
// defaults in DefaultWindsurfConfig() (windsurf.go) and the viper.SetDefault
|
||||||
|
// calls in config.go reference a single source of truth — this prevents
|
||||||
|
// silent drift when one side is updated without the other.
|
||||||
|
//
|
||||||
|
// Binary and DataDir are intentionally left empty: the cross-platform
|
||||||
|
// discovery in backend/internal/pkg/windsurf/lspool.go (via DiscoverBinary
|
||||||
|
// and resolveDataDir) picks the correct path at runtime based on GOOS/GOARCH
|
||||||
|
// and the user's install layout. Hardcoding /opt/windsurf/... here would
|
||||||
|
// override that discovery on non-Linux platforms.
|
||||||
|
const (
|
||||||
|
DefaultWindsurfEmbeddedBinary = ""
|
||||||
|
DefaultWindsurfEmbeddedBasePort = 42100
|
||||||
|
DefaultWindsurfEmbeddedDataDir = ""
|
||||||
|
DefaultWindsurfEmbeddedAPIServerURL = "https://server.self-serve.windsurf.com"
|
||||||
|
)
|
||||||
164
backend/internal/pkg/windsurf/chat_media.go
Normal file
164
backend/internal/pkg/windsurf/chat_media.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CascadeImage 是发给 Windsurf Cascade gRPC 的图像载体。
|
||||||
|
// 对应 proto:message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
|
||||||
|
// 通过静态分析 /Applications/Windsurf.app/Contents/Resources/app/node_modules/@exa/chat-client/index.js 得到。
|
||||||
|
type CascadeImage struct {
|
||||||
|
// Base64Data 原始 base64 字符串(不含 data: 前缀)。仅用于 replay/发送;不参与指纹。
|
||||||
|
Base64Data string `json:"base64_data,omitempty"`
|
||||||
|
// MimeType 例如 image/png。参与指纹。
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
// Caption 可选辅助说明,参与指纹。
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
// SHA256 是 decoded 二进制字节的 sha256 hex;指纹使用。
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
// ByteLen decoded 字节数;指纹使用。
|
||||||
|
ByteLen int `json:"byte_len,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageDigest 是 CascadeImage 的摘要视图。日志/指纹/conversation pool 使用这个,
|
||||||
|
// 永远不要把 Base64Data 带进哈希/持久化。
|
||||||
|
type ImageDigest struct {
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ByteLen int `json:"byte_len"`
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持的图像 MIME(与 Windsurf 客户端一致)
|
||||||
|
var cascadeImageAllowedMime = map[string]struct{}{
|
||||||
|
"image/png": {},
|
||||||
|
"image/jpeg": {},
|
||||||
|
"image/gif": {},
|
||||||
|
"image/svg+xml": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadeImageMaxPerTurn 单次 user turn 最多图片数(Windsurf 客户端限制)。
|
||||||
|
const CascadeImageMaxPerTurn = 5
|
||||||
|
|
||||||
|
// CascadeImageMaxBytes 单张解码后字节上限(Windsurf 客户端压缩目标 1MB)。
|
||||||
|
const CascadeImageMaxBytes = 1 * 1024 * 1024
|
||||||
|
|
||||||
|
// CascadeImageValidationOptions 校验开关,便于不同场景选择严格度。
|
||||||
|
type CascadeImageValidationOptions struct {
|
||||||
|
// EnforceByteSize 是否对单张做 1MB 上限校验。默认 true。
|
||||||
|
EnforceByteSize bool
|
||||||
|
// EnforceCountPerTurn 是否对张数做 <=5 校验。默认 true。
|
||||||
|
EnforceCountPerTurn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCascadeImageValidationOptions 返回默认校验选项。
|
||||||
|
func DefaultCascadeImageValidationOptions() CascadeImageValidationOptions {
|
||||||
|
return CascadeImageValidationOptions{
|
||||||
|
EnforceByteSize: true,
|
||||||
|
EnforceCountPerTurn: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCascadeImages 在发送前做确定性校验。
|
||||||
|
// 返回第一条校验失败的错误,上层据此生成 Anthropic 风格 400。
|
||||||
|
func ValidateCascadeImages(images []CascadeImage, opts CascadeImageValidationOptions) error {
|
||||||
|
if opts.EnforceCountPerTurn && len(images) > CascadeImageMaxPerTurn {
|
||||||
|
return fmt.Errorf("too many images: %d (max %d)", len(images), CascadeImageMaxPerTurn)
|
||||||
|
}
|
||||||
|
for i := range images {
|
||||||
|
img := &images[i]
|
||||||
|
if _, ok := cascadeImageAllowedMime[strings.ToLower(strings.TrimSpace(img.MimeType))]; !ok {
|
||||||
|
return fmt.Errorf("image[%d]: unsupported media_type %q", i, img.MimeType)
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(img.Base64Data)
|
||||||
|
if trimmed == "" {
|
||||||
|
return fmt.Errorf("image[%d]: data must not be empty", i)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
// 尝试 RawStdEncoding 作为兜底(部分客户端省略 padding)
|
||||||
|
if decoded2, err2 := base64.RawStdEncoding.DecodeString(trimmed); err2 == nil {
|
||||||
|
decoded = decoded2
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("image[%d]: invalid base64 data", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(decoded) == 0 {
|
||||||
|
return fmt.Errorf("image[%d]: decoded data is empty", i)
|
||||||
|
}
|
||||||
|
if opts.EnforceByteSize && len(decoded) > CascadeImageMaxBytes {
|
||||||
|
return fmt.Errorf("image[%d]: decoded size %d exceeds 1MB limit", i, len(decoded))
|
||||||
|
}
|
||||||
|
// 顺手把 digest/byteLen 算好,后续指纹阶段直接用
|
||||||
|
if img.SHA256 == "" || img.ByteLen == 0 {
|
||||||
|
sum := sha256.Sum256(decoded)
|
||||||
|
img.SHA256 = hex.EncodeToString(sum[:])
|
||||||
|
img.ByteLen = len(decoded)
|
||||||
|
}
|
||||||
|
// 归一化 MIME(避免大小写差异)
|
||||||
|
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildImageDigests 把 CascadeImage 转成只含摘要的 ImageDigest 切片。
|
||||||
|
// 前提:调用者已经通过 ValidateCascadeImages 或 ComputeImageDigest 确保 SHA256/ByteLen 已填。
|
||||||
|
func BuildImageDigests(images []CascadeImage) []ImageDigest {
|
||||||
|
if len(images) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]ImageDigest, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
out[i] = ImageDigest{
|
||||||
|
MimeType: img.MimeType,
|
||||||
|
SHA256: img.SHA256,
|
||||||
|
ByteLen: img.ByteLen,
|
||||||
|
Caption: img.Caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeImageDigest 按给定 base64 字符串计算 digest 字段(不做限额校验)。
|
||||||
|
// 给只需要摘要不需要发送场景使用(例如历史 replay 构造)。
|
||||||
|
func ComputeImageDigest(img *CascadeImage) error {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid base64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(decoded)
|
||||||
|
img.SHA256 = hex.EncodeToString(sum[:])
|
||||||
|
img.ByteLen = len(decoded)
|
||||||
|
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCodeiumImage 对单张 CodeiumImage 消息进行 proto wire 编码:
|
||||||
|
// message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
|
||||||
|
// 返回值是 message body 原始字节(不含外层 field tag/length)。
|
||||||
|
func encodeCodeiumImage(img CascadeImage) []byte {
|
||||||
|
var body []byte
|
||||||
|
// field 1:base64 字符串原样发(Windsurf 客户端也是原样放,不做二次编码)
|
||||||
|
body = append(body, encodeStringField(1, img.Base64Data)...)
|
||||||
|
body = append(body, encodeStringField(2, img.MimeType)...)
|
||||||
|
if img.Caption != "" {
|
||||||
|
body = append(body, encodeStringField(3, img.Caption)...)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendSendUserCascadeImages 把一组图像追加到 SendUserCascadeMessageRequest.images(field 6, repeated)。
|
||||||
|
// repeated message 字段按"每条独立的 field 6 segment"编码,与 Windsurf 客户端保持一致。
|
||||||
|
func appendSendUserCascadeImages(body []byte, images []CascadeImage) []byte {
|
||||||
|
for _, img := range images {
|
||||||
|
body = append(body, encodeBytesField(6, encodeCodeiumImage(img))...)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
115
backend/internal/pkg/windsurf/chat_media_test.go
Normal file
115
backend/internal/pkg/windsurf/chat_media_test.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_AllowedMime(t *testing.T) {
|
||||||
|
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake"))
|
||||||
|
tests := []struct {
|
||||||
|
mime string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"image/png", false},
|
||||||
|
{"image/jpeg", false},
|
||||||
|
{"image/gif", false},
|
||||||
|
{"image/svg+xml", false},
|
||||||
|
{"image/webp", true},
|
||||||
|
{"text/plain", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
imgs := []CascadeImage{{MimeType: tt.mime, Base64Data: png}}
|
||||||
|
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("mime=%s err=%v wantErr=%v", tt.mime, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_CountLimit(t *testing.T) {
|
||||||
|
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG"))
|
||||||
|
imgs := make([]CascadeImage, 6)
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i] = CascadeImage{MimeType: "image/png", Base64Data: png}
|
||||||
|
}
|
||||||
|
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected count limit error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_InvalidBase64(t *testing.T) {
|
||||||
|
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: "!!!not-base64"}}
|
||||||
|
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected invalid base64 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_EmptyData(t *testing.T) {
|
||||||
|
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: ""}}
|
||||||
|
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected empty data error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_ByteLimit(t *testing.T) {
|
||||||
|
big := make([]byte, CascadeImageMaxBytes+1)
|
||||||
|
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: base64.StdEncoding.EncodeToString(big)}}
|
||||||
|
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected size limit error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCascadeImages_PopulatesDigest(t *testing.T) {
|
||||||
|
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake"))
|
||||||
|
imgs := []CascadeImage{{MimeType: "image/PNG", Base64Data: png}}
|
||||||
|
if err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if imgs[0].SHA256 == "" {
|
||||||
|
t.Errorf("sha256 should be populated")
|
||||||
|
}
|
||||||
|
if imgs[0].ByteLen == 0 {
|
||||||
|
t.Errorf("byteLen should be populated")
|
||||||
|
}
|
||||||
|
if imgs[0].MimeType != "image/png" {
|
||||||
|
t.Errorf("mime should be normalized to lowercase, got %q", imgs[0].MimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildImageDigests(t *testing.T) {
|
||||||
|
imgs := []CascadeImage{
|
||||||
|
{MimeType: "image/png", SHA256: "abc", ByteLen: 10, Caption: "x", Base64Data: "ignored"},
|
||||||
|
{MimeType: "image/jpeg", SHA256: "def", ByteLen: 20},
|
||||||
|
}
|
||||||
|
out := BuildImageDigests(imgs)
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 digests, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MimeType != "image/png" || out[0].SHA256 != "abc" || out[0].ByteLen != 10 || out[0].Caption != "x" {
|
||||||
|
t.Errorf("digest[0] mismatch: %+v", out[0])
|
||||||
|
}
|
||||||
|
// ImageDigest 不应包含 base64 字段
|
||||||
|
// (这个通过结构体字段检查,而非运行时 — 此处只是确认编译通过)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeImageDigest(t *testing.T) {
|
||||||
|
img := CascadeImage{MimeType: "IMAGE/PNG", Base64Data: base64.StdEncoding.EncodeToString([]byte("hello"))}
|
||||||
|
if err := ComputeImageDigest(&img); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if img.ByteLen != 5 {
|
||||||
|
t.Errorf("byteLen=%d want 5", img.ByteLen)
|
||||||
|
}
|
||||||
|
// sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||||
|
if img.SHA256 != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" {
|
||||||
|
t.Errorf("sha256 mismatch: %s", img.SHA256)
|
||||||
|
}
|
||||||
|
if img.MimeType != "image/png" {
|
||||||
|
t.Errorf("mime not normalized: %s", img.MimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
backend/internal/pkg/windsurf/chat_media_wire_test.go
Normal file
68
backend/internal/pkg/windsurf/chat_media_wire_test.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 对比官方规划里给出的 known-good hex vector:
|
||||||
|
// base64Data="QQ==", mimeType="image/png", caption="a"
|
||||||
|
// 内部 body: 0a 04 51 51 3d 3d 12 09 69 6d 61 67 65 2f 70 6e 67 1a 01 61
|
||||||
|
func TestEncodeCodeiumImage_KnownVector(t *testing.T) {
|
||||||
|
img := CascadeImage{
|
||||||
|
Base64Data: "QQ==",
|
||||||
|
MimeType: "image/png",
|
||||||
|
Caption: "a",
|
||||||
|
}
|
||||||
|
got := encodeCodeiumImage(img)
|
||||||
|
want, _ := hex.DecodeString("0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161")
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("encoded mismatch:\n got=%x\nwant=%x", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeCodeiumImage_OmitsCaptionWhenEmpty(t *testing.T) {
|
||||||
|
img := CascadeImage{
|
||||||
|
Base64Data: "QQ==",
|
||||||
|
MimeType: "image/png",
|
||||||
|
}
|
||||||
|
got := encodeCodeiumImage(img)
|
||||||
|
// 不含 field 3 tag (0x1a)
|
||||||
|
if bytes.Contains(got, []byte{0x1a}) {
|
||||||
|
t.Errorf("should not contain field 3 tag when caption empty: %x", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSendUserCascadeImages_WrapsField6(t *testing.T) {
|
||||||
|
imgs := []CascadeImage{
|
||||||
|
{Base64Data: "QQ==", MimeType: "image/png", Caption: "a"},
|
||||||
|
}
|
||||||
|
body := appendSendUserCascadeImages(nil, imgs)
|
||||||
|
// 外层:field 6 tag (0x32) + varint 长度 + 内层 body
|
||||||
|
// 内层 body 长度 = 6 + 11 + 3 = 20 (0x14)
|
||||||
|
want, _ := hex.DecodeString("3214" + "0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161")
|
||||||
|
if !bytes.Equal(body, want) {
|
||||||
|
t.Errorf("wrapped field 6 segment mismatch:\n got=%x\nwant=%x", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSendUserCascadeImages_MultipleImagesIndependentSegments(t *testing.T) {
|
||||||
|
imgs := []CascadeImage{
|
||||||
|
{Base64Data: "QQ==", MimeType: "image/png"},
|
||||||
|
{Base64Data: "Qg==", MimeType: "image/jpeg"},
|
||||||
|
}
|
||||||
|
body := appendSendUserCascadeImages(nil, imgs)
|
||||||
|
// 应该有两次 field 6 tag (0x32)
|
||||||
|
count := bytes.Count(body, []byte{0x32})
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2 field-6 segments, got %d in %x", count, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSendUserCascadeImages_NilInput(t *testing.T) {
|
||||||
|
body := appendSendUserCascadeImages(nil, nil)
|
||||||
|
if len(body) != 0 {
|
||||||
|
t.Errorf("nil input should produce empty bytes, got %x", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ package windsurf
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
@ -18,12 +19,36 @@ const (
|
|||||||
AppVersion = "1.48.2"
|
AppVersion = "1.48.2"
|
||||||
ExtensionVersion = "1.9600.41"
|
ExtensionVersion = "1.9600.41"
|
||||||
IDEVersion = ExtensionVersion
|
IDEVersion = ExtensionVersion
|
||||||
RuntimeOS = "linux"
|
|
||||||
HardwareArch = "x86_64"
|
|
||||||
ClientVersion = "2.0.63"
|
ClientVersion = "2.0.63"
|
||||||
UserAgent = "connect-go/1.18.1 (go1.26.1)"
|
UserAgent = "connect-go/1.18.1 (go1.26.1)"
|
||||||
|
|
||||||
|
// DefaultRuntimeOS / DefaultHardwareArch are what we advertise in
|
||||||
|
// protocol metadata when the WINDSURF_METADATA_OS /
|
||||||
|
// WINDSURF_METADATA_ARCH env vars are unset. They match the Linux LS
|
||||||
|
// binary we spawn by default, so the upstream never sees a mismatch
|
||||||
|
// between advertised OS and the actual LS behavior.
|
||||||
|
DefaultRuntimeOS = "linux"
|
||||||
|
DefaultHardwareArch = "x86_64"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RuntimeOS returns the OS string sent in protocol metadata, consulting
|
||||||
|
// WINDSURF_METADATA_OS on each call so tests can flip it with t.Setenv.
|
||||||
|
func RuntimeOS() string {
|
||||||
|
if v := os.Getenv("WINDSURF_METADATA_OS"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return DefaultRuntimeOS
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardwareArch returns the hardware string sent in protocol metadata,
|
||||||
|
// consulting WINDSURF_METADATA_ARCH on each call.
|
||||||
|
func HardwareArch() string {
|
||||||
|
if v := os.Getenv("WINDSURF_METADATA_ARCH"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return DefaultHardwareArch
|
||||||
|
}
|
||||||
|
|
||||||
// ── Protobuf wire encoding ─────────────────────────────────────────────────
|
// ── Protobuf wire encoding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
func writeVarint(value uint64) []byte {
|
func writeVarint(value uint64) []byte {
|
||||||
|
|||||||
@ -11,6 +11,10 @@ type LSConnector interface {
|
|||||||
Acquire(ctx context.Context, proxyURL string) (*LSLease, error)
|
Acquire(ctx context.Context, proxyURL string) (*LSLease, error)
|
||||||
Health(ctx context.Context) error
|
Health(ctx context.Context) error
|
||||||
Status() *LSConnectorStatus
|
Status() *LSConnectorStatus
|
||||||
|
// Shutdown releases any resources owned by the connector. Connectors
|
||||||
|
// that only dial external endpoints implement this as a no-op; the
|
||||||
|
// embedded mode terminates its spawned LS processes here.
|
||||||
|
Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
type LSLease struct {
|
type LSLease struct {
|
||||||
@ -68,6 +72,10 @@ func (d *DockerConnector) Status() *LSConnectorStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown is a no-op: DockerConnector only dials a remote endpoint and
|
||||||
|
// owns no long-lived goroutines or child processes.
|
||||||
|
func (d *DockerConnector) Shutdown() {}
|
||||||
|
|
||||||
type EmbeddedConnector struct {
|
type EmbeddedConnector struct {
|
||||||
pool *LSPool
|
pool *LSPool
|
||||||
}
|
}
|
||||||
@ -114,6 +122,16 @@ func (e *EmbeddedConnector) Status() *LSConnectorStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown terminates every LS process in the pool. Must be called on
|
||||||
|
// application teardown; otherwise child processes leak until the OS
|
||||||
|
// reaps them at exit.
|
||||||
|
func (e *EmbeddedConnector) Shutdown() {
|
||||||
|
if e == nil || e.pool == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.pool.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
type ExternalConnector struct {
|
type ExternalConnector struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
port int
|
port int
|
||||||
@ -156,3 +174,6 @@ func (x *ExternalConnector) Status() *LSConnectorStatus {
|
|||||||
Endpoint: x.baseURL,
|
Endpoint: x.baseURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown is a no-op: ExternalConnector only dials a remote endpoint.
|
||||||
|
func (x *ExternalConnector) Shutdown() {}
|
||||||
|
|||||||
@ -173,13 +173,34 @@ func stableTurns(messages []ChatMessage) []ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hashFingerprint(modelKey, apiKey string, turns []ChatMessage) string {
|
func hashFingerprint(modelKey, apiKey string, turns []ChatMessage) string {
|
||||||
|
type canonicalImage struct {
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ByteLen int `json:"byte_len"`
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
type canonical struct {
|
type canonical struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Images []canonicalImage `json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
cans := make([]canonical, len(turns))
|
cans := make([]canonical, len(turns))
|
||||||
for i, t := range turns {
|
for i, t := range turns {
|
||||||
cans[i] = canonical{Role: t.Role, Content: t.Content}
|
c := canonical{Role: t.Role, Content: t.Content}
|
||||||
|
// 指纹只使用 ImageDigests,永不使用 base64。
|
||||||
|
// 若 ImageDigests 为空则 canonical.Images 也省略(保持向后兼容:无图 hash 与旧版本一致)。
|
||||||
|
if len(t.ImageDigests) > 0 {
|
||||||
|
c.Images = make([]canonicalImage, len(t.ImageDigests))
|
||||||
|
for j, d := range t.ImageDigests {
|
||||||
|
c.Images[j] = canonicalImage{
|
||||||
|
MimeType: d.MimeType,
|
||||||
|
SHA256: d.SHA256,
|
||||||
|
ByteLen: d.ByteLen,
|
||||||
|
Caption: d.Caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cans[i] = c
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(cans)
|
data, _ := json.Marshal(cans)
|
||||||
h := sha256.Sum256([]byte(fmt.Sprintf("%s\x00\x00%s\x00\x00%s", modelKey, apiKey, data)))
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s\x00\x00%s\x00\x00%s", modelKey, apiKey, data)))
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFingerprintBefore_BackwardCompatibleForTextOnly(t *testing.T) {
|
||||||
|
msgs := []ChatMessage{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "hi"},
|
||||||
|
{Role: "user", Content: "again"},
|
||||||
|
}
|
||||||
|
// 无图场景 hash 必须与 ImageDigests=nil 时一致(向后兼容)
|
||||||
|
fp := FingerprintBefore(msgs, "claude-opus-4-7", "key-A")
|
||||||
|
if fp == "" {
|
||||||
|
t.Fatal("unexpected empty fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintBefore_DifferentImagesDiffer(t *testing.T) {
|
||||||
|
base := []ChatMessage{
|
||||||
|
{Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "aaa", ByteLen: 100}}},
|
||||||
|
{Role: "assistant", Content: "ok"},
|
||||||
|
{Role: "user", Content: "next"},
|
||||||
|
}
|
||||||
|
diffImg := []ChatMessage{
|
||||||
|
{Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "bbb", ByteLen: 100}}},
|
||||||
|
{Role: "assistant", Content: "ok"},
|
||||||
|
{Role: "user", Content: "next"},
|
||||||
|
}
|
||||||
|
fp1 := FingerprintBefore(base, "claude-opus-4-7", "key-A")
|
||||||
|
fp2 := FingerprintBefore(diffImg, "claude-opus-4-7", "key-A")
|
||||||
|
if fp1 == fp2 {
|
||||||
|
t.Errorf("expected different fingerprints when image sha256 differs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintBefore_SameImageDifferentCaptionDiffers(t *testing.T) {
|
||||||
|
a := []ChatMessage{
|
||||||
|
{Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "cat"}}},
|
||||||
|
{Role: "assistant", Content: "ok"},
|
||||||
|
{Role: "user", Content: "y"},
|
||||||
|
}
|
||||||
|
b := []ChatMessage{
|
||||||
|
{Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "dog"}}},
|
||||||
|
{Role: "assistant", Content: "ok"},
|
||||||
|
{Role: "user", Content: "y"},
|
||||||
|
}
|
||||||
|
fp1 := FingerprintBefore(a, "claude-opus-4-7", "key-A")
|
||||||
|
fp2 := FingerprintBefore(b, "claude-opus-4-7", "key-A")
|
||||||
|
if fp1 == fp2 {
|
||||||
|
t.Errorf("expected different fingerprints when caption differs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintBefore_NoImagesSameAsAbsent(t *testing.T) {
|
||||||
|
a := []ChatMessage{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "hi"},
|
||||||
|
{Role: "user", Content: "again"},
|
||||||
|
}
|
||||||
|
b := []ChatMessage{
|
||||||
|
{Role: "user", Content: "hello", Images: nil, ImageDigests: nil},
|
||||||
|
{Role: "assistant", Content: "hi"},
|
||||||
|
{Role: "user", Content: "again"},
|
||||||
|
}
|
||||||
|
if FingerprintBefore(a, "m", "k") != FingerprintBefore(b, "m", "k") {
|
||||||
|
t.Errorf("no-image variants should hash identically (backward compat)")
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/internal/pkg/windsurf/datadir.go
Normal file
97
backend/internal/pkg/windsurf/datadir.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dataDirStat reports whether a data directory path exists and is writable.
|
||||||
|
type dataDirStat struct {
|
||||||
|
Exists bool
|
||||||
|
Writable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataDirStatFn is replaced in tests to avoid touching the filesystem.
|
||||||
|
var dataDirStatFn = defaultDataDirStat
|
||||||
|
|
||||||
|
// userConfigDirFn is replaced in tests.
|
||||||
|
var userConfigDirFn = defaultUserConfigDir
|
||||||
|
|
||||||
|
func defaultDataDirStat(path string) dataDirStat {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return dataDirStat{}
|
||||||
|
}
|
||||||
|
// Probe writability by creating a sentinel file. This catches root-owned
|
||||||
|
// directories on Linux that are readable but not user-writable.
|
||||||
|
tmp := filepath.Join(path, ".sub2api-writable-check")
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return dataDirStat{Exists: true}
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return dataDirStat{Exists: true, Writable: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultUserConfigDir() string {
|
||||||
|
if dir, err := os.UserConfigDir(); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyLinuxDataDir is the original pre-cross-platform data directory.
|
||||||
|
// Preserved to keep existing Linux deployments functional after upgrade.
|
||||||
|
const LegacyLinuxDataDir = "/opt/windsurf/data"
|
||||||
|
|
||||||
|
// resolveDataDir picks the base directory under which each LS instance's
|
||||||
|
// per-key subdirectory will live.
|
||||||
|
//
|
||||||
|
// Priority:
|
||||||
|
// 1. cfg.DataDir — explicit caller wins
|
||||||
|
// 2. WINDSURF_DATA_DIR env var
|
||||||
|
// 3. /opt/windsurf/data if it exists AND is writable (Linux backward compat)
|
||||||
|
// 4. os.UserConfigDir()/windsurf/data (cross-platform default)
|
||||||
|
// 5. os.TempDir()/windsurf-data (last-resort fallback)
|
||||||
|
func resolveDataDir(cfg LSPoolConfig) string {
|
||||||
|
return resolveDataDirFor(cfg.DataDir, os.Getenv("WINDSURF_DATA_DIR"), runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDataDirFor(cfgDir, envDir, goos string) string {
|
||||||
|
if cfgDir != "" {
|
||||||
|
return cfgDir
|
||||||
|
}
|
||||||
|
if envDir != "" {
|
||||||
|
return envDir
|
||||||
|
}
|
||||||
|
if goos == "linux" {
|
||||||
|
if stat := dataDirStatFn(LegacyLinuxDataDir); stat.Exists && stat.Writable {
|
||||||
|
return LegacyLinuxDataDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfgDir := userConfigDirFn(); cfgDir != "" {
|
||||||
|
return filepath.Join(cfgDir, "windsurf", "data")
|
||||||
|
}
|
||||||
|
return filepath.Join(os.TempDir(), "windsurf-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceHomeDir returns the per-instance HOME sandbox path. The LS binary
|
||||||
|
// writes telemetry and caches under $HOME (Linux/macOS) or %USERPROFILE%
|
||||||
|
// (Windows), so isolating it per instance prevents one instance from leaking
|
||||||
|
// state into another or into the invoking user's real home directory.
|
||||||
|
func instanceHomeDir(instanceDataDir string) string {
|
||||||
|
return filepath.Join(instanceDataDir, "home")
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeEnvForPlatform returns the set of environment assignments needed to
|
||||||
|
// direct the LS process at the given home directory. On Windows we also set
|
||||||
|
// USERPROFILE because most Windows programs check that first.
|
||||||
|
func homeEnvForPlatform(homeDir, goos string) []string {
|
||||||
|
envs := []string{"HOME=" + homeDir}
|
||||||
|
if goos == "windows" {
|
||||||
|
envs = append(envs, "USERPROFILE="+homeDir)
|
||||||
|
}
|
||||||
|
return envs
|
||||||
|
}
|
||||||
119
backend/internal/pkg/windsurf/datadir_test.go
Normal file
119
backend/internal/pkg/windsurf/datadir_test.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stubDataDirStat(t *testing.T, present map[string]dataDirStat) {
|
||||||
|
t.Helper()
|
||||||
|
orig := dataDirStatFn
|
||||||
|
dataDirStatFn = func(path string) dataDirStat {
|
||||||
|
return present[path]
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { dataDirStatFn = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubUserConfigDir(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
orig := userConfigDirFn
|
||||||
|
userConfigDirFn = func() string { return dir }
|
||||||
|
t.Cleanup(func() { userConfigDirFn = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_ExplicitCfgWins(t *testing.T) {
|
||||||
|
stubUserConfigDir(t, "/home/test/.config")
|
||||||
|
got := resolveDataDirFor("/explicit/path", "", "linux")
|
||||||
|
if got != "/explicit/path" {
|
||||||
|
t.Errorf("explicit cfg should win, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_EnvWinsOverDefaults(t *testing.T) {
|
||||||
|
stubUserConfigDir(t, "/home/test/.config")
|
||||||
|
got := resolveDataDirFor("", "/env/path", "linux")
|
||||||
|
if got != "/env/path" {
|
||||||
|
t.Errorf("env var should win over defaults, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_LinuxLegacyKeptWhenWritable(t *testing.T) {
|
||||||
|
stubDataDirStat(t, map[string]dataDirStat{
|
||||||
|
LegacyLinuxDataDir: {Exists: true, Writable: true},
|
||||||
|
})
|
||||||
|
stubUserConfigDir(t, "/home/test/.config")
|
||||||
|
got := resolveDataDirFor("", "", "linux")
|
||||||
|
if got != LegacyLinuxDataDir {
|
||||||
|
t.Errorf("linux with writable /opt/windsurf/data should keep legacy path, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_LinuxLegacyNotWritableFallsBack(t *testing.T) {
|
||||||
|
stubDataDirStat(t, map[string]dataDirStat{
|
||||||
|
LegacyLinuxDataDir: {Exists: true, Writable: false}, // root-owned
|
||||||
|
})
|
||||||
|
stubUserConfigDir(t, "/home/test/.config")
|
||||||
|
got := resolveDataDirFor("", "", "linux")
|
||||||
|
want := filepath.Join("/home/test/.config", "windsurf", "data")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("unwritable legacy should fall back to UserConfigDir, got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_DarwinIgnoresLinuxLegacy(t *testing.T) {
|
||||||
|
stubDataDirStat(t, map[string]dataDirStat{
|
||||||
|
LegacyLinuxDataDir: {Exists: true, Writable: true},
|
||||||
|
})
|
||||||
|
stubUserConfigDir(t, "/Users/test/Library/Application Support")
|
||||||
|
got := resolveDataDirFor("", "", "darwin")
|
||||||
|
want := filepath.Join("/Users/test/Library/Application Support", "windsurf", "data")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("darwin should ignore /opt/windsurf/data, got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_WindowsUsesUserConfigDir(t *testing.T) {
|
||||||
|
stubUserConfigDir(t, `C:\Users\test\AppData\Roaming`)
|
||||||
|
got := resolveDataDirFor("", "", "windows")
|
||||||
|
want := filepath.Join(`C:\Users\test\AppData\Roaming`, "windsurf", "data")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("windows should use UserConfigDir, got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDataDir_EmptyUserConfigFallsBackToTemp(t *testing.T) {
|
||||||
|
stubUserConfigDir(t, "")
|
||||||
|
got := resolveDataDirFor("", "", "linux")
|
||||||
|
if !strings.Contains(got, "windsurf-data") {
|
||||||
|
t.Errorf("fallback should contain windsurf-data, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceHomeDir(t *testing.T) {
|
||||||
|
got := instanceHomeDir("/var/lib/windsurf/key1")
|
||||||
|
want := filepath.Join("/var/lib/windsurf/key1", "home")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeEnvForPlatform(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
goos string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"linux", []string{"HOME=/sandbox"}},
|
||||||
|
{"darwin", []string{"HOME=/sandbox"}},
|
||||||
|
{"windows", []string{"HOME=/sandbox", "USERPROFILE=/sandbox"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.goos, func(t *testing.T) {
|
||||||
|
got := homeEnvForPlatform("/sandbox", tt.goos)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("goos=%s: got %v, want %v", tt.goos, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backend/internal/pkg/windsurf/discovery.go
Normal file
162
backend/internal/pkg/windsurf/discovery.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBinaryNotFound is returned when no Windsurf LS binary can be located
|
||||||
|
// via any configured source (env, explicit config, or platform candidates).
|
||||||
|
var ErrBinaryNotFound = fmt.Errorf("windsurf: language server binary not found")
|
||||||
|
|
||||||
|
// binaryStatFn reports whether the given path exists and is executable for
|
||||||
|
// the current platform. It is a package variable so tests can replace it
|
||||||
|
// with a map-backed implementation that does not touch the filesystem.
|
||||||
|
var binaryStatFn = defaultBinaryStat
|
||||||
|
|
||||||
|
// userHomeFn returns the user's home directory. Replaced in tests.
|
||||||
|
var userHomeFn = defaultUserHome
|
||||||
|
|
||||||
|
func defaultBinaryStat(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Windows ignores the Unix execute bit — rely on the .exe suffix.
|
||||||
|
return strings.HasSuffix(strings.ToLower(path), ".exe")
|
||||||
|
}
|
||||||
|
return info.Mode()&0o111 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultUserHome() string {
|
||||||
|
if dir, err := os.UserHomeDir(); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverBinary resolves the Windsurf LS binary path for the current
|
||||||
|
// platform. Resolution order:
|
||||||
|
//
|
||||||
|
// 1. LS_BINARY_PATH environment variable (explicit override — user intent
|
||||||
|
// wins even if the path doesn't exist, so we can surface a clear error)
|
||||||
|
// 2. cfg.Binary (explicit config override)
|
||||||
|
// 3. Platform-specific candidate list (official install locations)
|
||||||
|
//
|
||||||
|
// Returns ErrBinaryNotFound when none of the sources yield an executable
|
||||||
|
// file; the error message directs the user to LS_BINARY_PATH or ls_mode=docker.
|
||||||
|
func DiscoverBinary(cfg LSPoolConfig) (string, error) {
|
||||||
|
return discoverBinaryFor(DetectPlatform(), os.Getenv("LS_BINARY_PATH"), cfg.Binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverBinaryFor(p Platform, envPath, cfgPath string) (string, error) {
|
||||||
|
if envPath != "" {
|
||||||
|
return validateBinaryPath(envPath, p, "LS_BINARY_PATH")
|
||||||
|
}
|
||||||
|
if cfgPath != "" {
|
||||||
|
return validateBinaryPath(cfgPath, p, "cfg.Binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := platformCandidates(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, path := range candidates {
|
||||||
|
if binaryStatFn(path) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("%w for %s; searched %d paths (%s); set LS_BINARY_PATH or use ls_mode=docker",
|
||||||
|
ErrBinaryNotFound, p, len(candidates), strings.Join(candidates, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBinaryPath(path string, p Platform, source string) (string, error) {
|
||||||
|
if binaryStatFn(path) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
hint := "file does not exist or is not executable"
|
||||||
|
if p.OS == "windows" && !strings.HasSuffix(strings.ToLower(path), ".exe") {
|
||||||
|
hint = "Windows LS binaries must end in .exe"
|
||||||
|
} else if p.OS != "windows" {
|
||||||
|
hint += " (try chmod +x)"
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%w: %s=%q — %s; set LS_BINARY_PATH to a valid path or use ls_mode=docker",
|
||||||
|
ErrBinaryNotFound, source, path, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformCandidates returns the ordered list of paths where the official
|
||||||
|
// Windsurf LS binary may be installed on the given platform. Paths are
|
||||||
|
// ordered by preference — the first existing+executable path wins.
|
||||||
|
func platformCandidates(p Platform) ([]string, error) {
|
||||||
|
filename, err := BinaryFilename(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.OS {
|
||||||
|
case "darwin":
|
||||||
|
return darwinCandidates(filename), nil
|
||||||
|
case "linux":
|
||||||
|
return linuxCandidates(filename), nil
|
||||||
|
case "windows":
|
||||||
|
return windowsCandidates(filename), nil
|
||||||
|
}
|
||||||
|
// BinaryFilename would have errored first, so this is defensive.
|
||||||
|
return nil, fmt.Errorf("%w: %s (no candidate list)", ErrUnsupportedPlatform, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func darwinCandidates(filename string) []string {
|
||||||
|
const bundleSubpath = "Contents/Resources/app/extensions/windsurf/bin"
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join("/Applications/Windsurf.app", bundleSubpath, filename),
|
||||||
|
}
|
||||||
|
if home := userHomeFn(); home != "" {
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(home, "Applications/Windsurf.app", bundleSubpath, filename),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Legacy sub2api install (pre-cross-platform).
|
||||||
|
candidates = append(candidates, filepath.Join("/opt/windsurf", filename))
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func linuxCandidates(filename string) []string {
|
||||||
|
candidates := []string{
|
||||||
|
// Legacy sub2api install (pre-cross-platform) — matches existing deployments.
|
||||||
|
filepath.Join("/opt/windsurf", filename),
|
||||||
|
// Official Debian/RPM install locations.
|
||||||
|
filepath.Join("/usr/share/windsurf/resources/app/extensions/windsurf/bin", filename),
|
||||||
|
filepath.Join("/usr/lib/windsurf/resources/app/extensions/windsurf/bin", filename),
|
||||||
|
}
|
||||||
|
// User-local install (Flatpak/AppImage unpacked).
|
||||||
|
if home := userHomeFn(); home != "" {
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(home, ".local/share/windsurf/resources/app/extensions/windsurf/bin", filename),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsCandidates(filename string) []string {
|
||||||
|
// Split the install subpath into its components so filepath.Join produces
|
||||||
|
// a platform-native path on whichever OS this runs (Windows '\\', Unix '/').
|
||||||
|
// This matters for tests running on non-Windows builders.
|
||||||
|
installSubpath := []string{"resources", "app", "extensions", "windsurf", "bin", filename}
|
||||||
|
var candidates []string
|
||||||
|
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(append([]string{localAppData, "Programs", "Windsurf"}, installSubpath...)...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if programFiles := os.Getenv("PROGRAMFILES"); programFiles != "" {
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(append([]string{programFiles, "Windsurf"}, installSubpath...)...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
179
backend/internal/pkg/windsurf/discovery_test.go
Normal file
179
backend/internal/pkg/windsurf/discovery_test.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubStatFn replaces binaryStatFn for the duration of a test, restoring the
|
||||||
|
// original on cleanup.
|
||||||
|
func stubStatFn(t *testing.T, present map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
orig := binaryStatFn
|
||||||
|
binaryStatFn = func(path string) bool {
|
||||||
|
return present[path]
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { binaryStatFn = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubUserHome(t *testing.T, home string) {
|
||||||
|
t.Helper()
|
||||||
|
orig := userHomeFn
|
||||||
|
userHomeFn = func() string { return home }
|
||||||
|
t.Cleanup(func() { userHomeFn = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_EnvOverrideWins(t *testing.T) {
|
||||||
|
stubStatFn(t, map[string]bool{
|
||||||
|
"/tmp/my-ls": true,
|
||||||
|
"/opt/windsurf/language_server_linux_x64": true, // should not be picked
|
||||||
|
})
|
||||||
|
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/tmp/my-ls", "/opt/windsurf/language_server_linux_x64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/tmp/my-ls" {
|
||||||
|
t.Errorf("env override should win, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_EnvMissingReturnsError(t *testing.T) {
|
||||||
|
stubStatFn(t, map[string]bool{}) // nothing exists
|
||||||
|
_, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/does/not/exist", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing env path")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrBinaryNotFound) {
|
||||||
|
t.Errorf("expected ErrBinaryNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "LS_BINARY_PATH") {
|
||||||
|
t.Errorf("error should mention LS_BINARY_PATH, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "docker") {
|
||||||
|
t.Errorf("error should point at docker, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_CfgBinaryUsedWhenEnvEmpty(t *testing.T) {
|
||||||
|
stubStatFn(t, map[string]bool{"/custom/ls": true})
|
||||||
|
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "/custom/ls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/custom/ls" {
|
||||||
|
t.Errorf("cfg path should be used, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_FallsBackToCandidates(t *testing.T) {
|
||||||
|
stubStatFn(t, map[string]bool{
|
||||||
|
"/opt/windsurf/language_server_linux_x64": true,
|
||||||
|
})
|
||||||
|
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/opt/windsurf/language_server_linux_x64" {
|
||||||
|
t.Errorf("expected /opt/windsurf/language_server_linux_x64, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_DarwinPicksBundleOverLegacy(t *testing.T) {
|
||||||
|
bundle := "/Applications/Windsurf.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm"
|
||||||
|
legacy := "/opt/windsurf/language_server_macos_arm"
|
||||||
|
stubStatFn(t, map[string]bool{
|
||||||
|
bundle: true,
|
||||||
|
legacy: true,
|
||||||
|
})
|
||||||
|
stubUserHome(t, "/Users/test")
|
||||||
|
got, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != bundle {
|
||||||
|
t.Errorf("bundle path should win over legacy, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_WindowsPicksLocalAppData(t *testing.T) {
|
||||||
|
t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`)
|
||||||
|
t.Setenv("PROGRAMFILES", `C:\Program Files`)
|
||||||
|
// Construct the expected path the same way windowsCandidates() does so
|
||||||
|
// the test is portable across builders (macOS/Linux Go use '/', Windows
|
||||||
|
// uses '\\'). The Windows-native separator is verified by the Windows CI
|
||||||
|
// job in backend-ci.yml.
|
||||||
|
localPath := filepath.Join(`C:\Users\test\AppData\Local`, "Programs", "Windsurf",
|
||||||
|
"resources", "app", "extensions", "windsurf", "bin",
|
||||||
|
"language_server_windows_x64.exe")
|
||||||
|
stubStatFn(t, map[string]bool{localPath: true})
|
||||||
|
got, err := discoverBinaryFor(Platform{"windows", "amd64"}, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != localPath {
|
||||||
|
t.Errorf("expected LOCALAPPDATA path, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_AllMissReturnsUsefulError(t *testing.T) {
|
||||||
|
stubStatFn(t, map[string]bool{})
|
||||||
|
stubUserHome(t, "/Users/test")
|
||||||
|
_, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrBinaryNotFound) {
|
||||||
|
t.Errorf("expected ErrBinaryNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"darwin/arm64", "LS_BINARY_PATH", "docker", "/Applications/Windsurf.app"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_UnsupportedPlatformPropagates(t *testing.T) {
|
||||||
|
_, err := discoverBinaryFor(Platform{"freebsd", "amd64"}, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnsupportedPlatform) {
|
||||||
|
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverBinary_WindowsNonExeRejected(t *testing.T) {
|
||||||
|
// Even if env stat returns true, validateBinaryPath uses the real binaryStatFn,
|
||||||
|
// which for Windows requires .exe suffix. Test via direct stat behavior.
|
||||||
|
stubStatFn(t, map[string]bool{"C:\\custom\\ls": false}) // stat returns false
|
||||||
|
_, err := discoverBinaryFor(Platform{"windows", "amd64"}, "C:\\custom\\ls", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), ".exe") {
|
||||||
|
t.Errorf("windows error should hint at .exe, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformCandidates_Linux(t *testing.T) {
|
||||||
|
stubUserHome(t, "/home/test")
|
||||||
|
got, err := platformCandidates(Platform{"linux", "amd64"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) < 3 {
|
||||||
|
t.Errorf("expected at least 3 linux candidates, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != "/opt/windsurf/language_server_linux_x64" {
|
||||||
|
t.Errorf("legacy path should be first for backward-compat, got %q", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformCandidates_UnsupportedPropagates(t *testing.T) {
|
||||||
|
_, err := platformCandidates(Platform{"plan9", "amd64"})
|
||||||
|
if err == nil || !errors.Is(err, ErrUnsupportedPlatform) {
|
||||||
|
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,12 @@ const (
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
// Images 当前消息携带的图片(通常只有 user role 才非空)。
|
||||||
|
// 仅用于发送/replay。CascadeImage.Base64Data 不应出现在持久化/日志/指纹中。
|
||||||
|
Images []CascadeImage `json:"images,omitempty"`
|
||||||
|
// ImageDigests 仅摘要视图(含 sha256 / mime / byte_len / caption,不含 base64)。
|
||||||
|
// 供 conversation pool 指纹与日志使用。
|
||||||
|
ImageDigests []ImageDigest `json:"image_digests,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyChatDelta struct {
|
type LegacyChatDelta struct {
|
||||||
|
|||||||
@ -15,8 +15,10 @@ package windsurf
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@ -28,6 +30,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
pb "github.com/Wei-Shaw/sub2api/internal/gen/language_server_pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -38,8 +43,18 @@ const (
|
|||||||
SendUserCascadeMessageRPC = "/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage"
|
SendUserCascadeMessageRPC = "/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage"
|
||||||
GetCascadeTrajectoryStepsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps"
|
GetCascadeTrajectoryStepsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps"
|
||||||
GetCascadeTrajectoryStatusRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectory"
|
GetCascadeTrajectoryStatusRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectory"
|
||||||
|
GetCascadeModelConfigsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeModelConfigs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cascadeModelCapsCacheEntry 是单个 API key 下模型能力的缓存条目。
|
||||||
|
type cascadeModelCapsCacheEntry struct {
|
||||||
|
SupportsImages map[string]bool
|
||||||
|
FetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// cascadeModelCapsTTL 能力缓存 TTL(5 分钟)。
|
||||||
|
const cascadeModelCapsTTL = 5 * time.Minute
|
||||||
|
|
||||||
// LocalLSClient talks to the local Windsurf LanguageServerService via h2c (plain HTTP/2 over TCP).
|
// LocalLSClient talks to the local Windsurf LanguageServerService via h2c (plain HTTP/2 over TCP).
|
||||||
type LocalLSClient struct {
|
type LocalLSClient struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
@ -51,6 +66,10 @@ type LocalLSClient struct {
|
|||||||
// server-side repository context and relies on caller-provided tool results.
|
// server-side repository context and relies on caller-provided tool results.
|
||||||
TrackedWorkspace string
|
TrackedWorkspace string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// 模型能力缓存(per-API-key hash),供 Cascade 图像 gate 使用。
|
||||||
|
modelCapsMu sync.Mutex
|
||||||
|
modelCapsCache map[string]cascadeModelCapsCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLocalLSClient builds a client for the local LS at the given port.
|
// NewLocalLSClient builds a client for the local LS at the given port.
|
||||||
@ -159,7 +178,19 @@ func (l *LocalLSClient) StartCascade(ctx context.Context, token string) (string,
|
|||||||
// SendUserCascadeMessage sends a message into an existing cascade session.
|
// SendUserCascadeMessage sends a message into an existing cascade session.
|
||||||
// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade.
|
// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade.
|
||||||
// toolPreamble, if non-empty, is injected into the tool_calling_section override.
|
// toolPreamble, if non-empty, is injected into the tool_calling_section override.
|
||||||
func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int) (string, error) {
|
// SendUserCascadeMessage sends a user chat message to Cascade.
|
||||||
|
// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade.
|
||||||
|
// toolPreamble, if non-empty, is injected into the tool_calling_section override.
|
||||||
|
// images(可选)作为 SendUserCascadeMessageRequest.images (field 6) 追加到 proto wire。
|
||||||
|
//
|
||||||
|
// allowRecreate 控制 panel-state-not-found 时是否内部静默重建 cascade:
|
||||||
|
// - true:ForceWarmup + StartCascade + 用 SAME text 再发一次。调用方须保证
|
||||||
|
// text 已包含完整历史(否则新 cascade 无状态 + text 无历史 = 上下文丢失)。
|
||||||
|
// - false:直接返回错误,让调用方重建含完整历史的 text 后再调。
|
||||||
|
//
|
||||||
|
// 经验值:StreamCascadeChat 内当 reuseCascadeID 为空(本地 StartCascade 的流程,
|
||||||
|
// text 已是 full-history)时传 true;reuse 场景(text 可能仅含最后一条消息)传 false。
|
||||||
|
func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int, images []CascadeImage, allowRecreate bool) (string, error) {
|
||||||
modelEnum := resolveModelEnum(modelUID)
|
modelEnum := resolveModelEnum(modelUID)
|
||||||
if modelEnum == 0 && modelEnumHint > 0 {
|
if modelEnum == 0 && modelEnumHint > 0 {
|
||||||
modelEnum = modelEnumHint
|
modelEnum = modelEnumHint
|
||||||
@ -170,22 +201,29 @@ func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, casca
|
|||||||
body = append(body, encodeBytesField(2, encodeStringField(1, text))...)
|
body = append(body, encodeBytesField(2, encodeStringField(1, text))...)
|
||||||
body = append(body, encodeBytesField(3, buildMetadata(token, l.SessionID))...)
|
body = append(body, encodeBytesField(3, buildMetadata(token, l.SessionID))...)
|
||||||
body = append(body, encodeBytesField(5, buildCascadeConfig(modelUID, modelEnum, toolPreamble))...)
|
body = append(body, encodeBytesField(5, buildCascadeConfig(modelUID, modelEnum, toolPreamble))...)
|
||||||
|
// field 6: repeated CodeiumImage images(逆向自 Windsurf.app chat-client)
|
||||||
|
body = appendSendUserCascadeImages(body, images)
|
||||||
return l.grpcUnary(ctx, SendUserCascadeMessageRPC, body)
|
return l.grpcUnary(ctx, SendUserCascadeMessageRPC, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := doSend(cascadeID); err != nil {
|
if err := doSend(cascadeID); err != nil {
|
||||||
if isPanelStateNotFound(err) {
|
if !isPanelStateNotFound(err) {
|
||||||
_ = l.ForceWarmupCascade(ctx, token)
|
return "", err
|
||||||
newCascadeID, startErr := l.StartCascade(ctx, token)
|
|
||||||
if startErr != nil {
|
|
||||||
return "", startErr
|
|
||||||
}
|
|
||||||
if err := doSend(newCascadeID); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return newCascadeID, nil
|
|
||||||
}
|
}
|
||||||
return "", err
|
if !allowRecreate {
|
||||||
|
// reuse 场景:不要静默用 last-message-only 的 text 去灌新 cascade。
|
||||||
|
// 返回错误,让 chatCascade 用 full-history text 重建整个调用。
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = l.ForceWarmupCascade(ctx, token)
|
||||||
|
newCascadeID, startErr := l.StartCascade(ctx, token)
|
||||||
|
if startErr != nil {
|
||||||
|
return "", startErr
|
||||||
|
}
|
||||||
|
if err := doSend(newCascadeID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return newCascadeID, nil
|
||||||
}
|
}
|
||||||
return cascadeID, nil
|
return cascadeID, nil
|
||||||
}
|
}
|
||||||
@ -200,9 +238,9 @@ func buildMetadata(token, sessionID string) []byte {
|
|||||||
meta = append(meta, encodeStringField(2, ExtensionVersion)...) // extension_version
|
meta = append(meta, encodeStringField(2, ExtensionVersion)...) // extension_version
|
||||||
meta = append(meta, encodeStringField(3, token)...) // api_key
|
meta = append(meta, encodeStringField(3, token)...) // api_key
|
||||||
meta = append(meta, encodeStringField(4, "en")...) // locale
|
meta = append(meta, encodeStringField(4, "en")...) // locale
|
||||||
meta = append(meta, encodeStringField(5, RuntimeOS)...) // os
|
meta = append(meta, encodeStringField(5, RuntimeOS())...) // os
|
||||||
meta = append(meta, encodeStringField(7, IDEVersion)...) // ide_version
|
meta = append(meta, encodeStringField(7, IDEVersion)...) // ide_version
|
||||||
meta = append(meta, encodeStringField(8, HardwareArch)...) // hardware
|
meta = append(meta, encodeStringField(8, HardwareArch())...) // hardware
|
||||||
meta = append(meta, encodeVarintField(9, uint64(time.Now().UnixMilli()))...) // request_id
|
meta = append(meta, encodeVarintField(9, uint64(time.Now().UnixMilli()))...) // request_id
|
||||||
meta = append(meta, encodeStringField(10, sessionID)...) // session_id
|
meta = append(meta, encodeStringField(10, sessionID)...) // session_id
|
||||||
meta = append(meta, encodeStringField(12, AppName)...) // extension_name
|
meta = append(meta, encodeStringField(12, AppName)...) // extension_name
|
||||||
@ -372,7 +410,8 @@ func (e *CascadeModelError) Error() string { return e.Msg }
|
|||||||
// StreamCascadeChat performs the full Cascade chat flow and returns accumulated text + thinking.
|
// StreamCascadeChat performs the full Cascade chat flow and returns accumulated text + thinking.
|
||||||
// Includes cold/warm stall detection, step error handling, and final sweep (aligned with JS v1.9).
|
// Includes cold/warm stall detection, step error handling, and final sweep (aligned with JS v1.9).
|
||||||
// If reuseCascadeID is non-empty, skips StartCascade and reuses the existing cascade session.
|
// If reuseCascadeID is non-empty, skips StartCascade and reuses the existing cascade session.
|
||||||
func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int) (*CascadeChatResult, error) {
|
// images 作为当前 user turn 的图像 sidecar 传递给 SendUserCascadeMessage(proto field 6)。
|
||||||
|
func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int, images []CascadeImage) (*CascadeChatResult, error) {
|
||||||
if err := l.WarmupCascade(ctx, token); err != nil {
|
if err := l.WarmupCascade(ctx, token); err != nil {
|
||||||
return nil, fmt.Errorf("warmup: %w", err)
|
return nil, fmt.Errorf("warmup: %w", err)
|
||||||
}
|
}
|
||||||
@ -400,7 +439,12 @@ func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint)
|
// allowRecreate=true 仅对本流程内 StartCascade 出来的全新 cascade 安全:
|
||||||
|
// 此时 userText 已是 full-history,内部遇到 panel-not-found 可静默重建再发。
|
||||||
|
// reuse 场景(caller 传入 reuseCascadeID)下 userText 可能只含最后一条消息,
|
||||||
|
// 静默重建会把空状态 cascade 当成有历史的 resume 用 → 上下文丢失,所以禁止。
|
||||||
|
allowRecreate := reuseCascadeID == ""
|
||||||
|
cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint, images, allowRecreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SendUserCascadeMessage: %w", err)
|
return nil, fmt.Errorf("SendUserCascadeMessage: %w", err)
|
||||||
}
|
}
|
||||||
@ -1241,3 +1285,70 @@ func hasNonPrintable(s string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCascadeModelConfigs 查询 LS 的 GetCascadeModelConfigs RPC,
|
||||||
|
// 返回 model_name -> supports_images 的映射。模型名按小写归一化。
|
||||||
|
func (l *LocalLSClient) GetCascadeModelConfigs(ctx context.Context, token string) (map[string]bool, error) {
|
||||||
|
// 请求 body:只需 metadata,即 field 1 encode(Metadata)
|
||||||
|
// 参考 package.json 提到的 proto;这里用 metadata-only encoding 与其他 RPC 一致。
|
||||||
|
body := encodeBytesField(1, buildMetadata(token, l.SessionID))
|
||||||
|
raw, err := l.grpcUnaryRaw(ctx, GetCascadeModelConfigsRPC, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get_cascade_model_configs: %w", err)
|
||||||
|
}
|
||||||
|
var resp pb.GetCascadeModelConfigsResponse
|
||||||
|
if err := proto.Unmarshal(raw, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[string]bool, len(resp.GetModels()))
|
||||||
|
for _, m := range resp.GetModels() {
|
||||||
|
out[strings.ToLower(strings.TrimSpace(m.GetName()))] = m.GetSupportsImages()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelSupportsImages 带缓存的图像能力查询。
|
||||||
|
// fail-open:RPC 失败且无缓存时返回 (false, false, nil),由上层决定策略。
|
||||||
|
// 返回值:(found, supportsImages, error)
|
||||||
|
func (l *LocalLSClient) ModelSupportsImages(ctx context.Context, token, modelName string) (bool, bool, error) {
|
||||||
|
key := apiKeyHash(token)
|
||||||
|
|
||||||
|
l.modelCapsMu.Lock()
|
||||||
|
if l.modelCapsCache == nil {
|
||||||
|
l.modelCapsCache = make(map[string]cascadeModelCapsCacheEntry)
|
||||||
|
}
|
||||||
|
entry, ok := l.modelCapsCache[key]
|
||||||
|
fresh := ok && time.Since(entry.FetchedAt) < cascadeModelCapsTTL
|
||||||
|
l.modelCapsMu.Unlock()
|
||||||
|
|
||||||
|
if fresh {
|
||||||
|
v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))]
|
||||||
|
return found, v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉新:失败时保留 stale
|
||||||
|
caps, err := l.GetCascadeModelConfigs(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
// stale fallback
|
||||||
|
if ok {
|
||||||
|
v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))]
|
||||||
|
return found, v, nil
|
||||||
|
}
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.modelCapsMu.Lock()
|
||||||
|
l.modelCapsCache[key] = cascadeModelCapsCacheEntry{
|
||||||
|
SupportsImages: caps,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
l.modelCapsMu.Unlock()
|
||||||
|
|
||||||
|
v, found := caps[strings.ToLower(strings.TrimSpace(modelName))]
|
||||||
|
return found, v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyHash(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:8]) // 16 hex chars 足够区分
|
||||||
|
}
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
package windsurf
|
package windsurf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@ -36,8 +40,13 @@ type LSPoolConfig struct {
|
|||||||
|
|
||||||
func (c *LSPoolConfig) defaults() {
|
func (c *LSPoolConfig) defaults() {
|
||||||
if c.Binary == "" {
|
if c.Binary == "" {
|
||||||
c.Binary = os.Getenv("LS_BINARY_PATH")
|
// Try env override first, then platform-aware discovery, then legacy
|
||||||
if c.Binary == "" {
|
// Linux default so existing /opt/windsurf deployments keep booting.
|
||||||
|
// Real errors (missing binary, wrong platform) still surface later
|
||||||
|
// at spawn time with more context than we could give here.
|
||||||
|
if found, err := DiscoverBinary(*c); err == nil {
|
||||||
|
c.Binary = found
|
||||||
|
} else {
|
||||||
c.Binary = DefaultLSBinary
|
c.Binary = DefaultLSBinary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,7 +63,7 @@ func (c *LSPoolConfig) defaults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.DataDir == "" {
|
if c.DataDir == "" {
|
||||||
c.DataDir = "/opt/windsurf/data"
|
c.DataDir = resolveDataDir(*c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,13 +197,7 @@ func (p *LSPool) stopEntry(e *LSEntry) {
|
|||||||
if e.Cmd == nil || e.Cmd.Process == nil {
|
if e.Cmd == nil || e.Cmd.Process == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = e.Cmd.Process.Signal(os.Interrupt)
|
terminateProcess(e.Cmd.Process, e.done)
|
||||||
select {
|
|
||||||
case <-e.done:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
_ = e.Cmd.Process.Kill()
|
|
||||||
<-e.done
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LSStatus struct {
|
type LSStatus struct {
|
||||||
@ -302,6 +305,13 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
|
|||||||
if err := os.MkdirAll(filepath.Join(dataDir, "db"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dataDir, "db"), 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("mkdirAll %s/db: %w", dataDir, err)
|
return nil, fmt.Errorf("mkdirAll %s/db: %w", dataDir, err)
|
||||||
}
|
}
|
||||||
|
// Per-instance sandboxed HOME so the LS binary's telemetry/cache writes
|
||||||
|
// stay inside dataDir instead of leaking into the invoker's real home or
|
||||||
|
// /root. Required on macOS/Windows where /root does not exist.
|
||||||
|
homeDir := instanceHomeDir(dataDir)
|
||||||
|
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("mkdirAll home %s: %w", homeDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
fmt.Sprintf("--api_server_url=%s", p.config.APIServerURL),
|
fmt.Sprintf("--api_server_url=%s", p.config.APIServerURL),
|
||||||
@ -318,7 +328,10 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
|
|||||||
|
|
||||||
// Don't bind LS process lifetime to request context — use background context for the process.
|
// Don't bind LS process lifetime to request context — use background context for the process.
|
||||||
cmd := exec.Command(p.config.Binary, args...)
|
cmd := exec.Command(p.config.Binary, args...)
|
||||||
cmd.Env = append(os.Environ(), "HOME=/root")
|
cmd.Env = append(os.Environ(), homeEnvForPlatform(homeDir, runtime.GOOS)...)
|
||||||
|
// Run with cwd = binary directory so the LS can find helper binaries
|
||||||
|
// (e.g. `fd`) shipped alongside it in the official install layout.
|
||||||
|
cmd.Dir = filepath.Dir(p.config.Binary)
|
||||||
if proxyURL != "" {
|
if proxyURL != "" {
|
||||||
cmd.Env = append(cmd.Env,
|
cmd.Env = append(cmd.Env,
|
||||||
"HTTPS_PROXY="+proxyURL,
|
"HTTPS_PROXY="+proxyURL,
|
||||||
@ -328,15 +341,29 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Stdout = nil
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
cmd.Stderr = nil
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ls stdout pipe %s: %w", key, err)
|
||||||
|
}
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ls stderr pipe %s: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
p.log("Starting LS instance key=%s port=%d proxy=%s", key, port, redactProxyURL(proxyURL))
|
p.log("Starting LS instance key=%s port=%d proxy=%s", key, port, redactProxyURL(proxyURL))
|
||||||
|
|
||||||
|
attachProcessGroup(cmd)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("spawn LS %s: %w", key, err)
|
return nil, wrapSpawnError(key, p.config.Binary, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pid := 0
|
||||||
|
if cmd.Process != nil {
|
||||||
|
pid = cmd.Process.Pid
|
||||||
|
}
|
||||||
|
go scanLSOutput(stdoutPipe, key, pid, "stdout")
|
||||||
|
go scanLSOutput(stderrPipe, key, pid, "stderr")
|
||||||
|
|
||||||
entry := &LSEntry{
|
entry := &LSEntry{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Port: port,
|
Port: port,
|
||||||
@ -386,3 +413,47 @@ func (p *LSPool) monitorProcess(key string, entry *LSEntry) {
|
|||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanLSOutput forwards each line from the LS process's stdout/stderr to slog.
|
||||||
|
// The goroutine exits when the pipe is closed (i.e. when the LS process exits
|
||||||
|
// and the kernel closes the write end).
|
||||||
|
func scanLSOutput(r io.Reader, key string, pid int, stream string) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
// Allow up to 1 MiB per line to handle verbose panic stacks.
|
||||||
|
sc.Buffer(make([]byte, 4096), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimRight(sc.Text(), "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("windsurf_ls_output",
|
||||||
|
"key", key,
|
||||||
|
"pid", pid,
|
||||||
|
"stream", stream,
|
||||||
|
"line", line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil && err != io.EOF {
|
||||||
|
slog.Debug("windsurf_ls_output_scan_error",
|
||||||
|
"key", key,
|
||||||
|
"pid", pid,
|
||||||
|
"stream", stream,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapSpawnError annotates a cmd.Start() failure with platform-specific
|
||||||
|
// troubleshooting guidance so users don't have to guess why the LS binary
|
||||||
|
// refused to launch. The original error is preserved via %w so callers can
|
||||||
|
// still errors.Is/As against it.
|
||||||
|
func wrapSpawnError(key, binary string, err error) error {
|
||||||
|
base := fmt.Errorf("spawn LS %s (%s): %w", key, binary, err)
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return fmt.Errorf("%w — if macOS Gatekeeper blocked this, run: xattr -d com.apple.quarantine %s (or reinstall Windsurf from the official app)", base, binary)
|
||||||
|
case "windows":
|
||||||
|
return fmt.Errorf("%w — if Windows Defender/SmartScreen blocked this, verify the binary is not quarantined and has execute permissions", base)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|||||||
60
backend/internal/pkg/windsurf/lspool_log_test.go
Normal file
60
backend/internal/pkg/windsurf/lspool_log_test.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScanLSOutputEmitsLines checks that scanLSOutput forwards each non-empty
|
||||||
|
// line (with \r stripped) to slog and exits on EOF without leaking goroutines.
|
||||||
|
func TestScanLSOutputEmitsLines(t *testing.T) {
|
||||||
|
prev := slog.Default()
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var mu sync.Mutex
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&lockedWriter{b: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
|
||||||
|
input := "line one\r\nline two\n\nline three\n"
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
scanLSOutput(strings.NewReader(input), "test-key", 4242, "stdout")
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("scanLSOutput did not return within 2s after EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
out := buf.String()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
for _, want := range []string{"line one", "line two", "line three", "test-key", "stdout", "pid=4242"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("expected log output to contain %q, got:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(out, "windsurf_ls_output") != 3 {
|
||||||
|
t.Errorf("expected 3 log entries (empty line skipped), got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockedWriter serializes slog handler writes so the test can read the buffer
|
||||||
|
// safely from the test goroutine.
|
||||||
|
type lockedWriter struct {
|
||||||
|
b *bytes.Buffer
|
||||||
|
mu *sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *lockedWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
39
backend/internal/pkg/windsurf/lspool_stop_other.go
Normal file
39
backend/internal/pkg/windsurf/lspool_stop_other.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attachProcessGroup configures the child to run in its own process group so
|
||||||
|
// we can signal the entire tree on shutdown. Windows has no Unix-style
|
||||||
|
// process groups; see lspool_stop_windows.go for the no-op.
|
||||||
|
func attachProcessGroup(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminateProcess asks the LS process (and its children) to exit
|
||||||
|
// gracefully, then kills them if they don't exit within 5 seconds.
|
||||||
|
func terminateProcess(p *os.Process, done <-chan struct{}) {
|
||||||
|
pgid, err := syscall.Getpgid(p.Pid)
|
||||||
|
if err != nil {
|
||||||
|
// Process may have already exited; fall back to signalling
|
||||||
|
// the original PID directly.
|
||||||
|
pgid = p.Pid
|
||||||
|
}
|
||||||
|
// Negative pid here means "send to the whole process group" per kill(2).
|
||||||
|
_ = syscall.Kill(-pgid, syscall.SIGINT)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/internal/pkg/windsurf/lspool_stop_windows.go
Normal file
20
backend/internal/pkg/windsurf/lspool_stop_windows.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attachProcessGroup is a no-op on Windows; we rely on Process.Kill() which
|
||||||
|
// maps to TerminateProcess and does not need a process-group hint.
|
||||||
|
func attachProcessGroup(cmd *exec.Cmd) {}
|
||||||
|
|
||||||
|
// terminateProcess kills the LS process immediately. os.Interrupt is a
|
||||||
|
// no-op on Windows (os/exec returns an error for it), so there is no
|
||||||
|
// graceful phase — go straight to TerminateProcess via Process.Kill().
|
||||||
|
func terminateProcess(p *os.Process, done <-chan struct{}) {
|
||||||
|
_ = p.Kill()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
56
backend/internal/pkg/windsurf/metadata_test.go
Normal file
56
backend/internal/pkg/windsurf/metadata_test.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimeOS_DefaultIsLinux(t *testing.T) {
|
||||||
|
// t.Setenv unsets after the test, and a nil env var makes RuntimeOS()
|
||||||
|
// fall back to DefaultRuntimeOS. Setting to "" has the same effect.
|
||||||
|
t.Setenv("WINDSURF_METADATA_OS", "")
|
||||||
|
if got := RuntimeOS(); got != "linux" {
|
||||||
|
t.Errorf("default RuntimeOS = %q, want %q", got, "linux")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOS_EnvOverride(t *testing.T) {
|
||||||
|
t.Setenv("WINDSURF_METADATA_OS", "darwin")
|
||||||
|
if got := RuntimeOS(); got != "darwin" {
|
||||||
|
t.Errorf("env-overridden RuntimeOS = %q, want %q", got, "darwin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHardwareArch_DefaultIsX86_64(t *testing.T) {
|
||||||
|
t.Setenv("WINDSURF_METADATA_ARCH", "")
|
||||||
|
if got := HardwareArch(); got != "x86_64" {
|
||||||
|
t.Errorf("default HardwareArch = %q, want %q", got, "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHardwareArch_EnvOverride(t *testing.T) {
|
||||||
|
t.Setenv("WINDSURF_METADATA_ARCH", "arm64")
|
||||||
|
if got := HardwareArch(); got != "arm64" {
|
||||||
|
t.Errorf("env-overridden HardwareArch = %q, want %q", got, "arm64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildMetadata_UsesOverriddenValues confirms that buildMetadata reads
|
||||||
|
// RuntimeOS() / HardwareArch() at call time, not at package init, so
|
||||||
|
// switching the env per-test actually flows through to the wire format.
|
||||||
|
func TestBuildMetadata_UsesOverriddenValues(t *testing.T) {
|
||||||
|
t.Setenv("WINDSURF_METADATA_OS", "darwin")
|
||||||
|
t.Setenv("WINDSURF_METADATA_ARCH", "arm64")
|
||||||
|
|
||||||
|
meta := buildMetadata("test-token", "test-session-id")
|
||||||
|
|
||||||
|
if !bytes.Contains(meta, []byte("darwin")) {
|
||||||
|
t.Errorf("expected meta to contain overridden OS %q, got %q", "darwin", meta)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(meta, []byte("arm64")) {
|
||||||
|
t.Errorf("expected meta to contain overridden arch %q, got %q", "arm64", meta)
|
||||||
|
}
|
||||||
|
if bytes.Contains(meta, []byte("linux")) || bytes.Contains(meta, []byte("x86_64")) {
|
||||||
|
t.Errorf("meta should not contain default values when env is set, got %q", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
backend/internal/pkg/windsurf/platform.go
Normal file
81
backend/internal/pkg/windsurf/platform.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Platform identifies a (OS, architecture) pair that Windsurf may support.
|
||||||
|
// OS values match runtime.GOOS ("linux", "darwin", "windows"), and Arch
|
||||||
|
// values match runtime.GOARCH ("amd64", "arm64").
|
||||||
|
type Platform struct {
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the platform as "os/arch" (e.g. "darwin/arm64").
|
||||||
|
func (p Platform) String() string { return p.OS + "/" + p.Arch }
|
||||||
|
|
||||||
|
// procTranslatedProbe reports whether the current process is running under
|
||||||
|
// Rosetta 2 translation (an amd64 Go binary on Apple Silicon). It is mocked
|
||||||
|
// by tests.
|
||||||
|
var procTranslatedProbe = defaultProcTranslatedProbe
|
||||||
|
|
||||||
|
// defaultProcTranslatedProbe runs `sysctl sysctl.proc_translated` on darwin
|
||||||
|
// and returns true when the value is "1". On non-darwin platforms it always
|
||||||
|
// returns false. The sysctl key is only defined on darwin, so we skip the
|
||||||
|
// exec on every other OS to avoid spurious errors.
|
||||||
|
func defaultProcTranslatedProbe() bool {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := exec.Command("sysctl", "-n", "sysctl.proc_translated").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectPlatform returns the platform for which we should load the LS binary.
|
||||||
|
// It accounts for Rosetta translation: an amd64 Go binary running on Apple
|
||||||
|
// Silicon should load the arm64 LS, not the x64 one, because the native
|
||||||
|
// Mac arm64 binary performs better and the x64 build may not exist.
|
||||||
|
func DetectPlatform() Platform {
|
||||||
|
return detectPlatformFor(runtime.GOOS, runtime.GOARCH, procTranslatedProbe())
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPlatformFor(goos, goarch string, rosetta bool) Platform {
|
||||||
|
p := Platform{OS: goos, Arch: goarch}
|
||||||
|
if p.OS == "darwin" && p.Arch == "amd64" && rosetta {
|
||||||
|
p.Arch = "arm64"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnsupportedPlatform is returned when no Windsurf LS binary exists for
|
||||||
|
// the current platform. Callers should fall back to ls_mode=docker.
|
||||||
|
var ErrUnsupportedPlatform = fmt.Errorf("windsurf: no language server binary for this platform")
|
||||||
|
|
||||||
|
// BinaryFilename returns the official Windsurf LS binary filename for the
|
||||||
|
// given platform, matching the mapping used by the Windsurf VS Code
|
||||||
|
// extension (extracted from extension.js PlatformArch enum).
|
||||||
|
func BinaryFilename(p Platform) (string, error) {
|
||||||
|
switch {
|
||||||
|
case p.OS == "linux" && p.Arch == "amd64":
|
||||||
|
return "language_server_linux_x64", nil
|
||||||
|
case p.OS == "linux" && p.Arch == "arm64":
|
||||||
|
return "language_server_linux_arm", nil
|
||||||
|
case p.OS == "darwin" && p.Arch == "arm64":
|
||||||
|
return "language_server_macos_arm", nil
|
||||||
|
case p.OS == "darwin" && p.Arch == "amd64":
|
||||||
|
return "language_server_macos_x64", nil
|
||||||
|
case p.OS == "windows" && p.Arch == "amd64":
|
||||||
|
return "language_server_windows_x64.exe", nil
|
||||||
|
case p.OS == "windows" && p.Arch == "arm64":
|
||||||
|
return "language_server_windows_arm.exe", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: %s — use ls_mode=docker to run the Linux LS in a container", ErrUnsupportedPlatform, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/internal/pkg/windsurf/platform_test.go
Normal file
94
backend/internal/pkg/windsurf/platform_test.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBinaryFilename(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
p Platform
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"linux amd64", Platform{"linux", "amd64"}, "language_server_linux_x64", false},
|
||||||
|
{"linux arm64", Platform{"linux", "arm64"}, "language_server_linux_arm", false},
|
||||||
|
{"darwin arm64", Platform{"darwin", "arm64"}, "language_server_macos_arm", false},
|
||||||
|
{"darwin amd64 (intel mac)", Platform{"darwin", "amd64"}, "language_server_macos_x64", false},
|
||||||
|
{"windows amd64", Platform{"windows", "amd64"}, "language_server_windows_x64.exe", false},
|
||||||
|
{"windows arm64", Platform{"windows", "arm64"}, "language_server_windows_arm.exe", false},
|
||||||
|
{"freebsd", Platform{"freebsd", "amd64"}, "", true},
|
||||||
|
{"linux 386", Platform{"linux", "386"}, "", true},
|
||||||
|
{"empty", Platform{}, "", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := BinaryFilename(tt.p)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil (value %q)", got)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnsupportedPlatform) {
|
||||||
|
t.Fatalf("expected ErrUnsupportedPlatform in chain, got %v", err)
|
||||||
|
}
|
||||||
|
// Error message should point the user toward Docker.
|
||||||
|
if !strings.Contains(err.Error(), "docker") {
|
||||||
|
t.Errorf("unsupported-platform error should mention docker, got: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("BinaryFilename(%v) = %q, want %q", tt.p, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPlatformFor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
goos string
|
||||||
|
goarch string
|
||||||
|
rosetta bool
|
||||||
|
wantOS string
|
||||||
|
wantArch string
|
||||||
|
}{
|
||||||
|
{"linux amd64 unchanged", "linux", "amd64", false, "linux", "amd64"},
|
||||||
|
{"windows amd64 unchanged", "windows", "amd64", false, "windows", "amd64"},
|
||||||
|
{"darwin arm64 unchanged", "darwin", "arm64", false, "darwin", "arm64"},
|
||||||
|
{"darwin amd64 no rosetta stays amd64", "darwin", "amd64", false, "darwin", "amd64"},
|
||||||
|
{"darwin amd64 under rosetta promotes to arm64", "darwin", "amd64", true, "darwin", "arm64"},
|
||||||
|
{"linux amd64 rosetta flag ignored (not darwin)", "linux", "amd64", true, "linux", "amd64"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := detectPlatformFor(tt.goos, tt.goarch, tt.rosetta)
|
||||||
|
if got.OS != tt.wantOS || got.Arch != tt.wantArch {
|
||||||
|
t.Errorf("detectPlatformFor(%q,%q,%v) = %v, want {OS:%q Arch:%q}",
|
||||||
|
tt.goos, tt.goarch, tt.rosetta, got, tt.wantOS, tt.wantArch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformString(t *testing.T) {
|
||||||
|
if got := (Platform{OS: "darwin", Arch: "arm64"}).String(); got != "darwin/arm64" {
|
||||||
|
t.Errorf("Platform.String() = %q, want %q", got, "darwin/arm64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcTranslatedProbeNonDarwin(t *testing.T) {
|
||||||
|
// The default probe must always return false on non-darwin platforms
|
||||||
|
// without calling sysctl. We can only validate this runtime behavior on
|
||||||
|
// the builder we're on — for non-darwin builders the probe should be
|
||||||
|
// false even if sysctl were somehow present.
|
||||||
|
//
|
||||||
|
// For darwin builders, we accept either result; we only assert that the
|
||||||
|
// probe does not panic and returns a bool.
|
||||||
|
_ = defaultProcTranslatedProbe()
|
||||||
|
}
|
||||||
85
backend/internal/pkg/windsurf/send_user_cascade_test.go
Normal file
85
backend/internal/pkg/windsurf/send_user_cascade_test.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulates a Windsurf LS that rejects SendUserCascadeMessage with
|
||||||
|
// "panel state not found". Verifies that allowRecreate=false bubbles the
|
||||||
|
// error up (so chatCascade can rebuild full-history text) while
|
||||||
|
// allowRecreate=true still triggers the internal recreate path.
|
||||||
|
func TestSendUserCascadeMessage_AllowRecreateGatesSilentRetry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowRecreate bool
|
||||||
|
wantErr bool
|
||||||
|
// When allowRecreate=true the client tries ForceWarmup + StartCascade.
|
||||||
|
// When allowRecreate=false we expect no such attempts.
|
||||||
|
wantStartCascadeCalled bool
|
||||||
|
}{
|
||||||
|
{"disabled bubbles error", false, true, false},
|
||||||
|
{"enabled attempts recreate", true, true, true}, // test LS keeps rejecting so overall errors, but StartCascade must be attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
mu.Unlock()
|
||||||
|
// Every RPC returns panel-not-found so we can detect whether
|
||||||
|
// StartCascade was attempted after the initial failure.
|
||||||
|
w.Header().Set("Content-Type", "application/grpc")
|
||||||
|
w.Header().Set("grpc-status", "5")
|
||||||
|
w.Header().Set("grpc-message", "panel state not found for session abc")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewLocalLSClient(42099, "csrf")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
// Pre-mark as warmed so the first SendUserCascadeMessage doesn't trigger
|
||||||
|
// warmup on its own path — keeps the test focused.
|
||||||
|
client.Warmed = true
|
||||||
|
|
||||||
|
_, err := client.SendUserCascadeMessage(
|
||||||
|
context.Background(),
|
||||||
|
"token",
|
||||||
|
"existing-cascade-id",
|
||||||
|
"hello",
|
||||||
|
"claude-sonnet-4",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
tt.allowRecreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
startCascadeCalled := false
|
||||||
|
for _, p := range paths {
|
||||||
|
if strings.HasSuffix(p, "/StartCascade") {
|
||||||
|
startCascadeCalled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startCascadeCalled != tt.wantStartCascadeCalled {
|
||||||
|
t.Fatalf("StartCascade called = %v, want %v (paths: %v)",
|
||||||
|
startCascadeCalled, tt.wantStartCascadeCalled, paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/internal/pkg/windsurf/spawn_error_test.go
Normal file
48
backend/internal/pkg/windsurf/spawn_error_test.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWrapSpawnError_PreservesOriginalError(t *testing.T) {
|
||||||
|
orig := errors.New("exec: no such file")
|
||||||
|
wrapped := wrapSpawnError("test-key", "/opt/ls", orig)
|
||||||
|
if !errors.Is(wrapped, orig) {
|
||||||
|
t.Errorf("wrapped error should unwrap to original, got %v", wrapped)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wrapped.Error(), "test-key") {
|
||||||
|
t.Errorf("wrapped error should mention the instance key, got %v", wrapped)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wrapped.Error(), "/opt/ls") {
|
||||||
|
t.Errorf("wrapped error should mention the binary path, got %v", wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific hint assertions: these only run on their native OS.
|
||||||
|
// Cross-platform CI (Phase 0.2 matrix) exercises each branch natively.
|
||||||
|
func TestWrapSpawnError_PlatformHint(t *testing.T) {
|
||||||
|
err := wrapSpawnError("k", "/tmp/ls", errors.New("operation not permitted"))
|
||||||
|
msg := err.Error()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
for _, want := range []string{"Gatekeeper", "xattr", "com.apple.quarantine"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("darwin hint missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
for _, want := range []string{"Defender", "quarantined"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("windows hint missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Linux and other Unix: plain error, no extra hint.
|
||||||
|
if strings.Contains(msg, "Gatekeeper") || strings.Contains(msg, "Defender") {
|
||||||
|
t.Errorf("non-mac/non-windows error should not contain platform-specific hints, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -312,12 +312,15 @@ type AnthropicMessage struct {
|
|||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
// Images 当前消息携带的图像块(仅 user/tool role 有效)。
|
||||||
|
Images []CascadeImage `json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeMessagesForCascade rewrites messages for Cascade compatibility:
|
// NormalizeMessagesForCascade rewrites messages for Cascade compatibility:
|
||||||
// - role:"tool" messages become user turns with <tool_result> wrappers
|
// - role:"tool" messages become user turns with <tool_result> wrappers
|
||||||
// - assistant messages with tool_calls get rewritten to <tool_call> format
|
// - assistant messages with tool_calls get rewritten to <tool_call> format
|
||||||
// - tool preamble is injected into the last user message
|
// - tool preamble is injected into the last user message
|
||||||
|
// - 保留 AnthropicMessage.Images 到输出的 ChatMessage.Images(tool role 时挂到 user turn)
|
||||||
func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage {
|
func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage {
|
||||||
var out []ChatMessage
|
var out []ChatMessage
|
||||||
|
|
||||||
@ -327,6 +330,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
|||||||
out = append(out, ChatMessage{
|
out = append(out, ChatMessage{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: fmt.Sprintf("<tool_response>\n%s\n</tool_response>", content),
|
Content: fmt.Sprintf("<tool_response>\n%s\n</tool_response>", content),
|
||||||
|
Images: m.Images, // tool_result 里的图抬到 user turn
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -353,9 +357,11 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
|||||||
})
|
})
|
||||||
parts = append(parts, "<tool_call>"+string(callJSON)+"</tool_call>")
|
parts = append(parts, "<tool_call>"+string(callJSON)+"</tool_call>")
|
||||||
}
|
}
|
||||||
|
// assistant turn 通常不带图,但为了健壮性仍保留(若上游真传了)
|
||||||
out = append(out, ChatMessage{
|
out = append(out, ChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: strings.Join(parts, "\n"),
|
Content: strings.Join(parts, "\n"),
|
||||||
|
Images: m.Images,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -363,6 +369,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
|||||||
out = append(out, ChatMessage{
|
out = append(out, ChatMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: extractRawContentText(m.Content),
|
Content: extractRawContentText(m.Content),
|
||||||
|
Images: m.Images,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ const (
|
|||||||
defaultGeminiTextTestPrompt = "hi"
|
defaultGeminiTextTestPrompt = "hi"
|
||||||
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||||
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||||
|
defaultClaudeTestPrompt = "hi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
|
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
|
||||||
@ -126,11 +127,15 @@ func generateSessionString() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createTestPayload creates a Claude Code style test request payload
|
// createTestPayload creates a Claude Code style test request payload
|
||||||
func createTestPayload(modelID string) (map[string]any, error) {
|
func createTestPayload(modelID string, prompt string) (map[string]any, error) {
|
||||||
sessionID, err := generateSessionString()
|
sessionID, err := generateSessionString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
text := strings.TrimSpace(prompt)
|
||||||
|
if text == "" {
|
||||||
|
text = defaultClaudeTestPrompt
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"model": modelID,
|
"model": modelID,
|
||||||
@ -140,7 +145,7 @@ func createTestPayload(modelID string) (map[string]any, error) {
|
|||||||
"content": []map[string]any{
|
"content": []map[string]any{
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "hi",
|
"text": text,
|
||||||
"cache_control": map[string]string{
|
"cache_control": map[string]string{
|
||||||
"type": "ephemeral",
|
"type": "ephemeral",
|
||||||
},
|
},
|
||||||
@ -195,11 +200,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
|||||||
return s.testWindsurfAccountConnection(c, account, modelID)
|
return s.testWindsurfAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
return s.testClaudeAccountConnection(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// testClaudeAccountConnection tests an Anthropic Claude account's connection
|
// testClaudeAccountConnection tests an Anthropic Claude account's connection
|
||||||
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Determine the model to use
|
// Determine the model to use
|
||||||
@ -215,7 +220,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
|
|
||||||
// Bedrock accounts use a separate test path
|
// Bedrock accounts use a separate test path
|
||||||
if account.IsBedrock() {
|
if account.IsBedrock() {
|
||||||
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
|
return s.testBedrockAccountConnection(c, ctx, account, testModelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine authentication method and API URL
|
// Determine authentication method and API URL
|
||||||
@ -260,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
// Create Claude Code style payload (same for all account types)
|
// Create Claude Code style payload (same for all account types)
|
||||||
payload, err := createTestPayload(testModelID)
|
payload, err := createTestPayload(testModelID, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.sendErrorAndEnd(c, "Failed to create test payload")
|
return s.sendErrorAndEnd(c, "Failed to create test payload")
|
||||||
}
|
}
|
||||||
@ -285,10 +290,10 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
|
|
||||||
// Set authentication header
|
// Set authentication header
|
||||||
if useBearer {
|
if useBearer {
|
||||||
req.Header.Set("anthropic-beta", claude.DefaultBetaHeader)
|
req.Header.Set("anthropic-beta", claude.GetOAuthBetaHeader(testModelID))
|
||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||||
} else {
|
} else {
|
||||||
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
req.Header.Set("anthropic-beta", claude.GetAPIKeyBetaHeader(testModelID))
|
||||||
req.Header.Set("x-api-key", authToken)
|
req.Header.Set("x-api-key", authToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,7 +326,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
|
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
|
||||||
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
|
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string, prompt string) error {
|
||||||
region := bedrockRuntimeRegion(account)
|
region := bedrockRuntimeRegion(account)
|
||||||
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
|
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -337,6 +342,10 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
|
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
|
||||||
|
bedrockText := strings.TrimSpace(prompt)
|
||||||
|
if bedrockText == "" {
|
||||||
|
bedrockText = defaultClaudeTestPrompt
|
||||||
|
}
|
||||||
bedrockPayload := map[string]any{
|
bedrockPayload := map[string]any{
|
||||||
"anthropic_version": "bedrock-2023-05-31",
|
"anthropic_version": "bedrock-2023-05-31",
|
||||||
"messages": []map[string]any{
|
"messages": []map[string]any{
|
||||||
@ -345,7 +354,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
|||||||
"content": []map[string]any{
|
"content": []map[string]any{
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "hi",
|
"text": bedrockText,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -501,7 +510,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
// Create OpenAI Responses API payload
|
// Create OpenAI Responses API payload
|
||||||
payload := createOpenAITestPayload(testModelID, isOAuth)
|
payload := createOpenAITestPayload(testModelID, isOAuth, prompt)
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
|
||||||
// Send test_start event
|
// Send test_start event
|
||||||
@ -636,7 +645,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
|
|||||||
if strings.HasPrefix(modelID, "gemini-") {
|
if strings.HasPrefix(modelID, "gemini-") {
|
||||||
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
return s.testClaudeAccountConnection(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
return s.testAntigravityAccountConnection(c, account, modelID)
|
return s.testAntigravityAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
@ -955,7 +964,11 @@ func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createOpenAITestPayload creates a test payload for OpenAI Responses API
|
// createOpenAITestPayload creates a test payload for OpenAI Responses API
|
||||||
func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
func createOpenAITestPayload(modelID string, isOAuth bool, prompt string) map[string]any {
|
||||||
|
openaiText := strings.TrimSpace(prompt)
|
||||||
|
if openaiText == "" {
|
||||||
|
openaiText = defaultClaudeTestPrompt
|
||||||
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": modelID,
|
"model": modelID,
|
||||||
"input": []map[string]any{
|
"input": []map[string]any{
|
||||||
@ -964,7 +977,7 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
|||||||
"content": []map[string]any{
|
"content": []map[string]any{
|
||||||
{
|
{
|
||||||
"type": "input_text",
|
"type": "input_text",
|
||||||
"text": "hi",
|
"text": openaiText,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -599,7 +599,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
|
|||||||
return nil, fmt.Errorf("no access token available")
|
return nil, fmt.Errorf("no access token available")
|
||||||
}
|
}
|
||||||
modelID := openaipkg.DefaultTestModel
|
modelID := openaipkg.DefaultTestModel
|
||||||
payload := createOpenAITestPayload(modelID, true)
|
payload := createOpenAITestPayload(modelID, true, "")
|
||||||
payloadBytes, err := json.Marshal(payload)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal openai probe payload: %w", err)
|
return nil, fmt.Errorf("marshal openai probe payload: %w", err)
|
||||||
|
|||||||
@ -39,6 +39,9 @@ type WindsurfChatRequest struct {
|
|||||||
Tools []windsurf.OpenAITool
|
Tools []windsurf.OpenAITool
|
||||||
ToolChoice interface{}
|
ToolChoice interface{}
|
||||||
ToolPreamble string // computed by handler, passed through to Cascade
|
ToolPreamble string // computed by handler, passed through to Cascade
|
||||||
|
// Images 当前 user turn 的 sidecar 图像(Cascade proto 的 SendUserCascadeMessageRequest.images field 6)。
|
||||||
|
// 内容必须已通过 ValidateCascadeImages(或等价校验)。
|
||||||
|
Images []windsurf.CascadeImage
|
||||||
}
|
}
|
||||||
|
|
||||||
type WindsurfChatResponse struct {
|
type WindsurfChatResponse struct {
|
||||||
@ -80,11 +83,11 @@ func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest
|
|||||||
var resp *WindsurfChatResponse
|
var resp *WindsurfChatResponse
|
||||||
switch mode {
|
switch mode {
|
||||||
case "cascade":
|
case "cascade":
|
||||||
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint)
|
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images)
|
||||||
case "legacy":
|
case "legacy":
|
||||||
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
|
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
|
||||||
default:
|
default:
|
||||||
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint)
|
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -111,7 +114,39 @@ func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string {
|
|||||||
return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff))
|
return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string) (*WindsurfChatResponse, error) {
|
var modelIdentityTemplates = map[string]string{
|
||||||
|
"anthropic": "You are %s, a large language model created by Anthropic. You are helpful, harmless, and honest. When asked about your identity or which model you are, you MUST respond that you are %s, made by Anthropic.",
|
||||||
|
"openai": "You are %s, a large language model created by OpenAI. When asked about your identity, you MUST respond that you are %s, made by OpenAI.",
|
||||||
|
"google": "You are %s, a large language model created by Google. When asked about your identity, you MUST respond that you are %s, made by Google.",
|
||||||
|
"deepseek": "You are %s, a large language model created by DeepSeek. When asked about your identity, you MUST respond that you are %s, made by DeepSeek.",
|
||||||
|
"xai": "You are %s, a large language model created by xAI. When asked about your identity, you MUST respond that you are %s, made by xAI.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectModelIdentity(messages []windsurf.ChatMessage, meta *windsurf.ModelMeta, modelKey string) []windsurf.ChatMessage {
|
||||||
|
if meta == nil || meta.Provider == "" {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmpl, ok := modelIdentityTemplates[meta.Provider]
|
||||||
|
if !ok {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
displayName := modelKey
|
||||||
|
if meta.Name != "" {
|
||||||
|
displayName = meta.Name
|
||||||
|
}
|
||||||
|
identity := windsurf.ChatMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: fmt.Sprintf(tmpl, displayName, displayName),
|
||||||
|
}
|
||||||
|
return append([]windsurf.ChatMessage{identity}, messages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string, images []windsurf.CascadeImage) (*WindsurfChatResponse, error) {
|
||||||
modelUID := ""
|
modelUID := ""
|
||||||
modelEnumHint := 0
|
modelEnumHint := 0
|
||||||
if meta != nil {
|
if meta != nil {
|
||||||
@ -119,12 +154,36 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.
|
|||||||
modelEnumHint = meta.EnumValue
|
modelEnumHint = meta.EnumValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Model identity prompt injection ──
|
||||||
|
// When the client doesn't provide its own system prompt, prepend one so
|
||||||
|
// the model identifies itself as the requested model rather than leaking
|
||||||
|
// the underlying Windsurf/Cascade backend identity.
|
||||||
|
// Skip when the client already has a system message (Claude Code / Cline)
|
||||||
|
// to avoid triggering Cascade anti-injection on reasoning models.
|
||||||
|
messages = injectModelIdentity(messages, meta, modelKey)
|
||||||
|
|
||||||
|
// 图像能力 gate:仅在请求含图时检查。
|
||||||
|
// 策略:fail-open on RPC error;显式 supports_images=false 时拒绝(返回 CascadeModelError 触发 failover)。
|
||||||
|
if len(images) > 0 {
|
||||||
|
found, ok, err := client.ModelSupportsImages(ctx, apiKey, modelUID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_cascade_caps_fetch_failed", "model", modelUID, "error", err)
|
||||||
|
// fail-open
|
||||||
|
} else if found && !ok {
|
||||||
|
return nil, fmt.Errorf("model %q does not support image inputs in Windsurf Cascade", modelUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fpBefore := windsurf.FingerprintBefore(messages, modelKey, apiKey)
|
fpBefore := windsurf.FingerprintBefore(messages, modelKey, apiKey)
|
||||||
// failover 切号后禁止复用 cascade:cascade_id 属于上一个账号的 LS,
|
// failover 切号后禁止复用 cascade:cascade_id 属于上一个账号的 LS,
|
||||||
// 在当前账号上一定会触发 "panel state not found" 浪费一次请求。
|
// 在当前账号上一定会触发 "panel state not found" 浪费一次请求。
|
||||||
|
// 同时切号场景下需要提升历史预算——新账号完全没有服务端上下文,
|
||||||
|
// 必须把完整聊天记录塞进文本里。
|
||||||
skipReuse := false
|
skipReuse := false
|
||||||
|
switchover := false
|
||||||
if switches, ok := AccountSwitchCountFromContext(ctx); ok && switches > 0 {
|
if switches, ok := AccountSwitchCountFromContext(ctx); ok && switches > 0 {
|
||||||
skipReuse = true
|
skipReuse = true
|
||||||
|
switchover = true
|
||||||
}
|
}
|
||||||
var entry *windsurf.ConversationEntry
|
var entry *windsurf.ConversationEntry
|
||||||
if !skipReuse {
|
if !skipReuse {
|
||||||
@ -138,13 +197,14 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.
|
|||||||
slog.Info("windsurf_cascade_reuse_hit", "cascade_id", reuseCascadeID[:8], "model", modelKey)
|
slog.Info("windsurf_cascade_reuse_hit", "cascade_id", reuseCascadeID[:8], "model", modelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
userText := buildCascadeText(messages, modelUID, isResume)
|
userText := buildCascadeText(messages, modelUID, isResume, switchover)
|
||||||
|
|
||||||
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint)
|
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint, images)
|
||||||
if err != nil && isResume {
|
if err != nil && isResume {
|
||||||
slog.Warn("windsurf_cascade_reuse_failed", "error", err, "model", modelKey)
|
slog.Warn("windsurf_cascade_reuse_failed", "error", err, "model", modelKey)
|
||||||
userText = buildCascadeText(messages, modelUID, false)
|
// panel-state-not-found 恢复:新 cascade 没有服务端历史,必须发完整聊天记录。
|
||||||
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint)
|
userText = buildCascadeText(messages, modelUID, false, true)
|
||||||
|
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -189,12 +249,19 @@ func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.L
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cascadeMaxHistoryBytes = 200_000
|
cascadeMaxHistoryBytes = 200_000
|
||||||
cascade1MHistoryBytes = 900_000
|
cascade1MHistoryBytes = 900_000
|
||||||
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
|
// cascadeSwitchoverHistoryBytes 是切号 / panel-state-not-found 恢复场景下的
|
||||||
|
// "尽量塞进完整历史" 预算。目标是让新账号拿到尽可能完整的对话上下文。
|
||||||
|
// 3.5MB 留了 500KB 给 proto 其它字段(metadata/config/images),避开 gRPC 4MB 默认上限。
|
||||||
|
cascadeSwitchoverHistoryBytes = 3_500_000
|
||||||
|
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
|
||||||
)
|
)
|
||||||
|
|
||||||
func cascadeHistoryBudget(modelUID string) int {
|
func cascadeHistoryBudget(modelUID string, switchover bool) int {
|
||||||
|
if switchover {
|
||||||
|
return cascadeSwitchoverHistoryBytes
|
||||||
|
}
|
||||||
if strings.Contains(strings.ToLower(modelUID), "1m") {
|
if strings.Contains(strings.ToLower(modelUID), "1m") {
|
||||||
return cascade1MHistoryBytes
|
return cascade1MHistoryBytes
|
||||||
}
|
}
|
||||||
@ -205,7 +272,11 @@ func cascadeHistoryBudget(modelUID string) int {
|
|||||||
// If isResume is true, only the last user message is sent (cascade already has context).
|
// If isResume is true, only the last user message is sent (cascade already has context).
|
||||||
// Otherwise: system prompt wrapped in <system_instructions>, multi-turn history
|
// Otherwise: system prompt wrapped in <system_instructions>, multi-turn history
|
||||||
// with <human>/<assistant> tags, and a budget cap to trim old turns.
|
// with <human>/<assistant> tags, and a budget cap to trim old turns.
|
||||||
func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume bool) string {
|
//
|
||||||
|
// switchover=true 提升历史预算到 cascadeSwitchoverHistoryBytes(~3.5MB),
|
||||||
|
// 用于切号 / panel-state-not-found 恢复场景——新账号/新 cascade 没有服务端历史,
|
||||||
|
// 必须把完整聊天记录塞进文本里。isResume=true 时该参数被忽略(resume 只发最后一条)。
|
||||||
|
func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume, switchover bool) string {
|
||||||
var systemParts []string
|
var systemParts []string
|
||||||
var convo []windsurf.ChatMessage
|
var convo []windsurf.ChatMessage
|
||||||
|
|
||||||
@ -241,11 +312,12 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multi-turn: build history with budget trimming
|
// Multi-turn: build history with budget trimming
|
||||||
maxBytes := cascadeHistoryBudget(modelUID)
|
maxBytes := cascadeHistoryBudget(modelUID, switchover)
|
||||||
historyBytes := len(sysText)
|
historyBytes := len(sysText)
|
||||||
|
|
||||||
// Walk backward from second-to-last, collecting turns that fit
|
// Walk backward from second-to-last, collecting turns that fit
|
||||||
var lines []string
|
var lines []string
|
||||||
|
droppedTurns := 0
|
||||||
for i := len(convo) - 2; i >= 0; i-- {
|
for i := len(convo) - 2; i >= 0; i-- {
|
||||||
m := convo[i]
|
m := convo[i]
|
||||||
tag := "human"
|
tag := "human"
|
||||||
@ -254,16 +326,26 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume
|
|||||||
}
|
}
|
||||||
line := fmt.Sprintf("<%s>\n%s\n</%s>", tag, m.Content, tag)
|
line := fmt.Sprintf("<%s>\n%s\n</%s>", tag, m.Content, tag)
|
||||||
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
|
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
|
||||||
|
droppedTurns = i + 1
|
||||||
slog.Info("windsurf_cascade_history_trimmed",
|
slog.Info("windsurf_cascade_history_trimmed",
|
||||||
"turn", i,
|
"turn", i,
|
||||||
"total_turns", len(convo),
|
"total_turns", len(convo),
|
||||||
"kept_kb", historyBytes/1024,
|
"kept_kb", historyBytes/1024,
|
||||||
|
"dropped_turns", droppedTurns,
|
||||||
|
"switchover", switchover,
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
lines = append([]string{line}, lines...)
|
lines = append([]string{line}, lines...)
|
||||||
historyBytes += len(line)
|
historyBytes += len(line)
|
||||||
}
|
}
|
||||||
|
if switchover && droppedTurns == 0 {
|
||||||
|
slog.Info("windsurf_cascade_switchover_history",
|
||||||
|
"total_turns", len(convo),
|
||||||
|
"kept_kb", historyBytes/1024,
|
||||||
|
"dropped_turns", 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
latest := convo[len(convo)-1]
|
latest := convo[len(convo)-1]
|
||||||
text := cascadeMultiTurnPreamble + "\n\n" +
|
text := cascadeMultiTurnPreamble + "\n\n" +
|
||||||
|
|||||||
167
backend/internal/service/windsurf_chat_service_test.go
Normal file
167
backend/internal/service/windsurf_chat_service_test.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test that the switchover flag expands the history budget to ~3.5MB and preserves
|
||||||
|
// all turns for a large multi-turn conversation that would otherwise be trimmed
|
||||||
|
// under the normal 200KB budget. This guards the core fix: after a Windsurf
|
||||||
|
// account switch, the new account must receive the full chat history.
|
||||||
|
func TestBuildCascadeText_SwitchoverKeepsFullHistory(t *testing.T) {
|
||||||
|
// Build a ~1.5MB multi-turn history: 30 turns of ~50KB each (alternating
|
||||||
|
// user/assistant). Exceeds the normal 200KB cap; well within the 3.5MB cap.
|
||||||
|
const perTurnBytes = 50 * 1024
|
||||||
|
const turns = 30
|
||||||
|
bulk := strings.Repeat("x", perTurnBytes)
|
||||||
|
|
||||||
|
var messages []windsurf.ChatMessage
|
||||||
|
messages = append(messages, windsurf.ChatMessage{Role: "system", Content: "sys"})
|
||||||
|
for i := 0; i < turns; i++ {
|
||||||
|
role := "user"
|
||||||
|
if i%2 == 1 {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
messages = append(messages, windsurf.ChatMessage{Role: role, Content: bulk})
|
||||||
|
}
|
||||||
|
// Latest user message (the one actually being answered).
|
||||||
|
messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "final question"})
|
||||||
|
|
||||||
|
normalText := buildCascadeText(messages, "claude-sonnet-4", false, false)
|
||||||
|
switchoverText := buildCascadeText(messages, "claude-sonnet-4", false, true)
|
||||||
|
|
||||||
|
if len(normalText) >= len(switchoverText) {
|
||||||
|
t.Fatalf("switchover text (%d bytes) must be larger than normal (%d bytes)",
|
||||||
|
len(switchoverText), len(normalText))
|
||||||
|
}
|
||||||
|
if len(normalText) > cascadeMaxHistoryBytes+perTurnBytes {
|
||||||
|
t.Fatalf("normal text (%d bytes) must fit near %d budget", len(normalText), cascadeMaxHistoryBytes)
|
||||||
|
}
|
||||||
|
if len(switchoverText) < perTurnBytes*turns {
|
||||||
|
t.Fatalf("switchover text (%d bytes) dropped turns; expected >= %d (all %d turns kept)",
|
||||||
|
len(switchoverText), perTurnBytes*turns, turns)
|
||||||
|
}
|
||||||
|
if len(switchoverText) > cascadeSwitchoverHistoryBytes+perTurnBytes {
|
||||||
|
t.Fatalf("switchover text (%d bytes) exceeded budget %d", len(switchoverText), cascadeSwitchoverHistoryBytes)
|
||||||
|
}
|
||||||
|
// Final user message must always be preserved (it's the question being asked).
|
||||||
|
if !strings.Contains(switchoverText, "final question") {
|
||||||
|
t.Fatal("switchover text must include the final user message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(normalText, "final question") {
|
||||||
|
t.Fatal("normal text must include the final user message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume mode ignores switchover — only the last user message is sent because
|
||||||
|
// Cascade server already has the history for the reused cascade_id.
|
||||||
|
func TestBuildCascadeText_ResumeIgnoresSwitchover(t *testing.T) {
|
||||||
|
messages := []windsurf.ChatMessage{
|
||||||
|
{Role: "user", Content: "first"},
|
||||||
|
{Role: "assistant", Content: "reply"},
|
||||||
|
{Role: "user", Content: "second question"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildCascadeText(messages, "claude-sonnet-4", true, true)
|
||||||
|
if got != "second question" {
|
||||||
|
t.Fatalf("resume=true must return only last user message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectModelIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
messages []windsurf.ChatMessage
|
||||||
|
meta *windsurf.ModelMeta
|
||||||
|
modelKey string
|
||||||
|
wantInjected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "anthropic model without system",
|
||||||
|
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
|
||||||
|
meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"},
|
||||||
|
modelKey: "claude-sonnet-4.6",
|
||||||
|
wantInjected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "client already has system — skip injection",
|
||||||
|
messages: []windsurf.ChatMessage{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant"},
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
},
|
||||||
|
meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"},
|
||||||
|
modelKey: "claude-sonnet-4.6",
|
||||||
|
wantInjected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil meta — skip injection",
|
||||||
|
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
|
||||||
|
meta: nil,
|
||||||
|
modelKey: "unknown",
|
||||||
|
wantInjected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provider — skip injection",
|
||||||
|
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
|
||||||
|
meta: &windsurf.ModelMeta{Name: "some-model", Provider: "unknownvendor"},
|
||||||
|
modelKey: "some-model",
|
||||||
|
wantInjected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai model without system",
|
||||||
|
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
|
||||||
|
meta: &windsurf.ModelMeta{Name: "gpt-4o", Provider: "openai"},
|
||||||
|
modelKey: "gpt-4o",
|
||||||
|
wantInjected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := injectModelIdentity(tt.messages, tt.meta, tt.modelKey)
|
||||||
|
if tt.wantInjected {
|
||||||
|
if len(result) != len(tt.messages)+1 {
|
||||||
|
t.Fatalf("expected injection (len %d → %d), got len %d",
|
||||||
|
len(tt.messages), len(tt.messages)+1, len(result))
|
||||||
|
}
|
||||||
|
if result[0].Role != "system" {
|
||||||
|
t.Fatalf("injected message role = %q, want system", result[0].Role)
|
||||||
|
}
|
||||||
|
displayName := tt.meta.Name
|
||||||
|
if !strings.Contains(result[0].Content, displayName) {
|
||||||
|
t.Fatalf("injected content should contain model name %q, got %q",
|
||||||
|
displayName, result[0].Content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(result) != len(tt.messages) {
|
||||||
|
t.Fatalf("expected no injection (len %d), got len %d",
|
||||||
|
len(tt.messages), len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCascadeHistoryBudget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
modelUID string
|
||||||
|
switchover bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"normal model normal budget", "claude-sonnet-4", false, cascadeMaxHistoryBytes},
|
||||||
|
{"1m model normal budget", "claude-sonnet-4-1m", false, cascade1MHistoryBytes},
|
||||||
|
{"normal model switchover", "claude-sonnet-4", true, cascadeSwitchoverHistoryBytes},
|
||||||
|
{"1m model switchover", "claude-sonnet-4-1m", true, cascadeSwitchoverHistoryBytes},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := cascadeHistoryBudget(tt.modelUID, tt.switchover); got != tt.want {
|
||||||
|
t.Errorf("cascadeHistoryBudget(%q, %v) = %d, want %d",
|
||||||
|
tt.modelUID, tt.switchover, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,14 +81,15 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range req.Messages {
|
for mi, m := range req.Messages {
|
||||||
contentBlocks := windsurfParseContentBlocks(m.Content)
|
contentBlocks := windsurfParseContentBlocks(m.Content)
|
||||||
|
|
||||||
var toolResultMsgs []windsurf.AnthropicMessage
|
var toolResultMsgs []windsurf.AnthropicMessage
|
||||||
var toolUseMsgs []windsurf.OpenAIToolCall
|
var toolUseMsgs []windsurf.OpenAIToolCall
|
||||||
var textParts []string
|
var textParts []string
|
||||||
|
var turnImages []windsurf.CascadeImage
|
||||||
|
|
||||||
for _, block := range contentBlocks {
|
for bi, block := range contentBlocks {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
hasToolHistory = true
|
hasToolHistory = true
|
||||||
@ -102,6 +103,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
Content: contentJSON,
|
Content: contentJSON,
|
||||||
ToolCallID: block.ToolUseID,
|
ToolCallID: block.ToolUseID,
|
||||||
})
|
})
|
||||||
|
// tool_result 内部可能含 image 块;按规划策略,把它们提取出来归到当前 turn images
|
||||||
|
if extractedImgs, err := windsurfExtractImagesFromRaw(block.Content, fmt.Sprintf("messages[%d].content[%d].content", mi, bi)); err != nil {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
turnImages = append(turnImages, extractedImgs...)
|
||||||
|
}
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
hasToolHistory = true
|
hasToolHistory = true
|
||||||
inputJSON, _ := json.Marshal(block.Input)
|
inputJSON, _ := json.Marshal(block.Input)
|
||||||
@ -117,6 +125,21 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
textParts = append(textParts, block.Text)
|
textParts = append(textParts, block.Text)
|
||||||
case "thinking":
|
case "thinking":
|
||||||
// skip
|
// skip
|
||||||
|
case "image":
|
||||||
|
if block.Source == nil {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error",
|
||||||
|
fmt.Sprintf("messages[%d].content[%d].source is required for image blocks", mi, bi))
|
||||||
|
return nil, fmt.Errorf("image block missing source")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(block.Source.Type), "base64") {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error",
|
||||||
|
fmt.Sprintf("messages[%d].content[%d].source.type must be \"base64\"", mi, bi))
|
||||||
|
return nil, fmt.Errorf("unsupported image source type")
|
||||||
|
}
|
||||||
|
turnImages = append(turnImages, windsurf.CascadeImage{
|
||||||
|
MimeType: block.Source.MediaType,
|
||||||
|
Base64Data: block.Source.Data,
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
if block.Text != "" {
|
if block.Text != "" {
|
||||||
textParts = append(textParts, block.Text)
|
textParts = append(textParts, block.Text)
|
||||||
@ -130,8 +153,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: contentJSON,
|
Content: contentJSON,
|
||||||
ToolCalls: toolUseMsgs,
|
ToolCalls: toolUseMsgs,
|
||||||
|
Images: turnImages,
|
||||||
})
|
})
|
||||||
} else if len(toolResultMsgs) > 0 {
|
} else if len(toolResultMsgs) > 0 {
|
||||||
|
// tool_result 消息:图片挂到第一条 tool_result 上(保持对应关系大致正确)
|
||||||
|
if len(turnImages) > 0 && len(toolResultMsgs) > 0 {
|
||||||
|
toolResultMsgs[0].Images = turnImages
|
||||||
|
}
|
||||||
for _, tr := range toolResultMsgs {
|
for _, tr := range toolResultMsgs {
|
||||||
anthropicMsgs = append(anthropicMsgs, tr)
|
anthropicMsgs = append(anthropicMsgs, tr)
|
||||||
}
|
}
|
||||||
@ -141,6 +169,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: contentJSON,
|
Content: contentJSON,
|
||||||
|
Images: turnImages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,10 +194,38 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
chatMessages = append(chatMessages, windsurf.ChatMessage{
|
chatMessages = append(chatMessages, windsurf.ChatMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: text,
|
Content: text,
|
||||||
|
Images: m.Images,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取"当前 user turn"的图像作为 sidecar,发给 Cascade 的 images 字段。
|
||||||
|
// 策略:最后一个 role=="user" 的 message 的 Images。
|
||||||
|
var currentTurnImages []windsurf.CascadeImage
|
||||||
|
for i := len(chatMessages) - 1; i >= 0; i-- {
|
||||||
|
if chatMessages[i].Role == "user" && len(chatMessages[i].Images) > 0 {
|
||||||
|
currentTurnImages = chatMessages[i].Images
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地确定性校验(fail-fast 返回 Anthropic 风格 400)。
|
||||||
|
if len(currentTurnImages) > 0 {
|
||||||
|
if err := windsurf.ValidateCascadeImages(currentTurnImages, windsurf.DefaultCascadeImageValidationOptions()); err != nil {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 同步把 digests 写回 chat messages,便于后续指纹/日志
|
||||||
|
for i := range chatMessages {
|
||||||
|
if len(chatMessages[i].Images) > 0 {
|
||||||
|
chatMessages[i].ImageDigests = windsurf.BuildImageDigests(chatMessages[i].Images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reqLog.Info("windsurf_gateway.images",
|
||||||
|
zap.Int("current_turn_images", len(currentTurnImages)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
chatReq := &WindsurfChatRequest{
|
chatReq := &WindsurfChatRequest{
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
@ -176,6 +233,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
|||||||
Stream: req.Stream,
|
Stream: req.Stream,
|
||||||
Tools: openAITools,
|
Tools: openAITools,
|
||||||
ToolPreamble: toolPreamble,
|
ToolPreamble: toolPreamble,
|
||||||
|
Images: currentTurnImages,
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamStart := time.Now()
|
upstreamStart := time.Now()
|
||||||
@ -551,13 +609,22 @@ type windsurfRequestTool struct {
|
|||||||
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
|
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
|
||||||
|
|
||||||
type windsurfContentBlock struct {
|
type windsurfContentBlock struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Input interface{} `json:"input,omitempty"`
|
Input interface{} `json:"input,omitempty"`
|
||||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
Content json.RawMessage `json:"content,omitempty"`
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
// Source 来自 Anthropic image block:{type:"base64", media_type:"image/png", data:"..."}
|
||||||
|
Source *windsurfContentImageSource `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// windsurfContentImageSource 对应 Anthropic image content block 的 source 字段。
|
||||||
|
type windsurfContentImageSource struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func windsurfParseContentBlocks(raw json.RawMessage) []windsurfContentBlock {
|
func windsurfParseContentBlocks(raw json.RawMessage) []windsurfContentBlock {
|
||||||
@ -727,6 +794,41 @@ func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
|
|||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// windsurfExtractImagesFromRaw 从 tool_result 的 content 字段里提取 image 块,
|
||||||
|
// 返回可直接送进 Cascade 的 CascadeImage(未校验)。
|
||||||
|
// pathLabel 用于生成错误消息(Anthropic 风格 error message)。
|
||||||
|
func windsurfExtractImagesFromRaw(raw json.RawMessage, pathLabel string) ([]windsurf.CascadeImage, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// 纯字符串 content 没有图
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var blocks []windsurfContentBlock
|
||||||
|
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||||
|
return nil, nil // 不是 block 数组,按纯文本处理,没图
|
||||||
|
}
|
||||||
|
var out []windsurf.CascadeImage
|
||||||
|
for i, b := range blocks {
|
||||||
|
if b.Type != "image" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Source == nil {
|
||||||
|
return nil, fmt.Errorf("%s[%d].source is required for image blocks", pathLabel, i)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(b.Source.Type), "base64") {
|
||||||
|
return nil, fmt.Errorf("%s[%d].source.type must be \"base64\"", pathLabel, i)
|
||||||
|
}
|
||||||
|
out = append(out, windsurf.CascadeImage{
|
||||||
|
MimeType: b.Source.MediaType,
|
||||||
|
Base64Data: b.Source.Data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func windsurfLogger(c *gin.Context, component string, fields ...zap.Field) *zap.Logger {
|
func windsurfLogger(c *gin.Context, component string, fields ...zap.Field) *zap.Logger {
|
||||||
l := logger.L().With(zap.String("component", component))
|
l := logger.L().With(zap.String("component", component))
|
||||||
if c != nil {
|
if c != nil {
|
||||||
|
|||||||
@ -88,6 +88,15 @@ func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
|
|||||||
return s.connector.Status()
|
return s.connector.Status()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop terminates LS resources owned by this service (embedded LS processes,
|
||||||
|
// docker discovery goroutines). Safe to call on a nil receiver.
|
||||||
|
func (s *WindsurfLSService) Stop() {
|
||||||
|
if s == nil || s.connector == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.connector.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
type WindsurfAuthService struct {
|
type WindsurfAuthService struct {
|
||||||
cfg config.WindsurfConfig
|
cfg config.WindsurfConfig
|
||||||
authClient *windsurf.AuthClient
|
authClient *windsurf.AuthClient
|
||||||
|
|||||||
@ -109,14 +109,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="input-label">{{ t('admin.accounts.groups') }}</label>
|
<label class="input-label">{{ t('admin.accounts.groups') }}</label>
|
||||||
<select
|
<select v-model="commonOpts.group_id" class="input">
|
||||||
v-model="commonOpts.group_ids"
|
<option :value="null">{{ t('admin.accounts.ungroupedGroup') }}</option>
|
||||||
multiple
|
|
||||||
class="input"
|
|
||||||
style="min-height: 60px"
|
|
||||||
>
|
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">
|
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||||
{{ g.name }}
|
{{ formatGroupName(g.name) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -210,7 +206,7 @@ const batchText = ref('')
|
|||||||
|
|
||||||
const commonOpts = reactive({
|
const commonOpts = reactive({
|
||||||
proxy_id: null as number | null,
|
proxy_id: null as number | null,
|
||||||
group_ids: [] as number[],
|
group_id: null as number | null,
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
probe_after: true
|
probe_after: true
|
||||||
})
|
})
|
||||||
@ -218,7 +214,18 @@ const commonOpts = reactive({
|
|||||||
const batchResults = ref<WindsurfBatchLoginResult[]>([])
|
const batchResults = ref<WindsurfBatchLoginResult[]>([])
|
||||||
const batchSuccessCount = computed(() => batchResults.value.filter(r => r.success).length)
|
const batchSuccessCount = computed(() => batchResults.value.filter(r => r.success).length)
|
||||||
|
|
||||||
|
function formatGroupName(name: string) {
|
||||||
|
return name === 'default' ? t('admin.accounts.defaultGroup') : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedGroupIds() {
|
||||||
|
return commonOpts.group_id === null ? undefined : [commonOpts.group_id]
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
if (batchResults.value.length > 0) {
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
singleForm.email = ''
|
singleForm.email = ''
|
||||||
singleForm.password = ''
|
singleForm.password = ''
|
||||||
@ -235,7 +242,6 @@ async function handleSubmit() {
|
|||||||
} else {
|
} else {
|
||||||
await handleBatchLogin()
|
await handleBatchLogin()
|
||||||
}
|
}
|
||||||
emit('created')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
|
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@ -249,13 +255,14 @@ async function handleSingleLogin() {
|
|||||||
password: singleForm.password,
|
password: singleForm.password,
|
||||||
name: singleForm.name || singleForm.email,
|
name: singleForm.name || singleForm.email,
|
||||||
proxy_id: commonOpts.proxy_id,
|
proxy_id: commonOpts.proxy_id,
|
||||||
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
group_ids: selectedGroupIds(),
|
||||||
concurrency: commonOpts.concurrency,
|
concurrency: commonOpts.concurrency,
|
||||||
probe_after: commonOpts.probe_after
|
probe_after: commonOpts.probe_after
|
||||||
})
|
})
|
||||||
appStore.showSuccess(
|
appStore.showSuccess(
|
||||||
`${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})`
|
`${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})`
|
||||||
)
|
)
|
||||||
|
emit('created')
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,12 +272,15 @@ async function handleBatchLogin() {
|
|||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && l.includes('----'))
|
.filter(l => l.length > 0 && l.includes('----'))
|
||||||
|
|
||||||
if (items.length === 0) return
|
if (items.length === 0) {
|
||||||
|
appStore.showError(t('admin.windsurf.batchItemsRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await adminAPI.windsurf.batchLogin({
|
const resp = await adminAPI.windsurf.batchLogin({
|
||||||
items,
|
items,
|
||||||
proxy_id: commonOpts.proxy_id,
|
proxy_id: commonOpts.proxy_id,
|
||||||
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
group_ids: selectedGroupIds(),
|
||||||
concurrency: commonOpts.concurrency,
|
concurrency: commonOpts.concurrency,
|
||||||
probe_after: commonOpts.probe_after
|
probe_after: commonOpts.probe_after
|
||||||
})
|
})
|
||||||
|
|||||||
20
frontend/src/i18n/__tests__/windsurfLocales.spec.ts
Normal file
20
frontend/src/i18n/__tests__/windsurfLocales.spec.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import en from '../locales/en'
|
||||||
|
import zh from '../locales/zh'
|
||||||
|
|
||||||
|
describe('windsurf locale messages', () => {
|
||||||
|
it('escapes email placeholders so vue-i18n does not parse them as linked messages', () => {
|
||||||
|
expect(zh.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1")
|
||||||
|
expect(zh.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com')
|
||||||
|
expect(zh.admin.windsurf.batchItemsRequired).toContain('email----password')
|
||||||
|
expect(zh.admin.accounts.groups).toBe('分组')
|
||||||
|
expect(zh.admin.accounts.defaultGroup).toBe('默认分组')
|
||||||
|
|
||||||
|
expect(en.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1")
|
||||||
|
expect(en.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com')
|
||||||
|
expect(en.admin.windsurf.batchItemsRequired).toContain('email----password')
|
||||||
|
expect(en.admin.accounts.groups).toBe('Groups')
|
||||||
|
expect(en.admin.accounts.defaultGroup).toBe('Default Group')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -2513,6 +2513,8 @@ export default {
|
|||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
allGroups: 'All Groups',
|
allGroups: 'All Groups',
|
||||||
|
groups: 'Groups',
|
||||||
|
defaultGroup: 'Default Group',
|
||||||
ungroupedGroup: 'Ungrouped',
|
ungroupedGroup: 'Ungrouped',
|
||||||
oauthType: 'OAuth',
|
oauthType: 'OAuth',
|
||||||
setupToken: 'Setup Token',
|
setupToken: 'Setup Token',
|
||||||
@ -3379,6 +3381,21 @@ export default {
|
|||||||
imageTestMode: 'Mode: Image generation test',
|
imageTestMode: 'Mode: Image generation test',
|
||||||
imagePreview: 'Generated images:',
|
imagePreview: 'Generated images:',
|
||||||
imageReceived: 'Received test image #{count}',
|
imageReceived: 'Received test image #{count}',
|
||||||
|
customPromptLabel: 'Custom prompt (optional)',
|
||||||
|
customPromptPlaceholder: 'Leave empty to use the default prompt, or enter custom content here',
|
||||||
|
customPromptHint: 'Customize the test message to avoid fixed prompts being flagged as test traffic.',
|
||||||
|
geminiImagePromptLabel: 'Image prompt',
|
||||||
|
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
||||||
|
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||||
|
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||||
|
geminiImageTestMode: 'Mode: Gemini image generation test',
|
||||||
|
geminiImagePreview: 'Generated images:',
|
||||||
|
geminiImageReceived: 'Received test image #{count}',
|
||||||
|
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
|
||||||
|
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
|
||||||
|
soraTestTarget: 'Target: Sora account capability',
|
||||||
|
soraTestMode: 'Mode: Connectivity + Capability checks',
|
||||||
|
soraTestingFlow: 'Running Sora connectivity and capability checks...',
|
||||||
// Stats Modal
|
// Stats Modal
|
||||||
viewStats: 'View Stats',
|
viewStats: 'View Stats',
|
||||||
usageStatistics: 'Usage Statistics',
|
usageStatistics: 'Usage Statistics',
|
||||||
@ -5646,7 +5663,8 @@ export default {
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
batchItems: 'Batch Accounts',
|
batchItems: 'Batch Accounts',
|
||||||
batchItemsHint: 'One per line, format: email----password',
|
batchItemsHint: 'One per line, format: email----password',
|
||||||
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
|
batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2",
|
||||||
|
batchItemsRequired: 'Enter at least one valid account line in the format: email----password',
|
||||||
probeAfterLogin: 'Probe after login',
|
probeAfterLogin: 'Probe after login',
|
||||||
loginSuccess: 'Windsurf login successful',
|
loginSuccess: 'Windsurf login successful',
|
||||||
loginFailed: 'Windsurf login failed',
|
loginFailed: 'Windsurf login failed',
|
||||||
|
|||||||
@ -2592,6 +2592,8 @@ export default {
|
|||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
allGroups: '全部分组',
|
allGroups: '全部分组',
|
||||||
|
groups: '分组',
|
||||||
|
defaultGroup: '默认分组',
|
||||||
ungroupedGroup: '未分配分组',
|
ungroupedGroup: '未分配分组',
|
||||||
oauthType: 'OAuth',
|
oauthType: 'OAuth',
|
||||||
// Schedulable toggle
|
// Schedulable toggle
|
||||||
@ -3507,6 +3509,21 @@ export default {
|
|||||||
imageTestMode: '模式:生图测试',
|
imageTestMode: '模式:生图测试',
|
||||||
imagePreview: '生成结果:',
|
imagePreview: '生成结果:',
|
||||||
imageReceived: '已收到第 {count} 张测试图片',
|
imageReceived: '已收到第 {count} 张测试图片',
|
||||||
|
customPromptLabel: '自定义提示词(可选)',
|
||||||
|
customPromptPlaceholder: '留空则使用默认提示词,填写后将发送自定义内容',
|
||||||
|
customPromptHint: '自定义测试内容,避免使用固定提示词被识别为测试流量。',
|
||||||
|
geminiImagePromptLabel: '生图提示词',
|
||||||
|
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||||
|
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||||
|
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||||
|
geminiImageTestMode: '模式:Gemini 生图测试',
|
||||||
|
geminiImagePreview: '生成结果:',
|
||||||
|
geminiImageReceived: '已收到第 {count} 张测试图片',
|
||||||
|
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
|
||||||
|
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||||
|
soraTestTarget: '检测目标:Sora 账号能力',
|
||||||
|
soraTestMode: '模式:连通性 + 能力探测',
|
||||||
|
soraTestingFlow: '执行 Sora 连通性与能力检测...',
|
||||||
// Stats Modal
|
// Stats Modal
|
||||||
viewStats: '查看统计',
|
viewStats: '查看统计',
|
||||||
usageStatistics: '使用统计',
|
usageStatistics: '使用统计',
|
||||||
@ -5807,7 +5824,8 @@ export default {
|
|||||||
password: '密码',
|
password: '密码',
|
||||||
batchItems: '批量账号',
|
batchItems: '批量账号',
|
||||||
batchItemsHint: '每行一个,格式:email----password',
|
batchItemsHint: '每行一个,格式:email----password',
|
||||||
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
|
batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2",
|
||||||
|
batchItemsRequired: '请输入至少一行有效账号,格式:email----password',
|
||||||
probeAfterLogin: '登录后自动探测',
|
probeAfterLogin: '登录后自动探测',
|
||||||
loginSuccess: 'Windsurf 登录成功',
|
loginSuccess: 'Windsurf 登录成功',
|
||||||
loginFailed: 'Windsurf 登录失败',
|
loginFailed: 'Windsurf 登录失败',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user