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
|
||||
args: --timeout=30m
|
||||
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,
|
||||
windsurfRefresh *service.WindsurfRefreshService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
windsurfLS *service.WindsurfLSService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@ -253,6 +254,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"WindsurfLSService", func() error {
|
||||
if windsurfLS != nil {
|
||||
windsurfLS.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@ -273,7 +273,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
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{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@ -329,6 +329,7 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
windsurfRefresh *service.WindsurfRefreshService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
windsurfLS *service.WindsurfLSService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@ -78,6 +78,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // paymentOrderExpiry
|
||||
nil, // windsurfRefresh
|
||||
nil, // channelMonitorRunner
|
||||
nil, // windsurfLS
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@ -279,7 +279,7 @@ func main() {
|
||||
// SendUserCascadeMessage
|
||||
{
|
||||
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 != "" {
|
||||
cascadeID = newCID
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ func main() {
|
||||
fmt.Printf("✅ StartCascade cascade_id=%s\n", cascadeID)
|
||||
|
||||
// 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 {
|
||||
fmt.Fprintln(os.Stderr, "StreamCascadeChat:", err)
|
||||
os.Exit(1)
|
||||
@ -209,7 +209,7 @@ func main() {
|
||||
tc.ID, fakeResult)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), f.timeout)
|
||||
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 {
|
||||
fmt.Fprintln(os.Stderr, "\n❌ Turn2 StreamCascadeChat:", err)
|
||||
os.Exit(1)
|
||||
|
||||
@ -1852,10 +1852,10 @@ func setDefaults() {
|
||||
viper.SetDefault("windsurf.docker.discover_interval", "60s")
|
||||
viper.SetDefault("windsurf.docker.probe_interval", "30s")
|
||||
viper.SetDefault("windsurf.docker.probe_timeout", "3s")
|
||||
viper.SetDefault("windsurf.embedded.binary", "/opt/windsurf/language_server_linux_x64")
|
||||
viper.SetDefault("windsurf.embedded.base_port", 42100)
|
||||
viper.SetDefault("windsurf.embedded.data_dir", "/opt/windsurf/data")
|
||||
viper.SetDefault("windsurf.embedded.api_server_url", "https://server.self-serve.windsurf.com")
|
||||
viper.SetDefault("windsurf.embedded.binary", DefaultWindsurfEmbeddedBinary)
|
||||
viper.SetDefault("windsurf.embedded.base_port", DefaultWindsurfEmbeddedBasePort)
|
||||
viper.SetDefault("windsurf.embedded.data_dir", DefaultWindsurfEmbeddedDataDir)
|
||||
viper.SetDefault("windsurf.embedded.api_server_url", DefaultWindsurfEmbeddedAPIServerURL)
|
||||
viper.SetDefault("windsurf.refresh.enabled", true)
|
||||
viper.SetDefault("windsurf.refresh.token_scan_interval", "5m")
|
||||
viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m")
|
||||
|
||||
@ -94,10 +94,10 @@ func DefaultWindsurfConfig() WindsurfConfig {
|
||||
ProbeTimeout: 3 * time.Second,
|
||||
},
|
||||
Embedded: WindsurfEmbeddedConfig{
|
||||
Binary: "/opt/windsurf/language_server_linux_x64",
|
||||
BasePort: 42100,
|
||||
DataDir: "/opt/windsurf/data",
|
||||
APIServerURL: "https://server.self-serve.windsurf.com",
|
||||
Binary: DefaultWindsurfEmbeddedBinary,
|
||||
BasePort: DefaultWindsurfEmbeddedBasePort,
|
||||
DataDir: DefaultWindsurfEmbeddedDataDir,
|
||||
APIServerURL: DefaultWindsurfEmbeddedAPIServerURL,
|
||||
},
|
||||
External: WindsurfExternalConfig{},
|
||||
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 (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
@ -18,12 +19,36 @@ const (
|
||||
AppVersion = "1.48.2"
|
||||
ExtensionVersion = "1.9600.41"
|
||||
IDEVersion = ExtensionVersion
|
||||
RuntimeOS = "linux"
|
||||
HardwareArch = "x86_64"
|
||||
ClientVersion = "2.0.63"
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
func writeVarint(value uint64) []byte {
|
||||
|
||||
@ -11,6 +11,10 @@ type LSConnector interface {
|
||||
Acquire(ctx context.Context, proxyURL string) (*LSLease, error)
|
||||
Health(ctx context.Context) error
|
||||
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 {
|
||||
@ -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 {
|
||||
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 {
|
||||
baseURL string
|
||||
port int
|
||||
@ -156,3 +174,6 @@ func (x *ExternalConnector) Status() *LSConnectorStatus {
|
||||
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 {
|
||||
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 {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Images []canonicalImage `json:"images,omitempty"`
|
||||
}
|
||||
cans := make([]canonical, len(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)
|
||||
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 {
|
||||
Role string `json:"role"`
|
||||
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 {
|
||||
|
||||
@ -15,8 +15,10 @@ package windsurf
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@ -28,6 +30,9 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
pb "github.com/Wei-Shaw/sub2api/internal/gen/language_server_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -38,8 +43,18 @@ const (
|
||||
SendUserCascadeMessageRPC = "/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage"
|
||||
GetCascadeTrajectoryStepsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps"
|
||||
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).
|
||||
type LocalLSClient struct {
|
||||
BaseURL string
|
||||
@ -51,6 +66,10 @@ type LocalLSClient struct {
|
||||
// server-side repository context and relies on caller-provided tool results.
|
||||
TrackedWorkspace string
|
||||
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.
|
||||
@ -159,7 +178,19 @@ func (l *LocalLSClient) StartCascade(ctx context.Context, token string) (string,
|
||||
// SendUserCascadeMessage sends a message into an existing cascade session.
|
||||
// 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.
|
||||
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)
|
||||
if modelEnum == 0 && modelEnumHint > 0 {
|
||||
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(3, buildMetadata(token, l.SessionID))...)
|
||||
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)
|
||||
}
|
||||
|
||||
if err := doSend(cascadeID); err != nil {
|
||||
if isPanelStateNotFound(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
|
||||
if !isPanelStateNotFound(err) {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -200,9 +238,9 @@ func buildMetadata(token, sessionID string) []byte {
|
||||
meta = append(meta, encodeStringField(2, ExtensionVersion)...) // extension_version
|
||||
meta = append(meta, encodeStringField(3, token)...) // api_key
|
||||
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(8, HardwareArch)...) // hardware
|
||||
meta = append(meta, encodeStringField(8, HardwareArch())...) // hardware
|
||||
meta = append(meta, encodeVarintField(9, uint64(time.Now().UnixMilli()))...) // request_id
|
||||
meta = append(meta, encodeStringField(10, sessionID)...) // session_id
|
||||
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.
|
||||
// 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.
|
||||
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 {
|
||||
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 {
|
||||
return nil, fmt.Errorf("SendUserCascadeMessage: %w", err)
|
||||
}
|
||||
@ -1241,3 +1285,70 @@ func hasNonPrintable(s string) bool {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -36,8 +40,13 @@ type LSPoolConfig struct {
|
||||
|
||||
func (c *LSPoolConfig) defaults() {
|
||||
if c.Binary == "" {
|
||||
c.Binary = os.Getenv("LS_BINARY_PATH")
|
||||
if c.Binary == "" {
|
||||
// Try env override first, then platform-aware discovery, then legacy
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -54,7 +63,7 @@ func (c *LSPoolConfig) defaults() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return
|
||||
}
|
||||
_ = e.Cmd.Process.Signal(os.Interrupt)
|
||||
select {
|
||||
case <-e.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = e.Cmd.Process.Kill()
|
||||
<-e.done
|
||||
}
|
||||
terminateProcess(e.Cmd.Process, e.done)
|
||||
}
|
||||
|
||||
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 {
|
||||
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{
|
||||
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.
|
||||
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 != "" {
|
||||
cmd.Env = append(cmd.Env,
|
||||
"HTTPS_PROXY="+proxyURL,
|
||||
@ -328,15 +341,29 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
|
||||
)
|
||||
}
|
||||
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
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))
|
||||
|
||||
attachProcessGroup(cmd)
|
||||
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{
|
||||
Cmd: cmd,
|
||||
Port: port,
|
||||
@ -386,3 +413,47 @@ func (p *LSPool) monitorProcess(key string, entry *LSEntry) {
|
||||
}
|
||||
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"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
// Images 当前消息携带的图像块(仅 user/tool role 有效)。
|
||||
Images []CascadeImage `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeMessagesForCascade rewrites messages for Cascade compatibility:
|
||||
// - role:"tool" messages become user turns with <tool_result> wrappers
|
||||
// - assistant messages with tool_calls get rewritten to <tool_call> format
|
||||
// - tool preamble is injected into the last user message
|
||||
// - 保留 AnthropicMessage.Images 到输出的 ChatMessage.Images(tool role 时挂到 user turn)
|
||||
func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage {
|
||||
var out []ChatMessage
|
||||
|
||||
@ -327,6 +330,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
||||
out = append(out, ChatMessage{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("<tool_response>\n%s\n</tool_response>", content),
|
||||
Images: m.Images, // tool_result 里的图抬到 user turn
|
||||
})
|
||||
continue
|
||||
}
|
||||
@ -353,9 +357,11 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
||||
})
|
||||
parts = append(parts, "<tool_call>"+string(callJSON)+"</tool_call>")
|
||||
}
|
||||
// assistant turn 通常不带图,但为了健壮性仍保留(若上游真传了)
|
||||
out = append(out, ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: strings.Join(parts, "\n"),
|
||||
Images: m.Images,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@ -363,6 +369,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
|
||||
out = append(out, ChatMessage{
|
||||
Role: m.Role,
|
||||
Content: extractRawContentText(m.Content),
|
||||
Images: m.Images,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ const (
|
||||
defaultGeminiTextTestPrompt = "hi"
|
||||
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."
|
||||
defaultClaudeTestPrompt = "hi"
|
||||
)
|
||||
|
||||
// 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
|
||||
func createTestPayload(modelID string) (map[string]any, error) {
|
||||
func createTestPayload(modelID string, prompt string) (map[string]any, error) {
|
||||
sessionID, err := generateSessionString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := strings.TrimSpace(prompt)
|
||||
if text == "" {
|
||||
text = defaultClaudeTestPrompt
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"model": modelID,
|
||||
@ -140,7 +145,7 @@ func createTestPayload(modelID string) (map[string]any, error) {
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hi",
|
||||
"text": text,
|
||||
"cache_control": map[string]string{
|
||||
"type": "ephemeral",
|
||||
},
|
||||
@ -195,11 +200,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
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
|
||||
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()
|
||||
|
||||
// Determine the model to use
|
||||
@ -215,7 +220,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
|
||||
// Bedrock accounts use a separate test path
|
||||
if account.IsBedrock() {
|
||||
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
|
||||
return s.testBedrockAccountConnection(c, ctx, account, testModelID, prompt)
|
||||
}
|
||||
|
||||
// Determine authentication method and API URL
|
||||
@ -260,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
c.Writer.Flush()
|
||||
|
||||
// Create Claude Code style payload (same for all account types)
|
||||
payload, err := createTestPayload(testModelID)
|
||||
payload, err := createTestPayload(testModelID, prompt)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "Failed to create test payload")
|
||||
}
|
||||
@ -285,10 +290,10 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
|
||||
// Set authentication header
|
||||
if useBearer {
|
||||
req.Header.Set("anthropic-beta", claude.DefaultBetaHeader)
|
||||
req.Header.Set("anthropic-beta", claude.GetOAuthBetaHeader(testModelID))
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
} else {
|
||||
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
||||
req.Header.Set("anthropic-beta", claude.GetAPIKeyBetaHeader(testModelID))
|
||||
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
|
||||
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)
|
||||
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
|
||||
if !ok {
|
||||
@ -337,6 +342,10 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
c.Writer.Flush()
|
||||
|
||||
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
|
||||
bedrockText := strings.TrimSpace(prompt)
|
||||
if bedrockText == "" {
|
||||
bedrockText = defaultClaudeTestPrompt
|
||||
}
|
||||
bedrockPayload := map[string]any{
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
"messages": []map[string]any{
|
||||
@ -345,7 +354,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hi",
|
||||
"text": bedrockText,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -501,7 +510,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
c.Writer.Flush()
|
||||
|
||||
// Create OpenAI Responses API payload
|
||||
payload := createOpenAITestPayload(testModelID, isOAuth)
|
||||
payload := createOpenAITestPayload(testModelID, isOAuth, prompt)
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
// Send test_start event
|
||||
@ -636,7 +645,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
|
||||
if strings.HasPrefix(modelID, "gemini-") {
|
||||
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)
|
||||
}
|
||||
@ -955,7 +964,11 @@ func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader)
|
||||
}
|
||||
|
||||
// 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{
|
||||
"model": modelID,
|
||||
"input": []map[string]any{
|
||||
@ -964,7 +977,7 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"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")
|
||||
}
|
||||
modelID := openaipkg.DefaultTestModel
|
||||
payload := createOpenAITestPayload(modelID, true)
|
||||
payload := createOpenAITestPayload(modelID, true, "")
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal openai probe payload: %w", err)
|
||||
|
||||
@ -39,6 +39,9 @@ type WindsurfChatRequest struct {
|
||||
Tools []windsurf.OpenAITool
|
||||
ToolChoice interface{}
|
||||
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 {
|
||||
@ -80,11 +83,11 @@ func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest
|
||||
var resp *WindsurfChatResponse
|
||||
switch mode {
|
||||
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":
|
||||
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
|
||||
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 {
|
||||
@ -111,7 +114,39 @@ func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string {
|
||||
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 := ""
|
||||
modelEnumHint := 0
|
||||
if meta != nil {
|
||||
@ -119,12 +154,36 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.
|
||||
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)
|
||||
// failover 切号后禁止复用 cascade:cascade_id 属于上一个账号的 LS,
|
||||
// 在当前账号上一定会触发 "panel state not found" 浪费一次请求。
|
||||
// 同时切号场景下需要提升历史预算——新账号完全没有服务端上下文,
|
||||
// 必须把完整聊天记录塞进文本里。
|
||||
skipReuse := false
|
||||
switchover := false
|
||||
if switches, ok := AccountSwitchCountFromContext(ctx); ok && switches > 0 {
|
||||
skipReuse = true
|
||||
switchover = true
|
||||
}
|
||||
var entry *windsurf.ConversationEntry
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
slog.Warn("windsurf_cascade_reuse_failed", "error", err, "model", modelKey)
|
||||
userText = buildCascadeText(messages, modelUID, false)
|
||||
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint)
|
||||
// panel-state-not-found 恢复:新 cascade 没有服务端历史,必须发完整聊天记录。
|
||||
userText = buildCascadeText(messages, modelUID, false, true)
|
||||
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -189,12 +249,19 @@ func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.L
|
||||
}
|
||||
|
||||
const (
|
||||
cascadeMaxHistoryBytes = 200_000
|
||||
cascade1MHistoryBytes = 900_000
|
||||
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
|
||||
cascadeMaxHistoryBytes = 200_000
|
||||
cascade1MHistoryBytes = 900_000
|
||||
// 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") {
|
||||
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).
|
||||
// Otherwise: system prompt wrapped in <system_instructions>, multi-turn history
|
||||
// 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 convo []windsurf.ChatMessage
|
||||
|
||||
@ -241,11 +312,12 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume
|
||||
}
|
||||
|
||||
// Multi-turn: build history with budget trimming
|
||||
maxBytes := cascadeHistoryBudget(modelUID)
|
||||
maxBytes := cascadeHistoryBudget(modelUID, switchover)
|
||||
historyBytes := len(sysText)
|
||||
|
||||
// Walk backward from second-to-last, collecting turns that fit
|
||||
var lines []string
|
||||
droppedTurns := 0
|
||||
for i := len(convo) - 2; i >= 0; i-- {
|
||||
m := convo[i]
|
||||
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)
|
||||
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
|
||||
droppedTurns = i + 1
|
||||
slog.Info("windsurf_cascade_history_trimmed",
|
||||
"turn", i,
|
||||
"total_turns", len(convo),
|
||||
"kept_kb", historyBytes/1024,
|
||||
"dropped_turns", droppedTurns,
|
||||
"switchover", switchover,
|
||||
)
|
||||
break
|
||||
}
|
||||
lines = append([]string{line}, lines...)
|
||||
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]
|
||||
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)
|
||||
|
||||
var toolResultMsgs []windsurf.AnthropicMessage
|
||||
var toolUseMsgs []windsurf.OpenAIToolCall
|
||||
var textParts []string
|
||||
var turnImages []windsurf.CascadeImage
|
||||
|
||||
for _, block := range contentBlocks {
|
||||
for bi, block := range contentBlocks {
|
||||
switch block.Type {
|
||||
case "tool_result":
|
||||
hasToolHistory = true
|
||||
@ -102,6 +103,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
||||
Content: contentJSON,
|
||||
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":
|
||||
hasToolHistory = true
|
||||
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)
|
||||
case "thinking":
|
||||
// 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:
|
||||
if 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,
|
||||
Content: contentJSON,
|
||||
ToolCalls: toolUseMsgs,
|
||||
Images: turnImages,
|
||||
})
|
||||
} else if len(toolResultMsgs) > 0 {
|
||||
// tool_result 消息:图片挂到第一条 tool_result 上(保持对应关系大致正确)
|
||||
if len(turnImages) > 0 && len(toolResultMsgs) > 0 {
|
||||
toolResultMsgs[0].Images = turnImages
|
||||
}
|
||||
for _, tr := range toolResultMsgs {
|
||||
anthropicMsgs = append(anthropicMsgs, tr)
|
||||
}
|
||||
@ -141,6 +169,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
||||
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||||
Role: m.Role,
|
||||
Content: contentJSON,
|
||||
Images: turnImages,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -165,10 +194,38 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
||||
chatMessages = append(chatMessages, windsurf.ChatMessage{
|
||||
Role: m.Role,
|
||||
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{
|
||||
AccountID: account.ID,
|
||||
Model: req.Model,
|
||||
@ -176,6 +233,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
|
||||
Stream: req.Stream,
|
||||
Tools: openAITools,
|
||||
ToolPreamble: toolPreamble,
|
||||
Images: currentTurnImages,
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
@ -551,13 +609,22 @@ type windsurfRequestTool struct {
|
||||
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
|
||||
|
||||
type windsurfContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,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 {
|
||||
@ -727,6 +794,41 @@ func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
|
||||
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 {
|
||||
l := logger.L().With(zap.String("component", component))
|
||||
if c != nil {
|
||||
|
||||
@ -88,6 +88,15 @@ func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
|
||||
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 {
|
||||
cfg config.WindsurfConfig
|
||||
authClient *windsurf.AuthClient
|
||||
|
||||
@ -109,14 +109,10 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="input-label">{{ t('admin.accounts.groups') }}</label>
|
||||
<select
|
||||
v-model="commonOpts.group_ids"
|
||||
multiple
|
||||
class="input"
|
||||
style="min-height: 60px"
|
||||
>
|
||||
<select v-model="commonOpts.group_id" class="input">
|
||||
<option :value="null">{{ t('admin.accounts.ungroupedGroup') }}</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||
{{ g.name }}
|
||||
{{ formatGroupName(g.name) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -210,7 +206,7 @@ const batchText = ref('')
|
||||
|
||||
const commonOpts = reactive({
|
||||
proxy_id: null as number | null,
|
||||
group_ids: [] as number[],
|
||||
group_id: null as number | null,
|
||||
concurrency: 1,
|
||||
probe_after: true
|
||||
})
|
||||
@ -218,7 +214,18 @@ const commonOpts = reactive({
|
||||
const batchResults = ref<WindsurfBatchLoginResult[]>([])
|
||||
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() {
|
||||
if (batchResults.value.length > 0) {
|
||||
emit('created')
|
||||
}
|
||||
emit('close')
|
||||
singleForm.email = ''
|
||||
singleForm.password = ''
|
||||
@ -235,7 +242,6 @@ async function handleSubmit() {
|
||||
} else {
|
||||
await handleBatchLogin()
|
||||
}
|
||||
emit('created')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
|
||||
} finally {
|
||||
@ -249,13 +255,14 @@ async function handleSingleLogin() {
|
||||
password: singleForm.password,
|
||||
name: singleForm.name || singleForm.email,
|
||||
proxy_id: commonOpts.proxy_id,
|
||||
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
||||
group_ids: selectedGroupIds(),
|
||||
concurrency: commonOpts.concurrency,
|
||||
probe_after: commonOpts.probe_after
|
||||
})
|
||||
appStore.showSuccess(
|
||||
`${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})`
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
}
|
||||
|
||||
@ -265,12 +272,15 @@ async function handleBatchLogin() {
|
||||
.map(l => l.trim())
|
||||
.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({
|
||||
items,
|
||||
proxy_id: commonOpts.proxy_id,
|
||||
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
||||
group_ids: selectedGroupIds(),
|
||||
concurrency: commonOpts.concurrency,
|
||||
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',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
groups: 'Groups',
|
||||
defaultGroup: 'Default Group',
|
||||
ungroupedGroup: 'Ungrouped',
|
||||
oauthType: 'OAuth',
|
||||
setupToken: 'Setup Token',
|
||||
@ -3379,6 +3381,21 @@ export default {
|
||||
imageTestMode: 'Mode: Image generation test',
|
||||
imagePreview: 'Generated images:',
|
||||
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
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
@ -5646,7 +5663,8 @@ export default {
|
||||
password: 'Password',
|
||||
batchItems: 'Batch Accounts',
|
||||
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',
|
||||
loginSuccess: 'Windsurf login successful',
|
||||
loginFailed: 'Windsurf login failed',
|
||||
|
||||
@ -2592,6 +2592,8 @@ export default {
|
||||
allTypes: '全部类型',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
groups: '分组',
|
||||
defaultGroup: '默认分组',
|
||||
ungroupedGroup: '未分配分组',
|
||||
oauthType: 'OAuth',
|
||||
// Schedulable toggle
|
||||
@ -3507,6 +3509,21 @@ export default {
|
||||
imageTestMode: '模式:生图测试',
|
||||
imagePreview: '生成结果:',
|
||||
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
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
@ -5807,7 +5824,8 @@ export default {
|
||||
password: '密码',
|
||||
batchItems: '批量账号',
|
||||
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: '登录后自动探测',
|
||||
loginSuccess: 'Windsurf 登录成功',
|
||||
loginFailed: 'Windsurf 登录失败',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user