mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 11:37:20 +08:00
Merge pull request #242 from DedSecer/feat/cursor-local-history-transcripts
feat(cursor): support referencing local history from new chats
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -438,7 +439,11 @@ func (store *ConversationFileStore) writeConversationLocked(conversationID strin
|
|||||||
if err := store.writeContextLocked(conversationID, conversation); err != nil {
|
if err := store.writeContextLocked(conversationID, conversation); err != nil {
|
||||||
return err
|
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 {
|
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)
|
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 {
|
func contextVersionForEntries(entries []HistoryEntry) int64 {
|
||||||
var version int64
|
var version int64
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -663,6 +732,9 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
|
|||||||
target.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
target.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
||||||
target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
||||||
target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
||||||
|
if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" {
|
||||||
|
target.AgentTranscriptsFolder = folder
|
||||||
|
}
|
||||||
if strings.TrimSpace(source.Mode) != "" {
|
if strings.TrimSpace(source.Mode) != "" {
|
||||||
target.Mode = strings.TrimSpace(source.Mode)
|
target.Mode = strings.TrimSpace(source.Mode)
|
||||||
}
|
}
|
||||||
@@ -718,6 +790,10 @@ func normalizeLoadedConversation(conversationID string, conversation *Conversati
|
|||||||
if conversation.Entries == nil {
|
if conversation.Entries == nil {
|
||||||
conversation.Entries = make([]HistoryEntry, 0, 16)
|
conversation.Entries = make([]HistoryEntry, 0, 16)
|
||||||
}
|
}
|
||||||
|
conversation.AgentTranscriptsFolder = normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder)
|
||||||
|
if conversation.AgentTranscriptsFolder == "" {
|
||||||
|
conversation.AgentTranscriptsFolder = agentTranscriptsFolderFromEntries(conversation.Entries)
|
||||||
|
}
|
||||||
for _, entry := range conversation.Entries {
|
for _, entry := range conversation.Entries {
|
||||||
if entry.Seq >= conversation.NextEntrySeq {
|
if entry.Seq >= conversation.NextEntrySeq {
|
||||||
conversation.NextEntrySeq = entry.Seq + 1
|
conversation.NextEntrySeq = entry.Seq + 1
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
|
|||||||
conversation.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
conversation.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
||||||
conversation.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
conversation.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
||||||
conversation.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
conversation.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
||||||
|
if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" {
|
||||||
|
conversation.AgentTranscriptsFolder = folder
|
||||||
|
}
|
||||||
if strings.TrimSpace(source.Mode) != "" {
|
if strings.TrimSpace(source.Mode) != "" {
|
||||||
conversation.Mode = strings.TrimSpace(source.Mode)
|
conversation.Mode = strings.TrimSpace(source.Mode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
|
|||||||
appendSeq: newAppendSequenceTracker(),
|
appendSeq: newAppendSequenceTracker(),
|
||||||
}
|
}
|
||||||
service.startHistoryMaintenance()
|
service.startHistoryMaintenance()
|
||||||
|
store.SyncAllCursorTranscriptsBestEffort()
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +692,11 @@ func (service *Service) handleRunIntent(intent InboundIntent) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if intent.RequestContext != nil {
|
||||||
|
if folder := normalizeAgentTranscriptsFolder(intent.RequestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" {
|
||||||
|
conversation.AgentTranscriptsFolder = folder
|
||||||
|
}
|
||||||
|
}
|
||||||
rewindDecision := service.decideRunRewind(intent, conversation)
|
rewindDecision := service.decideRunRewind(intent, conversation)
|
||||||
if rewindDecision.Evaluated && !rewindDecision.Apply {
|
if rewindDecision.Evaluated && !rewindDecision.Apply {
|
||||||
service.logRunRewindDecision(intent.RequestID, intent.ConversationID, "rewind_skipped", rewindDecision)
|
service.logRunRewindDecision(intent.RequestID, intent.ConversationID, "rewind_skipped", rewindDecision)
|
||||||
|
|||||||
@@ -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)>.*?</(?: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[^>]*)?>.*?</`+regexp.QuoteMeta(tag)+`>`))
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) {
|
||||||
|
toolCall := transcriptTestEditToolCall(t, "file.txt")
|
||||||
|
conversation := transcriptTestConversation([]HistoryEntry{
|
||||||
|
transcriptTestUserMessageEntry(t, 1, "request-1", "<user_info>hidden</user_info>\n\nchange the file"),
|
||||||
|
newAssistantTextEntry(1, "request-1", "<thinking>hidden</thinking>\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),
|
||||||
|
transcriptTestUserMessageEntry(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 := transcriptTestConversation(nil)
|
||||||
|
conversation.AgentTranscriptsFolder = transcriptsFolder
|
||||||
|
|
||||||
|
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||||
|
transcriptTestUserMessageEntry(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 := transcriptTestConversation(nil)
|
||||||
|
conversation.AgentTranscriptsFolder = transcriptsFolder
|
||||||
|
_, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||||
|
transcriptTestUserMessageEntry(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 := transcriptTestConversation([]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 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)
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type ConversationFile struct {
|
|||||||
ParentConversationID string `json:"parent_conversation_id"`
|
ParentConversationID string `json:"parent_conversation_id"`
|
||||||
ParentToolCallID string `json:"parent_tool_call_id"`
|
ParentToolCallID string `json:"parent_tool_call_id"`
|
||||||
SubagentTypeName string `json:"subagent_type_name,omitempty"`
|
SubagentTypeName string `json:"subagent_type_name,omitempty"`
|
||||||
|
AgentTranscriptsFolder string `json:"agent_transcripts_folder,omitempty"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
ContextVersion int64 `json:"context_version,omitempty"`
|
ContextVersion int64 `json:"context_version,omitempty"`
|
||||||
CurrentLoopID string `json:"current_loop_id,omitempty"`
|
CurrentLoopID string `json:"current_loop_id,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user