sketch: add debug handler to dump conversation history as JSON

Add HTTP debug endpoint /debug/conversation-history to dump agent conversation
history as pretty-printed JSON for debugging purposes.

Sometimes, you just want to see what went on.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s6c9e876db9b3aa5ck
diff --git a/loop/agent.go b/loop/agent.go
index ed5c907..1416ab6 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -335,6 +335,7 @@
 	ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
 	CancelToolUse(toolUseID string, cause error) error
 	SubConvoWithHistory() *conversation.Convo
+	DebugJSON() ([]byte, error)
 }
 
 // AgentGitState holds the state necessary for pushing to a remote git repo
@@ -474,6 +475,11 @@
 	return a.config.Service.TokenContextWindow()
 }
 
+// GetConvo returns the conversation interface for debugging purposes.
+func (a *Agent) GetConvo() ConvoInterface {
+	return a.convo
+}
+
 // NewIterator implements CodingAgent.
 func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
 	a.mu.Lock()
diff --git a/loop/agent_test.go b/loop/agent_test.go
index ea93dc6..0d0e6ab 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -266,6 +266,7 @@
 	overBudgetFunc               func() error
 	getIDFunc                    func() string
 	subConvoWithHistoryFunc      func() *conversation.Convo
+	debugJSONFunc                func() ([]byte, error)
 }
 
 func (m *MockConvoInterface) SendMessage(message llm.Message) (*llm.Response, error) {
@@ -344,6 +345,13 @@
 	return nil
 }
 
+func (m *MockConvoInterface) DebugJSON() ([]byte, error) {
+	if m.debugJSONFunc != nil {
+		return m.debugJSONFunc()
+	}
+	return []byte(`[{"role": "user", "content": [{"type": "text", "text": "mock conversation"}]}]`), nil
+}
+
 // TestAgentProcessTurnWithNilResponseNilError tests the scenario where Agent.processTurn receives
 // a nil value for initialResp and nil error from processUserMessage.
 // This test verifies that the implementation properly handles this edge case.
@@ -529,6 +537,10 @@
 	return nil
 }
 
+func (m *mockConvoInterface) DebugJSON() ([]byte, error) {
+	return []byte(`[{"role": "user", "content": [{"type": "text", "text": "mock conversation"}]}]`), nil
+}
+
 func TestAgentProcessTurnStateTransitions(t *testing.T) {
 	// Create a mock ConvoInterface for testing
 	mockConvo := &mockConvoInterface{}
diff --git a/loop/mocks.go b/loop/mocks.go
index 7a7b946..b627cc0 100644
--- a/loop/mocks.go
+++ b/loop/mocks.go
@@ -242,3 +242,22 @@
 
 	return retErr
 }
+
+// DebugJSON returns mock conversation data as JSON for debugging purposes
+func (m *MockConvo) DebugJSON() ([]byte, error) {
+	m.recordCall("DebugJSON")
+	exp, ok := m.findMatchingExpectation("DebugJSON")
+	if !ok {
+		// Return a simple mock JSON response if no expectation is set
+		return []byte(`{"mock": "conversation", "calls": {}}`), nil
+	}
+
+	var retErr error
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if err, ok := exp.result[1].(error); ok {
+		retErr = err
+	}
+
+	return exp.result[0].([]byte), retErr
+}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 6fa3aae..7e06214 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -780,7 +780,7 @@
 		}()
 	})
 
-	debugMux := initDebugMux()
+	debugMux := initDebugMux(agent)
 	s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
 		debugMux.ServeHTTP(w, r)
 	})
@@ -1024,7 +1024,7 @@
 	return "/bin/sh"
 }
 
-func initDebugMux() *http.ServeMux {
+func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
 	mux := http.NewServeMux()
 	build := "unknown build"
 	bi, ok := debug.ReadBuildInfo()
@@ -1045,6 +1045,7 @@
 					<li><a href="pprof/symbol">pprof/symbol</a></li>
 					<li><a href="pprof/trace">pprof/trace</a></li>
 					<li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
+					<li><a href="conversation-history">conversation-history</a></li>
 			</ul>
 			</body>
 			</html>
@@ -1055,6 +1056,31 @@
 	mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
 	mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
 	mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
+
+	// Add conversation history debug handler
+	mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+
+		// Use type assertion to access the GetConvo method
+		type ConvoProvider interface {
+			GetConvo() loop.ConvoInterface
+		}
+
+		if convoProvider, ok := agent.(ConvoProvider); ok {
+			// Call the DebugJSON method to get the conversation history
+			historyJSON, err := convoProvider.GetConvo().DebugJSON()
+			if err != nil {
+				http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
+				return
+			}
+
+			// Write the JSON response
+			w.Write(historyJSON)
+		} else {
+			http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
+		}
+	})
+
 	return mux
 }