mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fix(reasoning): persist canonical history for DeepSeek and web chat
This commit is contained in:
+36
-12
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -48,6 +49,7 @@ type sessionListItem struct {
|
||||
type sessionChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
@@ -473,6 +475,18 @@ func sessionChatMessagePreview(msg sessionChatMessage) string {
|
||||
}
|
||||
|
||||
func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
|
||||
return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false)
|
||||
}
|
||||
|
||||
func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
|
||||
return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true)
|
||||
}
|
||||
|
||||
func sessionTranscriptMessages(
|
||||
messages []providers.Message,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
includeThoughts bool,
|
||||
) []sessionChatMessage {
|
||||
transcript := make([]sessionChatMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
@@ -494,11 +508,14 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
// Reasoning-only assistant messages are transient display artifacts and
|
||||
// should not be restored from session history.
|
||||
if assistantMessageTransientThought(msg) {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
if includeThoughts {
|
||||
if thoughtMsg, ok := assistantThoughtMessage(msg); ok {
|
||||
transcript = append(transcript, thoughtMsg)
|
||||
}
|
||||
}
|
||||
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(toolSummaryMessages) > 0 {
|
||||
@@ -672,18 +689,25 @@ func sessionAttachmentType(attachment providers.Attachment) string {
|
||||
}
|
||||
}
|
||||
|
||||
func assistantMessageTransientThought(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) == "" &&
|
||||
strings.TrimSpace(msg.ReasoningContent) != "" &&
|
||||
len(msg.ToolCalls) == 0 &&
|
||||
len(msg.Media) == 0 &&
|
||||
len(msg.Attachments) == 0
|
||||
}
|
||||
|
||||
func assistantMessageInternalOnly(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
|
||||
}
|
||||
|
||||
func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
|
||||
reasoning := strings.TrimSpace(msg.ReasoningContent)
|
||||
if reasoning == "" {
|
||||
return sessionChatMessage{}, false
|
||||
}
|
||||
if reasoning == strings.TrimSpace(msg.Content) {
|
||||
return sessionChatMessage{}, false
|
||||
}
|
||||
return sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: reasoning,
|
||||
Kind: "thought",
|
||||
}, true
|
||||
}
|
||||
|
||||
func visibleAssistantToolSummaryMessages(
|
||||
toolCalls []providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
@@ -962,7 +986,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
||||
messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
@@ -423,7 +423,7 @@ func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
||||
func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -460,6 +460,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
@@ -476,6 +477,180 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
sessionKey := picoSessionPrefix + "detail-reasoning-content"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"},
|
||||
} {
|
||||
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-reasoning-content", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[1].Role != "assistant" ||
|
||||
resp.Messages[1].Content != "internal chain of thought" ||
|
||||
resp.Messages[1].Kind != "thought" {
|
||||
t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" {
|
||||
t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
sessionKey := picoSessionPrefix + "detail-refresh-matrix"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "turn1"},
|
||||
{Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"},
|
||||
{Role: "user", Content: "turn2"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ReasoningContent: "tool thought",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_read_file",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_read_file", Content: "file result"},
|
||||
{Role: "user", Content: "turn3"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "tool visible only",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_list_dir",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "list_dir",
|
||||
Arguments: `{"path":"."}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"},
|
||||
{Role: "user", Content: "turn4"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "tool visible and thought",
|
||||
ReasoningContent: "tool mixed thought",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_exec",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "exec",
|
||||
Arguments: `{"command":"pwd"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_exec", Content: "pwd result"},
|
||||
} {
|
||||
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Messages) != 13 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages))
|
||||
}
|
||||
|
||||
assertMessage := func(index int, role, kind, content string) {
|
||||
t.Helper()
|
||||
msg := resp.Messages[index]
|
||||
if msg.Role != role || msg.Kind != kind || msg.Content != content {
|
||||
t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content)
|
||||
}
|
||||
}
|
||||
|
||||
assertMessage(0, "user", "", "turn1")
|
||||
assertMessage(1, "assistant", "thought", "plain thought")
|
||||
assertMessage(2, "assistant", "", "plain visible")
|
||||
assertMessage(3, "user", "", "turn2")
|
||||
assertMessage(4, "assistant", "thought", "tool thought")
|
||||
if !strings.Contains(resp.Messages[5].Content, "`read_file`") {
|
||||
t.Fatalf("messages[5] = %#v, want read_file tool summary", resp.Messages[5])
|
||||
}
|
||||
assertMessage(6, "user", "", "turn3")
|
||||
if !strings.Contains(resp.Messages[7].Content, "`list_dir`") {
|
||||
t.Fatalf("messages[7] = %#v, want list_dir tool summary", resp.Messages[7])
|
||||
}
|
||||
assertMessage(8, "assistant", "", "tool visible only")
|
||||
assertMessage(9, "user", "", "turn4")
|
||||
assertMessage(10, "assistant", "thought", "tool mixed thought")
|
||||
if !strings.Contains(resp.Messages[11].Content, "`exec`") {
|
||||
t.Fatalf("messages[11] = %#v, want exec tool summary", resp.Messages[11])
|
||||
}
|
||||
assertMessage(12, "assistant", "", "tool visible and thought")
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user