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.

This commit is contained in:
leookun
2026-08-05 01:46:47 +08:00
parent 374ff9c217
commit 4e9335d82f
2 changed files with 58 additions and 160 deletions
+4 -160
View File
@@ -506,158 +506,10 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
if structuredState.HasTodos { if structuredState.HasTodos {
state.Todos = encodeConversationTodoBytes(structuredState.Todos) state.Todos = encodeConversationTodoBytes(structuredState.Todos)
} }
grouped := make(map[int64][]HistoryEntry) // Cursor 3.14 treats ConversationStateStructure.turns as content-addressed
order := make([]int64, 0, conversation.NextTurnSeq) // blob IDs. Inline protobuf turns make Fork Chat look up the serialized turn
for _, entry := range checkpointProjectionEntries(conversation.Entries) { // itself as an ID and fail with "Missing turn blob". Keep model-visible
if entry.TurnSeq <= 0 { // history in root_prompt_messages_json until checkpoint blob sync is safe.
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)
}
replayMessages, err := projector.ProjectPromptReplay(conversation) replayMessages, err := projector.ProjectPromptReplay(conversation)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -688,14 +540,6 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
return state, nil 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 { func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
if conversation == nil { if conversation == nil {
return 0 return 0
@@ -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])
}
}