From 68e63c7a728780cbea1e032644341be77707e9dd Mon Sep 17 00:00:00 2001 From: DedSecer Date: Sat, 1 Aug 2026 21:04:26 +0800 Subject: [PATCH 1/2] feat(cursor): sync local conversation history to Cursor transcripts Project sidecar context history into Cursor-compatible JSONL transcripts so previous conversations remain readable when referenced from new chats. Backfill existing sessions on startup and preserve Cursor-managed turn status entries during atomic updates. --- internal/backend/forwarder/file_store.go | 78 ++- internal/backend/forwarder/rewind.go | 3 + internal/backend/forwarder/service.go | 6 + .../backend/forwarder/transcript_adapter.go | 449 ++++++++++++++++++ .../forwarder/transcript_adapter_test.go | 213 +++++++++ internal/backend/forwarder/types.go | 1 + 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 internal/backend/forwarder/transcript_adapter.go create mode 100644 internal/backend/forwarder/transcript_adapter_test.go diff --git a/internal/backend/forwarder/file_store.go b/internal/backend/forwarder/file_store.go index 4f34720..0732614 100644 --- a/internal/backend/forwarder/file_store.go +++ b/internal/backend/forwarder/file_store.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "runtime" @@ -438,7 +439,11 @@ func (store *ConversationFileStore) writeConversationLocked(conversationID strin if err := store.writeContextLocked(conversationID, conversation); err != nil { return err } - return store.writeConversationMetaLocked(conversationID, conversation) + if err := store.writeConversationMetaLocked(conversationID, conversation); err != nil { + return err + } + store.syncCursorTranscriptBestEffort(conversationID, conversation) + return nil } func (store *ConversationFileStore) writeConversationMetaLocked(conversationID string, conversation *ConversationFile) error { @@ -477,6 +482,70 @@ func (store *ConversationFileStore) writeContextLocked(conversationID string, co return writeJSONFileAtomic(store.contextPath(conversationID), context) } +func (store *ConversationFileStore) syncCursorTranscriptBestEffort(conversationID string, conversation *ConversationFile) { + if store == nil || conversation == nil { + return + } + folder := normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder) + if folder == "" { + return + } + if err := store.syncCursorTranscript(conversationID, conversation, folder); err != nil { + log.Printf("forwarder transcript sync failed conversation_id=%s err=%v", strings.TrimSpace(conversationID), err) + } +} + +func (store *ConversationFileStore) syncCursorTranscript(conversationID string, conversation *ConversationFile, transcriptsFolder string) error { + return store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, transcriptsFolder, false) +} + +func (store *ConversationFileStore) syncCursorTranscriptWithLatestStatus(conversationID string, conversation *ConversationFile, transcriptsFolder string, includeLatestStatus bool) error { + if store == nil || conversation == nil { + return nil + } + path, err := cursorTranscriptPath(transcriptsFolder, conversationID) + if err != nil { + return err + } + data, err := projectCursorTranscriptJSONLWithLatestStatus(conversation, includeLatestStatus) + if err != nil { + return err + } + if len(data) == 0 { + return nil + } + data = preserveCursorAppendedTurnEnded(path, data) + return writeCursorTranscriptAtomic(path, data) +} + +func (store *ConversationFileStore) SyncAllCursorTranscriptsBestEffort() { + if store == nil { + return + } + conversationIDs, err := store.ListConversationIDs() + if err != nil { + log.Printf("forwarder transcript backfill scan failed err=%v", err) + return + } + for _, conversationID := range conversationIDs { + conversation, err := store.LoadConversation(conversationID) + if err != nil { + log.Printf("forwarder transcript backfill load failed conversation_id=%s err=%v", conversationID, err) + continue + } + if conversation == nil || conversation.AgentTranscriptsFolder == "" { + continue + } + info, err := os.Stat(conversation.AgentTranscriptsFolder) + if err != nil || !info.IsDir() { + continue + } + if err := store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, conversation.AgentTranscriptsFolder, true); err != nil { + log.Printf("forwarder transcript backfill failed conversation_id=%s err=%v", conversationID, err) + } + } +} + func contextVersionForEntries(entries []HistoryEntry) int64 { var version int64 for _, entry := range entries { @@ -663,6 +732,9 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil target.ParentConversationID = strings.TrimSpace(source.ParentConversationID) target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID) target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName) + if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" { + target.AgentTranscriptsFolder = folder + } if strings.TrimSpace(source.Mode) != "" { target.Mode = strings.TrimSpace(source.Mode) } @@ -718,6 +790,10 @@ func normalizeLoadedConversation(conversationID string, conversation *Conversati if conversation.Entries == nil { conversation.Entries = make([]HistoryEntry, 0, 16) } + conversation.AgentTranscriptsFolder = normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder) + if conversation.AgentTranscriptsFolder == "" { + conversation.AgentTranscriptsFolder = agentTranscriptsFolderFromEntries(conversation.Entries) + } for _, entry := range conversation.Entries { if entry.Seq >= conversation.NextEntrySeq { conversation.NextEntrySeq = entry.Seq + 1 diff --git a/internal/backend/forwarder/rewind.go b/internal/backend/forwarder/rewind.go index e0f5898..ba7c851 100644 --- a/internal/backend/forwarder/rewind.go +++ b/internal/backend/forwarder/rewind.go @@ -260,6 +260,9 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation conversation.ParentConversationID = strings.TrimSpace(source.ParentConversationID) conversation.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID) conversation.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName) + if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" { + conversation.AgentTranscriptsFolder = folder + } if strings.TrimSpace(source.Mode) != "" { conversation.Mode = strings.TrimSpace(source.Mode) } diff --git a/internal/backend/forwarder/service.go b/internal/backend/forwarder/service.go index 09c2a1a..5bb04ca 100644 --- a/internal/backend/forwarder/service.go +++ b/internal/backend/forwarder/service.go @@ -302,6 +302,7 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv appendSeq: newAppendSequenceTracker(), } service.startHistoryMaintenance() + store.SyncAllCursorTranscriptsBestEffort() return service } @@ -691,6 +692,11 @@ func (service *Service) handleRunIntent(intent InboundIntent) error { if err != nil { return err } + if intent.RequestContext != nil { + if folder := normalizeAgentTranscriptsFolder(intent.RequestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" { + conversation.AgentTranscriptsFolder = folder + } + } rewindDecision := service.decideRunRewind(intent, conversation) if rewindDecision.Evaluated && !rewindDecision.Apply { service.logRunRewindDecision(intent.RequestID, intent.ConversationID, "rewind_skipped", rewindDecision) diff --git a/internal/backend/forwarder/transcript_adapter.go b/internal/backend/forwarder/transcript_adapter.go new file mode 100644 index 0000000..86605a0 --- /dev/null +++ b/internal/backend/forwarder/transcript_adapter.go @@ -0,0 +1,449 @@ +package forwarder + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + + "cursor/gen/agentv1" + modeladapter "cursor/internal/backend/agent/model" + promptengine "cursor/internal/backend/agent/prompt" +) + +var ( + transcriptContextTagPatterns = compileTranscriptContextTagPatterns([]string{ + "user_info", + "project_layout", + "rules", + "always_applied_workspace_rules", + "agent_requestable_workspace_rules", + "user_rules", + "agent_skills", + "available_skills", + "cloud_instructions", + "cloud_task_instructions", + "open_and_recently_viewed_files", + "system_reminder", + "system-reminder", + "mcp_instructions", + "mcp_file_system", + "mcp_file_system_servers", + "git_status", + "agent_transcripts", + "cursor_rules_context", + "attached_files", + "system_notification", + "task_notification", + "agent_notification", + }) + transcriptThinkingPattern = regexp.MustCompile(`(?is)<(?:think|thinking)>.*?`) + transcriptBlankLinesPattern = regexp.MustCompile(`\n{3,}`) +) + +type cursorTranscriptLine struct { + Role string `json:"role,omitempty"` + Message *cursorTranscriptMessage `json:"message,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +type cursorTranscriptMessage struct { + Content []cursorTranscriptContent `json:"content"` +} + +type cursorTranscriptContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` +} + +// projectCursorTranscriptJSONL projects the local semantic history into Cursor's +// current agent transcript JSONL contract. context.json remains the source of truth. +func projectCursorTranscriptJSONL(conversation *ConversationFile) ([]byte, error) { + return projectCursorTranscriptJSONLWithLatestStatus(conversation, false) +} + +func projectCursorTranscriptJSONLWithLatestStatus(conversation *ConversationFile, includeLatestStatus bool) ([]byte, error) { + if conversation == nil { + return nil, nil + } + lines := make([]cursorTranscriptLine, 0, len(conversation.Entries)) + maxTurnSeq := int64(0) + for _, entry := range conversation.Entries { + if entry.TurnSeq > maxTurnSeq { + maxTurnSeq = entry.TurnSeq + } + } + currentTurnSeq := int64(0) + pendingTurnStatus := cursorTranscriptLine{} + flushTurnStatus := func() { + if currentTurnSeq > 0 && (includeLatestStatus || currentTurnSeq < maxTurnSeq) && pendingTurnStatus.Type != "" { + lines = append(lines, pendingTurnStatus) + } + pendingTurnStatus = cursorTranscriptLine{} + } + for _, entry := range conversation.Entries { + if entry.TurnSeq > 0 && entry.TurnSeq != currentTurnSeq { + flushTurnStatus() + currentTurnSeq = entry.TurnSeq + } + projected, ok, err := projectCursorTranscriptEntry(entry) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, projected) + } + if status, ok := cursorTranscriptTurnStatus(entry); ok { + pendingTurnStatus = status + } + } + flushTurnStatus() + + if len(lines) == 0 { + return nil, nil + } + var output bytes.Buffer + encoder := json.NewEncoder(&output) + encoder.SetEscapeHTML(false) + for _, line := range lines { + if err := encoder.Encode(line); err != nil { + return nil, fmt.Errorf("encode cursor transcript line: %w", err) + } + } + return output.Bytes(), nil +} + +func projectCursorTranscriptEntry(entry HistoryEntry) (cursorTranscriptLine, bool, error) { + switch strings.TrimSpace(entry.Kind) { + case "user_message": + message := &agentv1.UserMessage{} + if err := protojson.Unmarshal(entry.Payload, message); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript user_message: %w", err) + } + text := cleanCursorTranscriptUserText(message.GetText()) + if text == "" { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptTextLine("user", text), true, nil + case "assistant_text": + var payload assistantTextPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript assistant_text: %w", err) + } + text := cleanCursorTranscriptAssistantText(payload.Text) + thinking := strings.TrimSpace(payload.ReasoningContent) + content := joinTranscriptText(text, thinking) + if content == "" { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptTextLine("assistant", content), true, nil + case "tool_call": + var payload toolCallEntryPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call: %w", err) + } + toolCall := &agentv1.ToolCall{} + if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call payload: %w", err) + } + descriptor, ok := promptengine.BuildToolCallReplayDescriptor(firstNonEmpty(payload.ToolCallID, entry.ToolCallID), toolCall) + if !ok { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptToolCallLine(descriptor.Function.Name, descriptor.Function.Arguments, payload.ReasoningContent), true, nil + case "model_message": + var payload modelMessageEntryPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript model_message: %w", err) + } + return projectCursorTranscriptModelMessage(payload.Message) + default: + return cursorTranscriptLine{}, false, nil + } +} + +func projectCursorTranscriptModelMessage(message modeladapter.Message) (cursorTranscriptLine, bool, error) { + role := strings.TrimSpace(message.Role) + if role == "" || role == "system" || role == "tool" { + return cursorTranscriptLine{}, false, nil + } + content := make([]cursorTranscriptContent, 0, len(message.ToolCalls)+1) + texts := make([]string, 0, len(message.ContentParts)+2) + if text := strings.TrimSpace(message.Content); text != "" { + texts = append(texts, text) + } + for _, part := range message.ContentParts { + switch strings.TrimSpace(strings.ToLower(part.Type)) { + case "text", "": + if text := strings.TrimSpace(part.Text); text != "" { + texts = append(texts, text) + } + case "image": + texts = append(texts, "[Image]") + } + } + if thinking := strings.TrimSpace(message.ReasoningContent); thinking != "" { + texts = append(texts, thinking) + } + if len(texts) > 0 { + text := strings.Join(texts, "\n\n") + if role == "user" { + text = cleanCursorTranscriptUserText(text) + } else if role == "assistant" { + text = cleanCursorTranscriptAssistantText(text) + } + if text != "" { + content = append(content, cursorTranscriptContent{Type: "text", Text: text}) + } + } + for _, call := range message.ToolCalls { + name := strings.TrimSpace(call.Function.Name) + if name == "" { + continue + } + content = append(content, cursorTranscriptContent{ + Type: "tool_use", + Name: name, + Input: decodeTranscriptToolInput(call.Function.Arguments), + }) + } + if len(content) == 0 { + return cursorTranscriptLine{}, false, nil + } + return cursorTranscriptLine{Role: role, Message: &cursorTranscriptMessage{Content: content}}, true, nil +} + +func cursorTranscriptTextLine(role string, text string) cursorTranscriptLine { + if strings.TrimSpace(text) == "" { + return cursorTranscriptLine{} + } + return cursorTranscriptLine{ + Role: strings.TrimSpace(role), + Message: &cursorTranscriptMessage{Content: []cursorTranscriptContent{{ + Type: "text", + Text: text, + }}}, + } +} + +func cursorTranscriptToolCallLine(name string, arguments string, reasoning string) cursorTranscriptLine { + content := make([]cursorTranscriptContent, 0, 2) + if thinking := strings.TrimSpace(reasoning); thinking != "" { + content = append(content, cursorTranscriptContent{Type: "text", Text: thinking}) + } + content = append(content, cursorTranscriptContent{ + Type: "tool_use", + Name: strings.TrimSpace(name), + Input: decodeTranscriptToolInput(arguments), + }) + return cursorTranscriptLine{Role: "assistant", Message: &cursorTranscriptMessage{Content: content}} +} + +func decodeTranscriptToolInput(arguments string) any { + trimmed := strings.TrimSpace(arguments) + if trimmed == "" { + return map[string]any{} + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return decoded + } + return trimmed +} + +func cursorTranscriptTurnStatus(entry HistoryEntry) (cursorTranscriptLine, bool) { + if strings.TrimSpace(entry.Kind) != "metadata" || entry.TurnSeq <= 0 { + return cursorTranscriptLine{}, false + } + var payload metadataPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return cursorTranscriptLine{}, false + } + switch strings.TrimSpace(payload.Type) { + case "turn_completed": + return cursorTranscriptLine{Type: "turn_ended", Status: "success"}, true + case "provider_error", "failed": + return cursorTranscriptLine{ + Type: "turn_ended", + Status: "error", + Error: firstNonEmpty(readStringValue(payload.Value["error"]), readStringValue(payload.Value["message"]), "Request failed"), + }, true + case "control": + if strings.TrimSpace(readStringValue(payload.Value["status"])) != "canceled" { + return cursorTranscriptLine{}, false + } + return cursorTranscriptLine{ + Type: "turn_ended", + Status: "aborted", + Error: firstNonEmpty(readStringValue(payload.Value["reason"]), readStringValue(payload.Value["message"]), "User aborted request"), + }, true + default: + return cursorTranscriptLine{}, false + } +} + +func cleanCursorTranscriptUserText(text string) string { + return cleanTranscriptContextTags(text) +} + +func cleanCursorTranscriptAssistantText(text string) string { + cleaned := transcriptThinkingPattern.ReplaceAllString(text, "") + return collapseTranscriptBlankLines(cleaned) +} + +func cleanTranscriptContextTags(text string) string { + cleaned := text + for _, pattern := range transcriptContextTagPatterns { + cleaned = pattern.ReplaceAllString(cleaned, "") + } + return collapseTranscriptBlankLines(cleaned) +} + +func compileTranscriptContextTagPatterns(tags []string) []*regexp.Regexp { + patterns := make([]*regexp.Regexp, 0, len(tags)) + for _, tag := range tags { + patterns = append(patterns, regexp.MustCompile(`(?is)<`+regexp.QuoteMeta(tag)+`(?:\s[^>]*)?>.*?`)) + } + return patterns +} + +func collapseTranscriptBlankLines(text string) string { + return strings.TrimSpace(transcriptBlankLinesPattern.ReplaceAllString(text, "\n\n")) +} + +func joinTranscriptText(text string, thinking string) string { + parts := make([]string, 0, 2) + if strings.TrimSpace(text) != "" { + parts = append(parts, strings.TrimSpace(text)) + } + if strings.TrimSpace(thinking) != "" { + parts = append(parts, strings.TrimSpace(thinking)) + } + return strings.Join(parts, "\n\n") +} + +func normalizeAgentTranscriptsFolder(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" || !filepath.IsAbs(trimmed) { + return "" + } + cleaned := filepath.Clean(trimmed) + if filepath.Base(cleaned) != "agent-transcripts" { + return "" + } + return cleaned +} + +func agentTranscriptsFolderFromEntries(entries []HistoryEntry) string { + for _, entry := range entries { + if strings.TrimSpace(entry.Kind) != "request_context" { + continue + } + requestContext := &agentv1.RequestContext{} + if err := protojson.Unmarshal(entry.Payload, requestContext); err != nil { + continue + } + if folder := normalizeAgentTranscriptsFolder(requestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" { + return folder + } + } + return "" +} + +func cursorTranscriptPath(transcriptsFolder string, conversationID string) (string, error) { + folder := normalizeAgentTranscriptsFolder(transcriptsFolder) + if folder == "" { + return "", fmt.Errorf("invalid agent transcripts folder") + } + id, err := validateConversationID(conversationID) + if err != nil { + return "", err + } + return filepath.Join(folder, id, id+".jsonl"), nil +} + +func preserveCursorAppendedTurnEnded(path string, projected []byte) []byte { + existing, err := os.ReadFile(path) + if err != nil { + return projected + } + lastLine := lastNonEmptyJSONLLine(existing) + if len(lastLine) == 0 { + return projected + } + var terminal cursorTranscriptLine + if json.Unmarshal(lastLine, &terminal) != nil || terminal.Type != "turn_ended" { + return projected + } + if countTranscriptTurnEnded(existing) <= countTranscriptTurnEnded(projected) { + return projected + } + result := append([]byte(nil), projected...) + if len(result) > 0 && result[len(result)-1] != '\n' { + result = append(result, '\n') + } + result = append(result, lastLine...) + return append(result, '\n') +} + +func lastNonEmptyJSONLLine(data []byte) []byte { + lines := bytes.Split(data, []byte{'\n'}) + for index := len(lines) - 1; index >= 0; index-- { + if line := bytes.TrimSpace(lines[index]); len(line) > 0 { + return append([]byte(nil), line...) + } + } + return nil +} + +func countTranscriptTurnEnded(data []byte) int { + count := 0 + for _, line := range bytes.Split(data, []byte{'\n'}) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + var item cursorTranscriptLine + if json.Unmarshal(trimmed, &item) == nil && item.Type == "turn_ended" { + count++ + } + } + return count +} + +func writeCursorTranscriptAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create transcript directory: %w", err) + } + file, tempPath, err := openUniqueArtifactTempFile(path) + if err != nil { + return fmt.Errorf("open transcript temp file: %w", err) + } + renamed := false + defer func() { + if !renamed { + _ = os.Remove(tempPath) + } + }() + if _, err := file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write transcript temp file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close transcript temp file: %w", err) + } + if err := renameArtifactTempFile(tempPath, path); err != nil { + return fmt.Errorf("rename transcript temp file: %w", err) + } + renamed = true + return syncDirectory(filepath.Dir(path)) +} diff --git a/internal/backend/forwarder/transcript_adapter_test.go b/internal/backend/forwarder/transcript_adapter_test.go new file mode 100644 index 0000000..3074a24 --- /dev/null +++ b/internal/backend/forwarder/transcript_adapter_test.go @@ -0,0 +1,213 @@ +package forwarder + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + + "cursor/gen/agentv1" +) + +func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) { + toolCall := testEditToolCall(t, "file.txt") + conversation := testConversation([]HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), + newAssistantTextEntry(1, "request-1", "hidden\nDone", "checked carefully", ""), + newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall), + newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall), + newMetadataEntry(1, "request-1", "turn_completed", nil), + testUserMessageEntry(t, 2, "request-2", "next question"), + newMetadataEntry(2, "request-2", "turn_completed", nil), + }) + + data, err := projectCursorTranscriptJSONL(conversation) + if err != nil { + t.Fatalf("projectCursorTranscriptJSONL() error = %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 5 { + t.Fatalf("transcript lines = %d, want 5\n%s", len(lines), data) + } + + if lines[0].Role != "user" || transcriptLineText(lines[0]) != "change the file" { + t.Fatalf("user line = %#v", lines[0]) + } + if lines[1].Role != "assistant" || transcriptLineText(lines[1]) != "Done\n\nchecked carefully" { + t.Fatalf("assistant line = %#v", lines[1]) + } + if lines[2].Role != "assistant" || lines[2].Message == nil || len(lines[2].Message.Content) != 1 { + t.Fatalf("tool line = %#v", lines[2]) + } + toolUse := lines[2].Message.Content[0] + if toolUse.Type != "tool_use" || toolUse.Name != "Edit" { + t.Fatalf("tool use = %#v", toolUse) + } + input, ok := toolUse.Input.(map[string]any) + if !ok || input["path"] != "file.txt" { + t.Fatalf("tool input = %#v", toolUse.Input) + } + if lines[3].Type != "turn_ended" || lines[3].Status != "success" { + t.Fatalf("turn status = %#v", lines[3]) + } + if lines[4].Role != "user" || transcriptLineText(lines[4]) != "next question" { + t.Fatalf("current user line = %#v", lines[4]) + } +} + +func TestConversationFileStoreSyncsCursorTranscript(t *testing.T) { + historyRoot := filepath.Join(t.TempDir(), "history") + transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") + store := NewConversationFileStore(historyRoot) + conversation := testConversation(nil) + conversation.AgentTranscriptsFolder = transcriptsFolder + + persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hello"), + newAssistantTextEntry(1, "request-1", "hi", "", ""), + }) + if err != nil { + t.Fatalf("SaveConversationWithEntries() error = %v", err) + } + if persisted.AgentTranscriptsFolder != transcriptsFolder { + t.Fatalf("persisted transcript folder = %q", persisted.AgentTranscriptsFolder) + } + + path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read synced transcript: %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 2 || lines[0].Role != "user" || lines[1].Role != "assistant" { + t.Fatalf("synced transcript = %s", data) + } + + reloaded, err := store.LoadConversation(conversation.ConversationID) + if err != nil { + t.Fatalf("LoadConversation() error = %v", err) + } + if reloaded.AgentTranscriptsFolder != transcriptsFolder { + t.Fatalf("reloaded transcript folder = %q", reloaded.AgentTranscriptsFolder) + } +} + +func TestConversationFileStoreBackfillsTranscriptOnStartup(t *testing.T) { + historyRoot := filepath.Join(t.TempDir(), "history") + transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") + if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil { + t.Fatalf("create transcript root: %v", err) + } + store := NewConversationFileStore(historyRoot) + conversation := testConversation(nil) + conversation.AgentTranscriptsFolder = transcriptsFolder + _, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ + testUserMessageEntry(t, 1, "request-1", "hello"), + newAssistantTextEntry(1, "request-1", "hi", "", ""), + newMetadataEntry(1, "request-1", "turn_completed", nil), + }) + if err != nil { + t.Fatalf("SaveConversationWithEntries() error = %v", err) + } + path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl") + if err := os.RemoveAll(filepath.Dir(path)); err != nil { + t.Fatalf("remove generated transcript: %v", err) + } + if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil { + t.Fatalf("restore transcript root: %v", err) + } + + store.SyncAllCursorTranscriptsBestEffort() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read backfilled transcript: %v", err) + } + lines := decodeCursorTranscriptLines(t, data) + if len(lines) != 3 || lines[2].Type != "turn_ended" || lines[2].Status != "success" { + t.Fatalf("backfilled transcript = %s", data) + } +} + +func TestNormalizeAgentTranscriptsFolderRejectsUnexpectedPaths(t *testing.T) { + root := t.TempDir() + if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "agent-transcripts")); got == "" { + t.Fatal("valid transcript folder was rejected") + } + if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "other")); got != "" { + t.Fatalf("unexpected folder accepted: %q", got) + } + if got := normalizeAgentTranscriptsFolder("agent-transcripts"); got != "" { + t.Fatalf("relative folder accepted: %q", got) + } +} + +func TestPreserveCursorAppendedTurnEnded(t *testing.T) { + path := filepath.Join(t.TempDir(), "conversation.jsonl") + existing := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"type\":\"turn_ended\",\"status\":\"success\"}\n") + if err := os.WriteFile(path, existing, 0o644); err != nil { + t.Fatalf("write existing transcript: %v", err) + } + projected := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}\n") + preserved := preserveCursorAppendedTurnEnded(path, projected) + if countTranscriptTurnEnded(preserved) != 1 { + t.Fatalf("preserved transcript = %s", preserved) + } + if !strings.HasSuffix(string(preserved), "{\"type\":\"turn_ended\",\"status\":\"success\"}\n") { + t.Fatalf("terminal line not preserved: %s", preserved) + } +} + +func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { + folder := filepath.Join(t.TempDir(), "agent-transcripts") + payload, err := protojson.Marshal(&agentv1.RequestContext{ + Env: &agentv1.RequestContextEnv{AgentTranscriptsFolder: folder}, + }) + if err != nil { + t.Fatalf("marshal request context: %v", err) + } + conversation := testConversation([]HistoryEntry{{ + TurnSeq: 1, + Role: "user", + Kind: "request_context", + Payload: payload, + }}) + conversation.AgentTranscriptsFolder = "" + normalizeLoadedConversation(conversation.ConversationID, conversation) + if conversation.AgentTranscriptsFolder != folder { + t.Fatalf("recovered transcript folder = %q", conversation.AgentTranscriptsFolder) + } +} + +func decodeCursorTranscriptLines(t *testing.T, data []byte) []cursorTranscriptLine { + t.Helper() + lines := make([]cursorTranscriptLine, 0) + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + var line cursorTranscriptLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("decode transcript line %q: %v", scanner.Text(), err) + } + lines = append(lines, line) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan transcript: %v", err) + } + return lines +} + +func transcriptLineText(line cursorTranscriptLine) string { + if line.Message == nil { + return "" + } + texts := make([]string, 0, len(line.Message.Content)) + for _, content := range line.Message.Content { + if content.Type == "text" { + texts = append(texts, content.Text) + } + } + return strings.Join(texts, "\n\n") +} diff --git a/internal/backend/forwarder/types.go b/internal/backend/forwarder/types.go index c87ef98..db78eee 100644 --- a/internal/backend/forwarder/types.go +++ b/internal/backend/forwarder/types.go @@ -22,6 +22,7 @@ type ConversationFile struct { ParentConversationID string `json:"parent_conversation_id"` ParentToolCallID string `json:"parent_tool_call_id"` SubagentTypeName string `json:"subagent_type_name,omitempty"` + AgentTranscriptsFolder string `json:"agent_transcripts_folder,omitempty"` Mode string `json:"mode"` ContextVersion int64 `json:"context_version,omitempty"` CurrentLoopID string `json:"current_loop_id,omitempty"` From 834288839b006c888109fae291fa68910fec22fd Mon Sep 17 00:00:00 2001 From: DedSecer Date: Sat, 1 Aug 2026 21:10:46 +0800 Subject: [PATCH 2/2] test(cursor): make transcript adapter tests self-contained Keep the transcript coverage independent from branch-specific projector helpers so it runs cleanly on the upstream main test layout. --- .../forwarder/transcript_adapter_test.go | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/internal/backend/forwarder/transcript_adapter_test.go b/internal/backend/forwarder/transcript_adapter_test.go index 3074a24..8f932f0 100644 --- a/internal/backend/forwarder/transcript_adapter_test.go +++ b/internal/backend/forwarder/transcript_adapter_test.go @@ -3,6 +3,7 @@ package forwarder import ( "bufio" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -14,14 +15,14 @@ import ( ) func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) { - toolCall := testEditToolCall(t, "file.txt") - conversation := testConversation([]HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), + toolCall := transcriptTestEditToolCall(t, "file.txt") + conversation := transcriptTestConversation([]HistoryEntry{ + transcriptTestUserMessageEntry(t, 1, "request-1", "hidden\n\nchange the file"), newAssistantTextEntry(1, "request-1", "hidden\nDone", "checked carefully", ""), newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall), newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall), newMetadataEntry(1, "request-1", "turn_completed", nil), - testUserMessageEntry(t, 2, "request-2", "next question"), + transcriptTestUserMessageEntry(t, 2, "request-2", "next question"), newMetadataEntry(2, "request-2", "turn_completed", nil), }) @@ -63,11 +64,11 @@ func TestConversationFileStoreSyncsCursorTranscript(t *testing.T) { historyRoot := filepath.Join(t.TempDir(), "history") transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts") store := NewConversationFileStore(historyRoot) - conversation := testConversation(nil) + conversation := transcriptTestConversation(nil) conversation.AgentTranscriptsFolder = transcriptsFolder persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hello"), + transcriptTestUserMessageEntry(t, 1, "request-1", "hello"), newAssistantTextEntry(1, "request-1", "hi", "", ""), }) if err != nil { @@ -103,10 +104,10 @@ func TestConversationFileStoreBackfillsTranscriptOnStartup(t *testing.T) { t.Fatalf("create transcript root: %v", err) } store := NewConversationFileStore(historyRoot) - conversation := testConversation(nil) + conversation := transcriptTestConversation(nil) conversation.AgentTranscriptsFolder = transcriptsFolder _, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{ - testUserMessageEntry(t, 1, "request-1", "hello"), + transcriptTestUserMessageEntry(t, 1, "request-1", "hello"), newAssistantTextEntry(1, "request-1", "hi", "", ""), newMetadataEntry(1, "request-1", "turn_completed", nil), }) @@ -169,7 +170,7 @@ func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { if err != nil { t.Fatalf("marshal request context: %v", err) } - conversation := testConversation([]HistoryEntry{{ + conversation := transcriptTestConversation([]HistoryEntry{{ TurnSeq: 1, Role: "user", Kind: "request_context", @@ -182,6 +183,49 @@ func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) { } } +func transcriptTestConversation(entries []HistoryEntry) *ConversationFile { + conversation := &ConversationFile{ + ConversationID: "conversation-1", + RootConversationID: "conversation-1", + Mode: "agent", + NextTurnSeq: 1, + NextEntrySeq: 1, + Entries: make([]HistoryEntry, 0, len(entries)), + } + appendEntriesInPlace(conversation, entries) + return conversation +} + +func transcriptTestUserMessageEntry(t *testing.T, turnSeq int64, requestID string, text string) HistoryEntry { + t.Helper() + payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: fmt.Sprintf("message-%d", turnSeq)}) + if err != nil { + t.Fatalf("marshal user message: %v", err) + } + return HistoryEntry{ + TurnSeq: turnSeq, + RequestID: requestID, + Role: "user", + Kind: "user_message", + Payload: payload, + } +} + +func transcriptTestEditToolCall(t *testing.T, path string) []byte { + t.Helper() + payload, err := protojson.Marshal(&agentv1.ToolCall{ + Tool: &agentv1.ToolCall_EditToolCall{ + EditToolCall: &agentv1.EditToolCall{ + Args: &agentv1.EditArgs{Path: path}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal edit tool call: %v", err) + } + return payload +} + func decodeCursorTranscriptLines(t *testing.T, data []byte) []cursorTranscriptLine { t.Helper() lines := make([]cursorTranscriptLine, 0)