diff --git a/internal/executor/claude_executor.go b/internal/executor/claude_executor.go index c3ab9876..9a1a594d 100644 --- a/internal/executor/claude_executor.go +++ b/internal/executor/claude_executor.go @@ -140,6 +140,11 @@ func (c *ClaudeExecutor) BuildCommand(task *db.Task, sessionID, prompt string) s return cmd } +// GetSessionContent reads the Claude session for the given workDir and returns a conversation transcript. +func (c *ClaudeExecutor) GetSessionContent(workDir string) string { + return ReadClaudeSessionContent(workDir, DefaultClaudeConfigDir()) +} + // ---- Session and Dangerous Mode Support ---- // SupportsSessionResume returns true - Claude supports session resume via --resume. diff --git a/internal/executor/codex_executor.go b/internal/executor/codex_executor.go index a0e17790..3fc0bd1f 100644 --- a/internal/executor/codex_executor.go +++ b/internal/executor/codex_executor.go @@ -413,6 +413,11 @@ func (c *CodexExecutor) ResumeSafe(task *db.Task, workDir string) bool { return c.executor.resumeCodexWithMode(task, workDir, false) } +// GetSessionContent reads the Codex session for the given workDir and returns a conversation transcript. +func (c *CodexExecutor) GetSessionContent(workDir string) string { + return ReadCodexSessionContent(workDir) +} + // findCodexSessionID discovers the most recent Codex session ID for the given workDir. // Codex stores sessions in ~/.codex/sessions/ directory. func findCodexSessionID(workDir string) string { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 89e33fcb..badba804 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1114,6 +1114,16 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { return } + // Check for previous session content from a different executor. + // If this is a fresh start (not retry) and the current executor has no existing session, + // but another executor does, include that session's conversation as context. + if !isRetry { + if handoff := e.GetPreviousSessionContent(taskExecutor, workDir); handoff != "" { + e.logLine(task.ID, "system", "Including previous session context from executor switch") + prompt = handoff + prompt + } + } + // Run the executor var result execResult if isRetry { @@ -1198,6 +1208,36 @@ func (e *Executor) GetTaskExecutor(task *db.Task) TaskExecutor { return e.executorFactory.Get(name) } +// GetPreviousSessionContent checks if a different executor has session content +// for the given workDir. This is used to preserve context when switching executors. +// Returns formatted session handoff text, or empty string if no previous session found. +func (e *Executor) GetPreviousSessionContent(currentExecutor TaskExecutor, workDir string) string { + // If the current executor already has a session, no need for handoff + if currentExecutor.FindSessionID(workDir) != "" { + return "" + } + + // Check all other executors for session content + for _, name := range e.executorFactory.All() { + if name == currentExecutor.Name() { + continue + } + other := e.executorFactory.Get(name) + if other == nil { + continue + } + // Check if this executor has a session for the workDir + if other.FindSessionID(workDir) == "" { + continue + } + content := other.GetSessionContent(workDir) + if content != "" { + return FormatSessionHandoff(name, content) + } + } + return "" +} + // AvailableExecutors returns the names of all available executors. func (e *Executor) AvailableExecutors() []string { return e.executorFactory.Available() diff --git a/internal/executor/gemini_executor.go b/internal/executor/gemini_executor.go index 0761515e..4fd9ff1a 100644 --- a/internal/executor/gemini_executor.go +++ b/internal/executor/gemini_executor.go @@ -358,6 +358,11 @@ func (g *GeminiExecutor) ResumeSafe(task *db.Task, workDir string) bool { return g.executor.resumeGeminiWithMode(task, workDir, false) } +// GetSessionContent reads the Gemini session for the given workDir and returns a conversation transcript. +func (g *GeminiExecutor) GetSessionContent(workDir string) string { + return ReadGeminiSessionContent(workDir) +} + // findGeminiSessionID discovers the most recent Gemini session ID for the given workDir. // Gemini stores sessions in ~/.gemini/tmp//chats/ directory. func findGeminiSessionID(workDir string) string { diff --git a/internal/executor/openclaw_executor.go b/internal/executor/openclaw_executor.go index afe171d6..e601fd91 100644 --- a/internal/executor/openclaw_executor.go +++ b/internal/executor/openclaw_executor.go @@ -360,6 +360,11 @@ func (o *OpenClawExecutor) SupportsDangerousMode() bool { return false } +// GetSessionContent returns empty - OpenClaw sessions are not file-based. +func (o *OpenClawExecutor) GetSessionContent(workDir string) string { + return "" +} + // FindSessionID returns the session key for the given task. // OpenClaw uses explicit session keys rather than auto-discovering from files. func (o *OpenClawExecutor) FindSessionID(workDir string) string { diff --git a/internal/executor/opencode_executor.go b/internal/executor/opencode_executor.go index 1c6f3e43..7b9ef38d 100644 --- a/internal/executor/opencode_executor.go +++ b/internal/executor/opencode_executor.go @@ -330,6 +330,11 @@ func (o *OpenCodeExecutor) SupportsDangerousMode() bool { return false } +// GetSessionContent returns empty - OpenCode doesn't support session discovery. +func (o *OpenCodeExecutor) GetSessionContent(workDir string) string { + return "" +} + // FindSessionID returns empty - OpenCode doesn't support session discovery. func (o *OpenCodeExecutor) FindSessionID(workDir string) string { return "" diff --git a/internal/executor/pi_executor.go b/internal/executor/pi_executor.go index 0f275f78..8cd912d4 100644 --- a/internal/executor/pi_executor.go +++ b/internal/executor/pi_executor.go @@ -171,6 +171,11 @@ func (p *PiExecutor) ResumeSafe(task *db.Task, workDir string) bool { return false } +// GetSessionContent reads the Pi session for the given workDir and returns a conversation transcript. +func (p *PiExecutor) GetSessionContent(workDir string) string { + return ReadPiSessionContent(workDir) +} + // findPiSessionID finds the most recent Pi session ID for a workDir. // It prioritizes explicit session paths in .task-worktrees/sessions/task-.jsonl // but falls back to Pi's internal storage (~/.pi/agent/sessions/...) for backward compatibility. diff --git a/internal/executor/session_content.go b/internal/executor/session_content.go new file mode 100644 index 00000000..b9ccce16 --- /dev/null +++ b/internal/executor/session_content.go @@ -0,0 +1,326 @@ +package executor + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// maxSessionContentSize is the maximum number of characters to include from a previous session. +// This prevents extremely long sessions from blowing up the prompt. +// ~50K chars ≈ ~12.5K tokens. +const maxSessionContentSize = 50000 + +// sessionMessage represents an extracted message from any executor's session file. +type sessionMessage struct { + Role string // "user" or "assistant" + Text string +} + +// ReadClaudeSessionContent reads a Claude session JSONL file and extracts +// a human-readable conversation transcript. +func ReadClaudeSessionContent(workDir, configDir string) string { + sessionID := findClaudeSessionIDImpl(workDir, configDir) + if sessionID == "" { + return "" + } + + baseDir := ResolveClaudeConfigDir(configDir) + escapedPath := strings.ReplaceAll(workDir, "/", "-") + escapedPath = strings.ReplaceAll(escapedPath, ".", "-") + sessionFile := filepath.Join(baseDir, "projects", escapedPath, sessionID+".jsonl") + + return readJSONLSessionContent(sessionFile) +} + +// ReadCodexSessionContent reads a Codex session JSON file and extracts conversation content. +func ReadCodexSessionContent(workDir string) string { + sessionID := findCodexSessionID(workDir) + if sessionID == "" { + return "" + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + sessionFile := filepath.Join(home, ".codex", "sessions", sessionID+".json") + return readJSONSessionContent(sessionFile) +} + +// ReadGeminiSessionContent reads a Gemini session JSON file and extracts conversation content. +func ReadGeminiSessionContent(workDir string) string { + sessionID := findGeminiSessionID(workDir) + if sessionID == "" { + return "" + } + + // Find the actual file path (it's in a hash-named subdirectory) + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + geminiTmpDir := filepath.Join(home, ".gemini", "tmp") + sessionFile := sessionID + ".json" + var foundPath string + + filepath.Walk(geminiTmpDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if strings.Contains(path, "/chats/") && info.Name() == sessionFile { + foundPath = path + return filepath.SkipAll + } + return nil + }) + + if foundPath == "" { + return "" + } + return readJSONSessionContent(foundPath) +} + +// ReadPiSessionContent reads a Pi session JSONL file and extracts conversation content. +func ReadPiSessionContent(workDir string) string { + sessionPath := findPiSessionID(workDir) + if sessionPath == "" { + return "" + } + return readJSONLSessionContent(sessionPath) +} + +// readJSONLSessionContent parses a JSONL session file (used by Claude and Pi). +// Each line is a JSON object. We extract user/assistant text messages. +func readJSONLSessionContent(path string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + var messages []sessionMessage + scanner := bufio.NewScanner(file) + // Increase buffer for large messages (1MB per line) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var entry map[string]interface{} + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + + // Extract role-based messages + role, _ := entry["role"].(string) + if role != "user" && role != "assistant" { + continue + } + + // Extract text from content array + content, ok := entry["content"].([]interface{}) + if !ok { + continue + } + + for _, item := range content { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if itemMap["type"] == "text" { + text, _ := itemMap["text"].(string) + if text != "" { + messages = append(messages, sessionMessage{Role: role, Text: text}) + } + } + } + } + + return formatMessages(messages) +} + +// readJSONSessionContent parses a JSON session file (used by Codex and Gemini). +// These tools store sessions as JSON objects with varying structures. +// We try common patterns to extract conversation messages. +func readJSONSessionContent(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + // Try parsing as a JSON object + var obj map[string]interface{} + if err := json.Unmarshal(data, &obj); err != nil { + return "" + } + + var messages []sessionMessage + + // Try common conversation keys + for _, key := range []string{"messages", "conversation", "history", "entries", "chat", "turns"} { + if arr, ok := obj[key].([]interface{}); ok { + messages = extractMessagesFromArray(arr) + if len(messages) > 0 { + break + } + } + } + + // If no messages found from known keys, try to find any array of message-like objects + if len(messages) == 0 { + messages = findMessagesInObject(obj) + } + + return formatMessages(messages) +} + +// extractMessagesFromArray extracts messages from a JSON array of message objects. +func extractMessagesFromArray(arr []interface{}) []sessionMessage { + var messages []sessionMessage + for _, item := range arr { + msg, ok := item.(map[string]interface{}) + if !ok { + continue + } + + role := extractRole(msg) + if role != "user" && role != "assistant" { + continue + } + + text := extractText(msg) + if text != "" { + messages = append(messages, sessionMessage{Role: role, Text: text}) + } + } + return messages +} + +// extractRole gets the role from a message object, checking common field names. +func extractRole(msg map[string]interface{}) string { + for _, key := range []string{"role", "sender", "author", "from", "type"} { + if v, ok := msg[key].(string); ok { + // Normalize role names + switch strings.ToLower(v) { + case "user", "human": + return "user" + case "assistant", "ai", "bot", "model": + return "assistant" + } + } + } + return "" +} + +// extractText gets text content from a message object. +func extractText(msg map[string]interface{}) string { + // Direct text/content string + for _, key := range []string{"text", "content", "message", "body"} { + if v, ok := msg[key].(string); ok && v != "" { + return v + } + } + + // Content array (Claude-style) + for _, key := range []string{"content", "parts"} { + if arr, ok := msg[key].([]interface{}); ok { + var texts []string + for _, item := range arr { + switch v := item.(type) { + case string: + if v != "" { + texts = append(texts, v) + } + case map[string]interface{}: + if v["type"] == "text" { + if t, ok := v["text"].(string); ok && t != "" { + texts = append(texts, t) + } + } + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n") + } + } + } + + return "" +} + +// findMessagesInObject recursively looks for arrays of message-like objects in a JSON object. +// This is a best-effort fallback for unknown session formats. +func findMessagesInObject(obj map[string]interface{}) []sessionMessage { + for _, v := range obj { + arr, ok := v.([]interface{}) + if !ok || len(arr) == 0 { + continue + } + + // Check if this looks like an array of messages (has role/content fields) + messages := extractMessagesFromArray(arr) + if len(messages) >= 2 { // Need at least 2 messages to be a conversation + return messages + } + } + return nil +} + +// formatMessages converts extracted messages to a readable string with truncation. +func formatMessages(messages []sessionMessage) string { + if len(messages) == 0 { + return "" + } + + var parts []string + for _, msg := range messages { + prefix := "User" + if msg.Role == "assistant" { + prefix = "Assistant" + } + parts = append(parts, fmt.Sprintf("**%s:** %s", prefix, msg.Text)) + } + + content := strings.Join(parts, "\n\n") + return truncateSessionContent(content) +} + +// truncateSessionContent truncates content to maxSessionContentSize, +// keeping the most recent messages and adding a truncation notice. +func truncateSessionContent(content string) string { + if len(content) <= maxSessionContentSize { + return content + } + + content = content[len(content)-maxSessionContentSize:] + // Find the first complete message boundary + if idx := strings.Index(content, "\n\n**"); idx != -1 { + content = content[idx+2:] + } + return "[... earlier conversation truncated ...]\n\n" + content +} + +// FormatSessionHandoff wraps session content in a section header for inclusion in prompts. +func FormatSessionHandoff(executorName, content string) string { + if content == "" { + return "" + } + + var sb strings.Builder + sb.WriteString("## Previous Session Context\n\n") + sb.WriteString(fmt.Sprintf("This task was previously worked on using **%s**. Below is the conversation history from that session.\n", executorName)) + sb.WriteString("Use this context to continue the work seamlessly.\n\n") + sb.WriteString(content) + sb.WriteString("\n\n---\n\n") + return sb.String() +} diff --git a/internal/executor/session_content_test.go b/internal/executor/session_content_test.go new file mode 100644 index 00000000..86d74bd4 --- /dev/null +++ b/internal/executor/session_content_test.go @@ -0,0 +1,429 @@ +package executor + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +func TestReadJSONLSessionContent(t *testing.T) { + t.Run("claude-style JSONL with user and assistant messages", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.jsonl") + + content := `{"type":"system","message":"system init"} +{"role":"user","content":[{"type":"text","text":"Hello, can you help me fix a bug?"}]} +{"role":"assistant","content":[{"type":"text","text":"Sure! What bug are you seeing?"},{"type":"tool_use","id":"123","name":"read_file"}]} +{"role":"user","content":[{"type":"text","text":"The login page crashes when I submit"}]} +{"role":"assistant","content":[{"type":"text","text":"I found the issue. The form handler has a null pointer dereference."}]} +` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONLSessionContent(sessionFile) + + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "**User:** Hello, can you help me fix a bug?") { + t.Errorf("expected user message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** Sure! What bug are you seeing?") { + t.Errorf("expected assistant message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** I found the issue.") { + t.Errorf("expected second assistant message, got: %s", result) + } + // Tool use should be excluded + if strings.Contains(result, "tool_use") || strings.Contains(result, "read_file") { + t.Errorf("tool use details should not appear in output: %s", result) + } + }) + + t.Run("empty file returns empty string", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "empty.jsonl") + if err := os.WriteFile(sessionFile, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONLSessionContent(sessionFile) + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) + + t.Run("non-existent file returns empty string", func(t *testing.T) { + result := readJSONLSessionContent("/nonexistent/path/session.jsonl") + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) + + t.Run("only system messages returns empty", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "system-only.jsonl") + content := `{"type":"system","message":"init"} +{"type":"result","result":"done"} +` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONLSessionContent(sessionFile) + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) +} + +func TestReadJSONSessionContent(t *testing.T) { + t.Run("messages array with role and content", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.json") + + content := `{ + "workDir": "/tmp/test", + "messages": [ + {"role": "user", "content": "Please fix the tests"}, + {"role": "assistant", "content": "I'll look at the test failures now."}, + {"role": "user", "content": "Focus on the auth module"}, + {"role": "assistant", "content": "Found the issue in auth/login_test.go"} + ] + }` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONSessionContent(sessionFile) + + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "**User:** Please fix the tests") { + t.Errorf("expected user message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** I'll look at the test failures now.") { + t.Errorf("expected assistant message, got: %s", result) + } + }) + + t.Run("conversation key with sender field", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.json") + + content := `{ + "conversation": [ + {"sender": "human", "text": "What is this code doing?"}, + {"sender": "ai", "text": "This code implements a REST API."} + ] + }` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONSessionContent(sessionFile) + + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "**User:** What is this code doing?") { + t.Errorf("expected user message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** This code implements a REST API.") { + t.Errorf("expected assistant message, got: %s", result) + } + }) + + t.Run("nested content array (Claude-style in JSON)", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.json") + + content := `{ + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Help me debug"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "Sure, let me check."}]} + ] + }` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONSessionContent(sessionFile) + + if !strings.Contains(result, "**User:** Help me debug") { + t.Errorf("expected user message from content array, got: %s", result) + } + }) + + t.Run("Gemini-style with parts array", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.json") + + content := `{ + "history": [ + {"role": "user", "parts": ["Analyze this code"]}, + {"role": "model", "parts": ["The code implements a sorting algorithm."]} + ] + }` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONSessionContent(sessionFile) + + if !strings.Contains(result, "**User:** Analyze this code") { + t.Errorf("expected user message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** The code implements a sorting algorithm.") { + t.Errorf("expected model->assistant message, got: %s", result) + } + }) + + t.Run("auto-discover messages in nested object", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "session.json") + + content := `{ + "metadata": {"version": 1}, + "data": { + "thread": [ + {"role": "user", "content": "First message"}, + {"role": "assistant", "content": "First response"}, + {"role": "user", "content": "Second message"}, + {"role": "assistant", "content": "Second response"} + ] + } + }` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + _ = readJSONSessionContent(sessionFile) + // findMessagesInObject only searches top-level values, not recursively nested. + // The "data" value is an object not an array, so "thread" won't be found. + // This is expected - the fallback is best-effort for top-level arrays only. + }) + + t.Run("empty JSON returns empty", func(t *testing.T) { + dir := t.TempDir() + sessionFile := filepath.Join(dir, "empty.json") + if err := os.WriteFile(sessionFile, []byte(`{}`), 0644); err != nil { + t.Fatal(err) + } + + result := readJSONSessionContent(sessionFile) + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) + + t.Run("non-existent file returns empty", func(t *testing.T) { + result := readJSONSessionContent("/nonexistent/session.json") + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) +} + +func TestTruncateSessionContent(t *testing.T) { + t.Run("short content is not truncated", func(t *testing.T) { + content := "**User:** Hello\n\n**Assistant:** Hi there!" + result := truncateSessionContent(content) + if result != content { + t.Errorf("expected unchanged content, got: %s", result) + } + }) + + t.Run("long content is truncated from beginning", func(t *testing.T) { + // Build content longer than maxSessionContentSize + var sb strings.Builder + for i := 0; i < 1000; i++ { + sb.WriteString("**User:** " + strings.Repeat("x", 100) + "\n\n") + } + content := sb.String() + + result := truncateSessionContent(content) + + if len(result) > maxSessionContentSize+200 { // allow some overhead for truncation notice + t.Errorf("expected truncated content, got length: %d", len(result)) + } + if !strings.HasPrefix(result, "[... earlier conversation truncated ...]") { + t.Errorf("expected truncation notice, got: %s", result[:100]) + } + }) +} + +func TestFormatSessionHandoff(t *testing.T) { + t.Run("formats content with executor name", func(t *testing.T) { + content := "**User:** Hello\n\n**Assistant:** Hi!" + result := FormatSessionHandoff("claude", content) + + if !strings.Contains(result, "## Previous Session Context") { + t.Error("expected section header") + } + if !strings.Contains(result, "**claude**") { + t.Error("expected executor name") + } + if !strings.Contains(result, content) { + t.Error("expected content to be included") + } + }) + + t.Run("empty content returns empty", func(t *testing.T) { + result := FormatSessionHandoff("claude", "") + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) +} + +func TestReadClaudeSessionContent(t *testing.T) { + t.Run("reads session from claude config dir", func(t *testing.T) { + // Setup a fake Claude config directory structure + tmpDir := t.TempDir() + workDir := "/tmp/test-workdir" + + // Claude escapes path: /tmp/test-workdir -> -tmp-test-workdir + escapedPath := strings.ReplaceAll(workDir, "/", "-") + escapedPath = strings.ReplaceAll(escapedPath, ".", "-") + projectDir := filepath.Join(tmpDir, "projects", escapedPath) + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a session file + sessionContent := `{"role":"user","content":[{"type":"text","text":"Fix the bug"}]} +{"role":"assistant","content":[{"type":"text","text":"I'll fix it now."}]} +` + sessionFile := filepath.Join(projectDir, "abc12345-1234-5678-abcd-123456789abc.jsonl") + if err := os.WriteFile(sessionFile, []byte(sessionContent), 0644); err != nil { + t.Fatal(err) + } + + result := ReadClaudeSessionContent(workDir, tmpDir) + + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "**User:** Fix the bug") { + t.Errorf("expected user message, got: %s", result) + } + if !strings.Contains(result, "**Assistant:** I'll fix it now.") { + t.Errorf("expected assistant message, got: %s", result) + } + }) + + t.Run("no session returns empty", func(t *testing.T) { + tmpDir := t.TempDir() + result := ReadClaudeSessionContent("/nonexistent/workdir", tmpDir) + if result != "" { + t.Errorf("expected empty result, got: %s", result) + } + }) +} + +// mockTaskExecutor is a minimal TaskExecutor for testing GetPreviousSessionContent. +type mockTaskExecutor struct { + name string + sessionID string // returned by FindSessionID + sessionContent string // returned by GetSessionContent +} + +func (m *mockTaskExecutor) Name() string { return m.name } +func (m *mockTaskExecutor) FindSessionID(workDir string) string { return m.sessionID } +func (m *mockTaskExecutor) GetSessionContent(workDir string) string { return m.sessionContent } + +// Stubs for the rest of the interface — not exercised by these tests. +func (m *mockTaskExecutor) Execute(ctx context.Context, task *db.Task, workDir, prompt string) ExecResult { + return ExecResult{} +} +func (m *mockTaskExecutor) Resume(ctx context.Context, task *db.Task, workDir, prompt, feedback string) ExecResult { + return ExecResult{} +} +func (m *mockTaskExecutor) BuildCommand(task *db.Task, sessionID, prompt string) string { return "" } +func (m *mockTaskExecutor) IsAvailable() bool { return true } +func (m *mockTaskExecutor) GetProcessID(taskID int64) int { return 0 } +func (m *mockTaskExecutor) Kill(taskID int64) bool { return false } +func (m *mockTaskExecutor) Suspend(taskID int64) bool { return false } +func (m *mockTaskExecutor) IsSuspended(taskID int64) bool { return false } +func (m *mockTaskExecutor) SupportsSessionResume() bool { return false } +func (m *mockTaskExecutor) SupportsDangerousMode() bool { return false } +func (m *mockTaskExecutor) ResumeDangerous(task *db.Task, workDir string) bool { return false } +func (m *mockTaskExecutor) ResumeSafe(task *db.Task, workDir string) bool { return false } + +func TestGetPreviousSessionContent(t *testing.T) { + buildExecutor := func(executors ...TaskExecutor) *Executor { + factory := NewExecutorFactory() + for _, ex := range executors { + factory.Register(ex) + } + return &Executor{executorFactory: factory} + } + + t.Run("returns empty when current executor has session", func(t *testing.T) { + current := &mockTaskExecutor{name: "codex", sessionID: "existing-session"} + other := &mockTaskExecutor{name: "claude", sessionID: "old-session", sessionContent: "**User:** hello"} + + e := buildExecutor(current, other) + result := e.GetPreviousSessionContent(current, "/tmp/work") + + if result != "" { + t.Errorf("expected empty (current has session), got: %s", result) + } + }) + + t.Run("returns handoff when other executor has session", func(t *testing.T) { + current := &mockTaskExecutor{name: "codex", sessionID: ""} + other := &mockTaskExecutor{name: "claude", sessionID: "old-session", sessionContent: "**User:** Fix the bug\n\n**Assistant:** On it."} + + e := buildExecutor(current, other) + result := e.GetPreviousSessionContent(current, "/tmp/work") + + if result == "" { + t.Fatal("expected handoff content, got empty") + } + if !strings.Contains(result, "## Previous Session Context") { + t.Error("expected session handoff header") + } + if !strings.Contains(result, "**claude**") { + t.Error("expected executor name in handoff") + } + if !strings.Contains(result, "Fix the bug") { + t.Error("expected session content in handoff") + } + }) + + t.Run("returns empty when no other executor has session", func(t *testing.T) { + current := &mockTaskExecutor{name: "codex", sessionID: ""} + other := &mockTaskExecutor{name: "claude", sessionID: ""} + + e := buildExecutor(current, other) + result := e.GetPreviousSessionContent(current, "/tmp/work") + + if result != "" { + t.Errorf("expected empty (no other sessions), got: %s", result) + } + }) + + t.Run("skips other executor with session ID but empty content", func(t *testing.T) { + current := &mockTaskExecutor{name: "codex", sessionID: ""} + noContent := &mockTaskExecutor{name: "gemini", sessionID: "some-id", sessionContent: ""} + hasContent := &mockTaskExecutor{name: "claude", sessionID: "old-session", sessionContent: "**User:** hello"} + + e := buildExecutor(current, noContent, hasContent) + result := e.GetPreviousSessionContent(current, "/tmp/work") + + if result == "" { + t.Fatal("expected handoff from claude, got empty") + } + if !strings.Contains(result, "**claude**") { + t.Errorf("expected claude handoff, got: %s", result) + } + }) +} diff --git a/internal/executor/task_executor.go b/internal/executor/task_executor.go index 2a6a5122..b17b1e59 100644 --- a/internal/executor/task_executor.go +++ b/internal/executor/task_executor.go @@ -71,6 +71,11 @@ type TaskExecutor interface { // Returns empty string if no session is found or sessions aren't supported. FindSessionID(workDir string) string + // GetSessionContent reads the session file for the given workDir and returns + // a human-readable conversation transcript. Used for preserving context when + // switching between executors on the same task. + GetSessionContent(workDir string) string + // ResumeDangerous kills the current process and restarts with dangerous mode enabled. // Returns true if successfully restarted. Requires session resume support. ResumeDangerous(task *db.Task, workDir string) bool diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 3d547491..fc152223 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -803,6 +803,13 @@ func (m *DetailModel) startResumableSession(sessionID string) error { if sessionID == "" { // No session to resume - build prompt from task var promptBuilder strings.Builder + + // Check for previous session content from a different executor (executor switch) + if handoff := m.executor.GetPreviousSessionContent(taskExecutor, workDir); handoff != "" { + promptBuilder.WriteString(handoff) + m.database.AppendTaskLog(m.task.ID, "system", "Including previous session context from executor switch") + } + promptBuilder.WriteString(fmt.Sprintf("# Task: %s\n\n", m.task.Title)) if m.task.Body != "" { promptBuilder.WriteString(m.task.Body)