mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 11:37:20 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb24bb4d4 | ||
|
|
426bdd6592 | ||
|
|
fa36dc2c60 | ||
|
|
4e649d676f |
@@ -1134,10 +1134,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。
|
||||||
|
// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek
|
||||||
|
// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的
|
||||||
|
// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。
|
||||||
|
type anthropicThinkingCarrier struct {
|
||||||
|
reasoning string
|
||||||
|
signature string
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
|
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
|
||||||
systemParts := make([]string, 0, len(input))
|
systemParts := make([]string, 0, len(input))
|
||||||
messages := make([]anthropicMessage, 0, len(input))
|
messages := make([]anthropicMessage, 0, len(input))
|
||||||
pendingToolResults := make([]map[string]any, 0, 2)
|
pendingToolResults := make([]map[string]any, 0, 2)
|
||||||
|
var thinkingCarrier *anthropicThinkingCarrier
|
||||||
flushToolResults := func() {
|
flushToolResults := func() {
|
||||||
if len(pendingToolResults) == 0 {
|
if len(pendingToolResults) == 0 {
|
||||||
return
|
return
|
||||||
@@ -1175,7 +1185,15 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
|
|||||||
})
|
})
|
||||||
case "user", "assistant":
|
case "user", "assistant":
|
||||||
flushToolResults()
|
flushToolResults()
|
||||||
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled)
|
if thinkingEnabled && role == "assistant" {
|
||||||
|
if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" {
|
||||||
|
thinkingCarrier = &anthropicThinkingCarrier{
|
||||||
|
reasoning: reasoning,
|
||||||
|
signature: anthropicThinkingSignature(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1302,7 @@ func isAnthropicImageBlock(block map[string]any) bool {
|
|||||||
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
|
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
|
||||||
}
|
}
|
||||||
|
|
||||||
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) {
|
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
|
||||||
blocks, err := anthropicContentBlocks(message)
|
blocks, err := anthropicContentBlocks(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1293,11 +1311,17 @@ func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]ma
|
|||||||
return blocks, nil
|
return blocks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reasoning := strings.TrimSpace(message.ReasoningContent)
|
||||||
|
signature := anthropicThinkingSignature(message)
|
||||||
|
if reasoning == "" && carrier != nil {
|
||||||
|
reasoning = carrier.reasoning
|
||||||
|
signature = carrier.signature
|
||||||
|
}
|
||||||
thinkingBlock := map[string]any{
|
thinkingBlock := map[string]any{
|
||||||
"type": "thinking",
|
"type": "thinking",
|
||||||
"thinking": message.ReasoningContent,
|
"thinking": reasoning,
|
||||||
}
|
}
|
||||||
if signature := anthropicThinkingSignature(message); signature != "" {
|
if signature != "" {
|
||||||
thinkingBlock["signature"] = signature
|
thinkingBlock["signature"] = signature
|
||||||
}
|
}
|
||||||
return append([]map[string]any{thinkingBlock}, blocks...), nil
|
return append([]map[string]any{thinkingBlock}, blocks...), nil
|
||||||
@@ -1381,9 +1405,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool)
|
|||||||
if strings.TrimSpace(message.Role) != "assistant" {
|
if strings.TrimSpace(message.Role) != "assistant" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(message.ReasoningContent) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package modeladapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNormalizeAnthropicProviderMessagesThinkingCarrier 验证 thinking 模式下,
|
||||||
|
// 缺少 reasoning 的 assistant 轮次(如 DeepSeek adaptive thinking 跳过思考的
|
||||||
|
// tool-call 轮次)会用请求内最近一个 carrier 的 thinking+signature 兜底,
|
||||||
|
// 保证每个 assistant 轮次都有 thinking 块,避免上游 "thinking must be passed
|
||||||
|
// back to the API" 400。
|
||||||
|
func TestNormalizeAnthropicProviderMessagesThinkingCarrier(t *testing.T) {
|
||||||
|
carrierToolCall := []ToolCallDescriptor{{
|
||||||
|
ID: "call-2",
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunctionShape{
|
||||||
|
Name: "read",
|
||||||
|
Arguments: `{}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
input := []Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||||
|
{Role: "user", Content: "tool result 1"},
|
||||||
|
{Role: "assistant", ToolCalls: carrierToolCall}, // 无 reasoning → 用 carrier
|
||||||
|
{Role: "user", Content: "tool result 2"},
|
||||||
|
{Role: "assistant", Content: "done", ReasoningContent: "R2", ReasoningSignature: "S2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 6 {
|
||||||
|
t.Fatalf("expected 6 messages, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 条(有 reasoning)应保留自己的 thinking。
|
||||||
|
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
|
||||||
|
// 第 4 条(无 reasoning 的 tool-call 轮次)应复用 carrier 的 thinking+signature。
|
||||||
|
assertAnthropicThinkingBlock(t, messages[3], "R1", "S1")
|
||||||
|
// 第 5 条应为 tool_result 消息(合并路径不适用时,tool-call 轮次独立成消息)。
|
||||||
|
if role := messages[4].Role; role != "user" {
|
||||||
|
t.Fatalf("expected messages[4] role=user, got %s", role)
|
||||||
|
}
|
||||||
|
// 第 6 条有自己的 thinking。
|
||||||
|
assertAnthropicThinkingBlock(t, messages[5], "R2", "S2")
|
||||||
|
|
||||||
|
// tool-call 轮次应包含 tool_use 块。
|
||||||
|
hasToolUse := false
|
||||||
|
for _, block := range messages[3].Content {
|
||||||
|
if strings.TrimSpace(anthropicStringField(block, "type")) == "tool_use" {
|
||||||
|
hasToolUse = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasToolUse {
|
||||||
|
t.Fatal("expected tool_use block on the carrier-fallback assistant message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn 验证请求内第一条
|
||||||
|
// assistant 轮次就缺 reasoning 且无 carrier 时,兜底输出空 thinking 块。
|
||||||
|
func TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn(t *testing.T) {
|
||||||
|
input := []Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "ok"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||||
|
}
|
||||||
|
if got := anthropicStringField(messages[1].Content[0], "type"); got != "thinking" {
|
||||||
|
t.Fatalf("expected first block type=thinking, got %s", got)
|
||||||
|
}
|
||||||
|
if got := anthropicStringField(messages[1].Content[0], "thinking"); got != "" {
|
||||||
|
t.Fatalf("expected empty fallback thinking, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAnthropicProviderMessagesThinkingDisabled 验证 thinking 关闭时
|
||||||
|
// 不输出任何 thinking 块(回归保护)。
|
||||||
|
func TestNormalizeAnthropicProviderMessagesThinkingDisabled(t *testing.T) {
|
||||||
|
input := []Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "ok", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||||
|
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
|
||||||
|
ID: "call-2",
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunctionShape{
|
||||||
|
Name: "read",
|
||||||
|
Arguments: `{}`,
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, messages, err := normalizeAnthropicProviderMessages(input, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
for index, message := range messages {
|
||||||
|
for _, block := range message.Content {
|
||||||
|
if blockType := anthropicStringField(block, "type"); blockType == "thinking" {
|
||||||
|
t.Fatalf("unexpected thinking block at messages[%d]", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAnthropicProviderMessagesThinkingMerge 验证有 reasoning 的纯
|
||||||
|
// tool-call 轮次仍按既有逻辑合并进上一条 assistant 消息(thinking 去重,无回归)。
|
||||||
|
func TestNormalizeAnthropicProviderMessagesThinkingMerge(t *testing.T) {
|
||||||
|
input := []Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||||
|
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
|
||||||
|
ID: "call-2",
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunctionShape{
|
||||||
|
Name: "read",
|
||||||
|
Arguments: `{}`,
|
||||||
|
},
|
||||||
|
}}, ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages (tool-call merged), got %d", len(messages))
|
||||||
|
}
|
||||||
|
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
|
||||||
|
hasToolUse := false
|
||||||
|
for _, block := range messages[1].Content {
|
||||||
|
if blockType := anthropicStringField(block, "type"); blockType == "tool_use" {
|
||||||
|
hasToolUse = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasToolUse {
|
||||||
|
t.Fatal("expected merged tool_use block on messages[1]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAnthropicThinkingBlock(t *testing.T, message anthropicMessage, wantThinking string, wantSignature string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(message.Content) == 0 {
|
||||||
|
t.Fatalf("expected non-empty content for %s message", message.Role)
|
||||||
|
}
|
||||||
|
first := message.Content[0]
|
||||||
|
if blockType := anthropicStringField(first, "type"); blockType != "thinking" {
|
||||||
|
t.Fatalf("expected first block type=thinking, got %s", blockType)
|
||||||
|
}
|
||||||
|
if got := anthropicStringField(first, "thinking"); got != wantThinking {
|
||||||
|
t.Fatalf("expected thinking=%q, got %q", wantThinking, got)
|
||||||
|
}
|
||||||
|
if got := anthropicStringField(first, "signature"); got != wantSignature {
|
||||||
|
t.Fatalf("expected signature=%q, got %q", wantSignature, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,6 +201,41 @@ func buildShellOutputDeltaMessage(delta *agentv1.ShellOutputDeltaUpdate) *agentv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildShellToolCallDeltaMessage maps client shell output to the delta consumed by Cursor's terminal bubble.
|
||||||
|
func buildShellToolCallDeltaMessage(callID string, modelCallID string, output *agentv1.ShellOutputDeltaUpdate) *agentv1.AgentServerMessage {
|
||||||
|
if output == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var delta *agentv1.ShellToolCallDelta
|
||||||
|
switch event := output.GetEvent().(type) {
|
||||||
|
case *agentv1.ShellOutputDeltaUpdate_Stdout:
|
||||||
|
content := event.Stdout.GetData()
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delta = &agentv1.ShellToolCallDelta{
|
||||||
|
Delta: &agentv1.ShellToolCallDelta_Stdout{
|
||||||
|
Stdout: &agentv1.ShellToolCallStdoutDelta{Content: content},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case *agentv1.ShellOutputDeltaUpdate_Stderr:
|
||||||
|
content := event.Stderr.GetData()
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delta = &agentv1.ShellToolCallDelta{
|
||||||
|
Delta: &agentv1.ShellToolCallDelta_Stderr{
|
||||||
|
Stderr: &agentv1.ShellToolCallStderrDelta{Content: content},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return buildToolCallDeltaMessage(callID, modelCallID, &agentv1.ToolCallDelta{
|
||||||
|
Delta: &agentv1.ToolCallDelta_ShellToolCallDelta{ShellToolCallDelta: delta},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// buildTurnEndedMessage 构造 turn 结束消息,并携带标准化后的 token 统计。
|
// buildTurnEndedMessage 构造 turn 结束消息,并携带标准化后的 token 统计。
|
||||||
func buildTurnEndedMessage(inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64) *agentv1.AgentServerMessage {
|
func buildTurnEndedMessage(inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64) *agentv1.AgentServerMessage {
|
||||||
inputTokensValue := inputTokens
|
inputTokensValue := inputTokens
|
||||||
|
|||||||
@@ -1003,6 +1003,11 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if message := buildShellToolCallDeltaMessage(pending.ToolCallID, pending.ModelCallID, result.ShellOutputDelta); message != nil {
|
||||||
|
if err := service.broker.Publish(intent.RequestID, StreamEvent{Message: message}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !result.IsTerminal {
|
if !result.IsTerminal {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
execbridge "cursor/internal/backend/agent/bridge/exec"
|
||||||
|
runtimecore "cursor/internal/backend/agent/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleExecResultPublishesShellToolCallDelta(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
shellStream func() *agentv1.ShellStream
|
||||||
|
wantStdout string
|
||||||
|
wantStderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "stdout",
|
||||||
|
shellStream: func() *agentv1.ShellStream {
|
||||||
|
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stdout{
|
||||||
|
Stdout: &agentv1.ShellStreamStdout{Data: "stdout chunk\n"},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
wantStdout: "stdout chunk\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stderr",
|
||||||
|
shellStream: func() *agentv1.ShellStream {
|
||||||
|
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stderr{
|
||||||
|
Stderr: &agentv1.ShellStreamStderr{Data: "stderr chunk\n"},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
wantStderr: "stderr chunk\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
broker := NewStreamBroker()
|
||||||
|
service := &Service{
|
||||||
|
broker: broker,
|
||||||
|
execBridge: execbridge.NewBridge(),
|
||||||
|
}
|
||||||
|
stream, err := broker.OpenStream(
|
||||||
|
"request-1", "conversation-1", 1, "default", "default",
|
||||||
|
agentv1.AgentMode_AGENT_MODE_AGENT, "run command",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenStream() error = %v", err)
|
||||||
|
}
|
||||||
|
pending := runtimecore.PendingExec{
|
||||||
|
MessageID: 42,
|
||||||
|
ExecID: "exec-shell-1",
|
||||||
|
ModelCallID: "model-call-1",
|
||||||
|
ToolCallID: "tool-call-1",
|
||||||
|
ExecKind: "shell",
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.PendingExecs[pending.ExecID] = pending
|
||||||
|
stream.mu.Unlock()
|
||||||
|
|
||||||
|
if err := service.handleExecResult(InboundIntent{
|
||||||
|
Kind: "exec_result",
|
||||||
|
RequestID: "request-1",
|
||||||
|
ExecClientMessage: &agentv1.ExecClientMessage{
|
||||||
|
Id: pending.MessageID,
|
||||||
|
ExecId: pending.ExecID,
|
||||||
|
Message: &agentv1.ExecClientMessage_ShellStream{
|
||||||
|
ShellStream: test.shellStream(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleExecResult() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := broker.ReadFromCursor("request-1", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("published events = %d, want compatibility and tool-call deltas", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
var compatibilityCount, toolCallDeltaCount int
|
||||||
|
for _, event := range events {
|
||||||
|
update := event.Message.GetInteractionUpdate()
|
||||||
|
if update.GetShellOutputDelta() != nil {
|
||||||
|
compatibilityCount++
|
||||||
|
}
|
||||||
|
deltaUpdate := update.GetToolCallDelta()
|
||||||
|
if deltaUpdate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toolCallDeltaCount++
|
||||||
|
if deltaUpdate.GetCallId() != pending.ToolCallID || deltaUpdate.GetModelCallId() != pending.ModelCallID {
|
||||||
|
t.Fatalf("tool-call delta ids = call %q model %q", deltaUpdate.GetCallId(), deltaUpdate.GetModelCallId())
|
||||||
|
}
|
||||||
|
shellDelta := deltaUpdate.GetToolCallDelta().GetShellToolCallDelta()
|
||||||
|
if shellDelta == nil || shellDelta.GetStdout().GetContent() != test.wantStdout || shellDelta.GetStderr().GetContent() != test.wantStderr {
|
||||||
|
t.Fatalf("shell tool-call delta = %#v", shellDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if compatibilityCount != 1 || toolCallDeltaCount != 1 {
|
||||||
|
t.Fatalf("published compatibility=%d tool_call_delta=%d, want one each", compatibilityCount, toolCallDeltaCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildShellToolCallDeltaMessageIgnoresNonOutputEvents(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output *agentv1.ShellOutputDeltaUpdate
|
||||||
|
}{
|
||||||
|
{name: "nil"},
|
||||||
|
{
|
||||||
|
name: "start",
|
||||||
|
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Start{
|
||||||
|
Start: &agentv1.ShellStreamStart{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exit",
|
||||||
|
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Exit{
|
||||||
|
Exit: &agentv1.ShellStreamExit{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty stdout",
|
||||||
|
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stdout{
|
||||||
|
Stdout: &agentv1.ShellStreamStdout{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty stderr",
|
||||||
|
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stderr{
|
||||||
|
Stderr: &agentv1.ShellStreamStderr{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if message := buildShellToolCallDeltaMessage("tool-call-1", "model-call-1", test.output); message != nil {
|
||||||
|
t.Fatalf("buildShellToolCallDeltaMessage() = %#v, want nil", message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ var bootstrapStatsigTemplate = statsigBootstrapTemplate{
|
|||||||
bootstrapStatsigGlassCustomThemeSupport: buildEnabledStatsigGate(bootstrapStatsigGlassCustomThemeSupport),
|
bootstrapStatsigGlassCustomThemeSupport: buildEnabledStatsigGate(bootstrapStatsigGlassCustomThemeSupport),
|
||||||
bootstrapStatsigGlassAutomationsUI: buildEnabledStatsigGate(bootstrapStatsigGlassAutomationsUI),
|
bootstrapStatsigGlassAutomationsUI: buildEnabledStatsigGate(bootstrapStatsigGlassAutomationsUI),
|
||||||
bootstrapStatsigTerminalUI2: buildEnabledStatsigGate(bootstrapStatsigTerminalUI2),
|
bootstrapStatsigTerminalUI2: buildEnabledStatsigGate(bootstrapStatsigTerminalUI2),
|
||||||
bootstrapStatsigDisableTerminalOutputUIStreaming: buildEnabledStatsigGate(bootstrapStatsigDisableTerminalOutputUIStreaming),
|
bootstrapStatsigDisableTerminalOutputUIStreaming: buildDisabledStatsigGate(bootstrapStatsigDisableTerminalOutputUIStreaming),
|
||||||
bootstrapStatsigBrowserCanvas: buildEnabledStatsigGate(bootstrapStatsigBrowserCanvas),
|
bootstrapStatsigBrowserCanvas: buildEnabledStatsigGate(bootstrapStatsigBrowserCanvas),
|
||||||
bootstrapStatsigEnableMultitaskMode: buildEnabledStatsigGate(bootstrapStatsigEnableMultitaskMode),
|
bootstrapStatsigEnableMultitaskMode: buildEnabledStatsigGate(bootstrapStatsigEnableMultitaskMode),
|
||||||
bootstrapStatsigDecomposeAlwaysLocalExtHostGate: buildDisabledStatsigGate(bootstrapStatsigDecomposeAlwaysLocalExtHostGate),
|
bootstrapStatsigDecomposeAlwaysLocalExtHostGate: buildDisabledStatsigGate(bootstrapStatsigDecomposeAlwaysLocalExtHostGate),
|
||||||
|
|||||||
@@ -73,3 +73,26 @@ func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *
|
|||||||
t.Fatalf("unexpected rule_id: %q", ruleID)
|
t.Fatalf("unexpected rule_id: %q", ruleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildBootstrapStatsigConfigJSONEnablesTerminalOutputUIStreaming(t *testing.T) {
|
||||||
|
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build bootstrap statsig config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded statsigBootstrapTemplate
|
||||||
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode bootstrap statsig config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gate, ok := decoded.FeatureGates[bootstrapStatsigDisableTerminalOutputUIStreaming]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing feature gate %q", bootstrapStatsigDisableTerminalOutputUIStreaming)
|
||||||
|
}
|
||||||
|
if value, _ := gate["value"].(bool); value {
|
||||||
|
t.Fatalf("expected %q to be disabled", bootstrapStatsigDisableTerminalOutputUIStreaming)
|
||||||
|
}
|
||||||
|
if ruleID, _ := gate["rule_id"].(string); ruleID != "local_disabled" {
|
||||||
|
t.Fatalf("unexpected rule_id: %q", ruleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const (
|
|||||||
var cursorStateDisabledStatsigGates = []string{
|
var cursorStateDisabledStatsigGates = []string{
|
||||||
"decompose_always_local_ext_host",
|
"decompose_always_local_ext_host",
|
||||||
"cursor_extensions_isolation_v2",
|
"cursor_extensions_isolation_v2",
|
||||||
|
"disable_terminal_output_ui_streaming",
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
|
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package cursor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncCursorAuthStateDBDisablesCachedTerminalOutputUIStreamingIdempotently(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.vscdb")
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open temporary state db: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
t.Fatalf("create ItemTable: %v", err)
|
||||||
|
}
|
||||||
|
bootstrap := map[string]any{
|
||||||
|
"feature_gates": map[string]any{
|
||||||
|
"disable_terminal_output_ui_streaming": map[string]any{
|
||||||
|
"value": true,
|
||||||
|
"rule_id": "local_enabled",
|
||||||
|
"groupName": "local_enabled",
|
||||||
|
},
|
||||||
|
"unrelated_gate": map[string]any{"value": true},
|
||||||
|
},
|
||||||
|
"hash_used": "none",
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(bootstrap)
|
||||||
|
if err != nil {
|
||||||
|
db.Close()
|
||||||
|
t.Fatalf("encode bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO ItemTable(key, value) VALUES(?, ?)", cursorStateStatsigBootstrapKey, raw); err != nil {
|
||||||
|
db.Close()
|
||||||
|
t.Fatalf("insert bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatalf("close setup db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{"cursorAuth/cachedEmail": "local@example.com"}
|
||||||
|
if err := syncCursorAuthStateDB(path, values); err != nil {
|
||||||
|
t.Fatalf("first state sync: %v", err)
|
||||||
|
}
|
||||||
|
first := readCursorStatsigBootstrapForTest(t, path)
|
||||||
|
assertCursorStatsigGateValueForTest(t, first, "disable_terminal_output_ui_streaming", false)
|
||||||
|
assertCursorStatsigGateValueForTest(t, first, "unrelated_gate", true)
|
||||||
|
|
||||||
|
if err := syncCursorAuthStateDB(path, values); err != nil {
|
||||||
|
t.Fatalf("second state sync: %v", err)
|
||||||
|
}
|
||||||
|
second := readCursorStatsigBootstrapForTest(t, path)
|
||||||
|
if string(second) != string(first) {
|
||||||
|
t.Fatalf("repeated sync changed bootstrap:\nfirst: %s\nsecond: %s", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCursorStatsigBootstrapForTest(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open state db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRowContext(context.Background(), "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("read bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCursorStatsigGateValueForTest(t *testing.T, raw []byte, name string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
FeatureGates map[string]struct {
|
||||||
|
Value bool `json:"value"`
|
||||||
|
} `json:"feature_gates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||||
|
t.Fatalf("decode bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
gate, ok := payload.FeatureGates[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing gate %q", name)
|
||||||
|
}
|
||||||
|
if gate.Value != want {
|
||||||
|
t.Fatalf("gate %q value=%t, want %t", name, gate.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user