From 7172b5a8ff97a5c50056d8c73a639302b6c42e53 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 18 Mar 2026 10:14:19 -0500 Subject: [PATCH 1/3] Preserve session content when switching executors When switching from one executor to another on an existing task, the previous session's conversation history is now extracted and injected into the new executor's prompt as context. This prevents losing all context when changing e.g. from Claude to Codex or Gemini. Implementation: - Add GetSessionContent(workDir) to TaskExecutor interface - Implement for all executors: Claude/Pi read JSONL sessions, Codex/Gemini parse JSON sessions with generic message extraction, OpenClaw/OpenCode return empty (no file-based sessions) - Add GetPreviousSessionContent() on Executor that checks other executors for session content when current executor has no session - Wire into both daemon (executeTask) and interactive (TUI) paths - Truncate large sessions to ~50K chars keeping most recent context Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/executor/claude_executor.go | 5 + internal/executor/codex_executor.go | 5 + internal/executor/executor.go | 40 +++ internal/executor/gemini_executor.go | 5 + internal/executor/openclaw_executor.go | 5 + internal/executor/opencode_executor.go | 5 + internal/executor/pi_executor.go | 5 + internal/executor/session_content.go | 326 ++++++++++++++++++++++ internal/executor/session_content_test.go | 326 ++++++++++++++++++++++ internal/executor/task_executor.go | 5 + internal/ui/detail.go | 7 + 11 files changed, 734 insertions(+) create mode 100644 internal/executor/session_content.go create mode 100644 internal/executor/session_content_test.go 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..9e29272c --- /dev/null +++ b/internal/executor/session_content_test.go @@ -0,0 +1,326 @@ +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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) + } + }) +} 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) From a63bda5e7505db3439205102116480023afab352 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 18 Mar 2026 10:48:10 -0500 Subject: [PATCH 2/3] Add unit tests for GetPreviousSessionContent orchestration Tests the 4 key branches: current executor has session (skip), other executor has session (return handoff), no sessions found (return empty), and other has session ID but empty content (skip to next). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/executor/session_content_test.go | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/internal/executor/session_content_test.go b/internal/executor/session_content_test.go index 9e29272c..1598b51b 100644 --- a/internal/executor/session_content_test.go +++ b/internal/executor/session_content_test.go @@ -1,10 +1,13 @@ package executor import ( + "context" "os" "path/filepath" "strings" "testing" + + "github.com/bborn/workflow/internal/db" ) func TestReadJSONLSessionContent(t *testing.T) { @@ -324,3 +327,103 @@ func TestReadClaudeSessionContent(t *testing.T) { } }) } + +// 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) + } + }) +} From 283940e025eaadad8f23064e7a2a1208d30f7e5e Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 18 Mar 2026 11:06:49 -0500 Subject: [PATCH 3/3] Fix lint: goimports formatting in session_content_test.go Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/executor/session_content_test.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/executor/session_content_test.go b/internal/executor/session_content_test.go index 1598b51b..86d74bd4 100644 --- a/internal/executor/session_content_test.go +++ b/internal/executor/session_content_test.go @@ -335,8 +335,8 @@ type mockTaskExecutor struct { 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) 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. @@ -347,15 +347,15 @@ func (m *mockTaskExecutor) Resume(ctx context.Context, task *db.Task, workDir, p 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 (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 {