From 4e9335d82f0f7a655e02d459ce4716d19b1d0e46 Mon Sep 17 00:00:00 2001 From: leookun Date: Wed, 5 Aug 2026 01:46:47 +0800 Subject: [PATCH] Implement tests for ProjectLegacyCheckpoint to ensure proper handling of conversation state and message imports. This includes verifying that no dangling inline blobs are present and that the correct user and assistant messages are imported. --- internal/backend/forwarder/projector.go | 164 +----------------- .../backend/forwarder/projector_fork_test.go | 54 ++++++ 2 files changed, 58 insertions(+), 160 deletions(-) create mode 100644 internal/backend/forwarder/projector_fork_test.go diff --git a/internal/backend/forwarder/projector.go b/internal/backend/forwarder/projector.go index 5726f8f..ed6dcb9 100644 --- a/internal/backend/forwarder/projector.go +++ b/internal/backend/forwarder/projector.go @@ -506,158 +506,10 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers if structuredState.HasTodos { state.Todos = encodeConversationTodoBytes(structuredState.Todos) } - grouped := make(map[int64][]HistoryEntry) - order := make([]int64, 0, conversation.NextTurnSeq) - for _, entry := range checkpointProjectionEntries(conversation.Entries) { - if entry.TurnSeq <= 0 { - continue - } - if _, ok := grouped[entry.TurnSeq]; !ok { - order = append(order, entry.TurnSeq) - } - grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry) - } - - for _, turnSeq := range order { - entries := grouped[turnSeq] - var rawUserMessage []byte - var turnRequestID string - steps := make([][]byte, 0, len(entries)) - seenToolCalls := make(map[string]struct{}) - openToolCalls := make(map[string]struct{}) - for _, entry := range entries { - if turnRequestID == "" { - turnRequestID = strings.TrimSpace(entry.RequestID) - } - switch strings.TrimSpace(entry.Kind) { - case "user_message": - userMessage := &agentv1.UserMessage{} - if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil { - return nil, fmt.Errorf("decode checkpoint user_message: %w", err) - } - payload, err := proto.Marshal(userMessage) - if err != nil { - return nil, err - } - rawUserMessage = payload - case "assistant_text": - var payload assistantTextPayload - if err := json.Unmarshal(entry.Payload, &payload); err != nil { - return nil, err - } - if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 { - continue - } - if strings.TrimSpace(payload.ReasoningContent) != "" { - stepPayload, err := marshalThinkingStep(payload.ReasoningContent) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - } - if strings.TrimSpace(payload.Text) == "" { - continue - } - stepPayload, err := proto.Marshal(&agentv1.ConversationStep{ - Message: &agentv1.ConversationStep_AssistantMessage{ - AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)}, - }, - }) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - case "tool_call": - var payload toolCallEntryPayload - if err := json.Unmarshal(entry.Payload, &payload); err != nil { - return nil, err - } - if strings.TrimSpace(payload.ReasoningContent) != "" { - stepPayload, err := marshalThinkingStep(payload.ReasoningContent) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - } - toolCall := &agentv1.ToolCall{} - if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil { - return nil, err - } - if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) { - continue - } - stepPayload, err := proto.Marshal(&agentv1.ConversationStep{ - Message: &agentv1.ConversationStep_ToolCall{ - ToolCall: toolCall, - }, - }) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" { - seenToolCalls[toolCallID] = struct{}{} - openToolCalls[toolCallID] = struct{}{} - } - case "tool_result": - var payload toolResultEntryPayload - if err := json.Unmarshal(entry.Payload, &payload); err != nil { - return nil, err - } - if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" { - if _, ok := seenToolCalls[toolCallID]; ok { - delete(openToolCalls, toolCallID) - continue - } - } - if strings.TrimSpace(payload.ReasoningContent) != "" { - stepPayload, err := marshalThinkingStep(payload.ReasoningContent) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - } - if len(payload.ToolCall) == 0 { - continue - } - toolCall := &agentv1.ToolCall{} - if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil { - return nil, err - } - if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) { - continue - } - stepPayload, err := proto.Marshal(&agentv1.ConversationStep{ - Message: &agentv1.ConversationStep_ToolCall{ - ToolCall: toolCall, - }, - }) - if err != nil { - return nil, err - } - steps = append(steps, stepPayload) - } - } - if len(rawUserMessage) == 0 && len(steps) == 0 { - continue - } - agentTurn := &agentv1.AgentConversationTurnStructure{ - UserMessage: rawUserMessage, - Steps: steps, - } - if turnRequestID != "" { - agentTurn.RequestId = &turnRequestID - } - turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{ - Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{ - AgentConversationTurn: agentTurn, - }, - }) - if err != nil { - return nil, err - } - state.Turns = append(state.Turns, turnPayload) - } + // Cursor 3.14 treats ConversationStateStructure.turns as content-addressed + // blob IDs. Inline protobuf turns make Fork Chat look up the serialized turn + // itself as an ID and fail with "Missing turn blob". Keep model-visible + // history in root_prompt_messages_json until checkpoint blob sync is safe. replayMessages, err := projector.ProjectPromptReplay(conversation) if err != nil { return nil, err @@ -688,14 +540,6 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers return state, nil } -func marshalThinkingStep(text string) ([]byte, error) { - return proto.Marshal(&agentv1.ConversationStep{ - Message: &agentv1.ConversationStep_ThinkingMessage{ - ThinkingMessage: &agentv1.ThinkingMessage{Text: text}, - }, - }) -} - func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 { if conversation == nil { return 0 diff --git a/internal/backend/forwarder/projector_fork_test.go b/internal/backend/forwarder/projector_fork_test.go new file mode 100644 index 0000000..dcc3ed4 --- /dev/null +++ b/internal/backend/forwarder/projector_fork_test.go @@ -0,0 +1,54 @@ +package forwarder + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + + "cursor/gen/agentv1" +) + +func TestProjectLegacyCheckpointKeepsForkContextWithoutDanglingTurnBlobs(t *testing.T) { + userPayload, err := protojson.Marshal(&agentv1.UserMessage{ + Text: "parent question", + MessageId: "message-1", + }) + if err != nil { + t.Fatalf("marshal user message: %v", err) + } + conversation := &ConversationFile{ + ConversationID: "conversation-1", + RootConversationID: "conversation-1", + Mode: "agent", + NextTurnSeq: 2, + NextEntrySeq: 3, + TokenDetailsMaxTokens: projectedConversationMaxTokens, + Entries: []HistoryEntry{ + {Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload}, + newAssistantTextEntry(1, "request-1", "parent answer", "", ""), + }, + } + + state, err := NewHistoryProjector().ProjectLegacyCheckpoint(conversation) + if err != nil { + t.Fatalf("ProjectLegacyCheckpoint() error = %v", err) + } + if len(state.GetTurns()) != 0 { + t.Fatalf("ProjectLegacyCheckpoint() turns = %d, want 0 dangling inline blobs", len(state.GetTurns())) + } + + messages, err := importedConversationStateModelMessages(state) + if err != nil { + t.Fatalf("importedConversationStateModelMessages() error = %v", err) + } + if len(messages) != 2 { + t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages)) + } + if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") { + t.Fatalf("first imported message = %#v", messages[0]) + } + if messages[1].Role != "assistant" || messages[1].Content != "parent answer" { + t.Fatalf("second imported message = %#v", messages[1]) + } +}