-
Notifications
You must be signed in to change notification settings - Fork 203
Description
Summary
- Context: The session store's
GetSessionandGetSessionsmethods handle backward compatibility for sessions stored before August 2025, which used a different message format containing anAgentFilenamefield. - Bug: The backward compatibility logic is inverted - it attempts to unmarshal old format messages as the new
[]Itemformat instead of converting them from[]Message. - Actual vs. expected: Old format sessions are retrieved with empty message content, roles, and agent names, instead of preserving the original message data.
- Impact: Users with sessions created before August 21, 2025 cannot access their historical session data - all message content is lost when reading these sessions from the database.
Code with bug
pkg/session/store.go lines 186-196 (in GetSession):
var items []Item
var messages []Message
if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
return nil, err
}
if len(messages) > 0 { // <-- BUG 🔴 Logic is inverted
if err := json.Unmarshal([]byte(messagesJSON), &items); err != nil {
return nil, err
}
} else {
items = convertMessagesToItems(messages)
}The same bug exists in GetSessions at lines 273-283.
The current logic:
- Unmarshal as
[]Message(succeeds for old format) - If successful and
len(messages) > 0, try to unmarshal as[]Item(creates empty items because JSON structure doesn't match) - Else (if messages is empty), convert messages to items
The correct logic should be:
- Try to unmarshal as new format
[]Itemfirst - If that fails, try to unmarshal as old format
[]Messageand convert
Evidence
Example
Consider an old format session stored in the database with this JSON:
[
{
"agentFilename": "demo.yaml",
"agentName": "",
"message": {
"role": "user",
"content": "Hello from old format"
}
}
]With the current buggy code:
json.Unmarshal(messagesJSON, &messages)succeeds (Go's JSON unmarshaler ignores unknown fieldagentFilename)len(messages) > 0is true (1 message)json.Unmarshal(messagesJSON, &items)is attempted - this creates an Item but the nested structure doesn't match- The JSON has
{"agentFilename": ..., "agentName": ..., "message": {...}}but Item expects{"message": {"agentName": ..., "message": {...}}, "sub_session": ...} - The unmarshal "succeeds" but all fields are empty because the structure doesn't match
- Result: Item with empty Message.Content, empty Message.Role, empty Message.AgentName
Inconsistency within the codebase
Reference code
pkg/session/store.go at commit 3268ca8 (August 21, 2025 - when Item format was introduced):
// Parse the data
var items []Item
if err := json.Unmarshal([]byte(messagesJSON), &items); err != nil {
// Try to unmarshal as legacy messages format for backward compatibility
var messages []Message
if err2 := json.Unmarshal([]byte(messagesJSON), &messages); err2 != nil {
return nil, err // Return original error if both fail
}
items = convertMessagesToItems(messages)
}Current code
pkg/session/store.go at current HEAD:
var items []Item
var messages []Message
if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
return nil, err
}
if len(messages) > 0 {
if err := json.Unmarshal([]byte(messagesJSON), &items); err != nil {
return nil, err
}
} else {
items = convertMessagesToItems(messages)
}Contradiction
The original implementation (commit 3268ca8) correctly:
- First tries to unmarshal as NEW format (
[]Item) - On failure, falls back to OLD format (
[]Message) and converts
The current implementation incorrectly:
- First unmarshals as OLD format (
[]Message) - always succeeds due to JSON flexibility - Then tries to unmarshal as NEW format when
len(messages) > 0- creates empty Items - Only converts messages when
len(messages) == 0- which never happens for old data
The logic was broken in commit 3f5d48f when the AgentFilename field was removed from the Message struct. The intermediate fix in commit 1705a24 checked messages[0].AgentFilename == "" to distinguish formats, but when AgentFilename was removed, the check was changed to just len(messages) > 0, which inverted the logic.
Full context
The session store is the persistence layer for agent conversation sessions. It supports two implementations:
InMemorySessionStore: For ephemeral sessionsSQLiteSessionStore: For persistent sessions in a SQLite database
The SQLiteSessionStore.GetSession and SQLiteSessionStore.GetSessions methods read sessions from the database and must handle two different JSON formats:
-
Old format (pre-August 2025): An array of
Messageobjects with structure:[{"agentFilename": "...", "agentName": "...", "message": {...}}] -
New format (post-August 2025): An array of
Itemobjects that can contain either messages or sub-sessions:[{"message": {"agentName": "...", "message": {...}}, "sub_session": null}]
The backward compatibility code is critical because:
- Sessions can persist for months
- Users may have valuable conversation history from before August 2025
- The migration is automatic (no schema change) - it happens during read
The bug is in the detection logic that determines which format is being read. When the code incorrectly unmarshals old format as new format, the nested JSON structure doesn't align, resulting in empty fields. This data loss is silent - no error is returned, just empty messages.
The code is called by:
- The server API (
pkg/server/session_manager.go) when retrieving sessions for the UI - The runtime (
pkg/runtime/runtime.go) when resuming sessions - Any code that lists or retrieves historical sessions
Failing test
Test script
package session
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/cagent/pkg/chat"
)
// OldMessage represents the old message format with AgentFilename field
// This format was used before commit 3f5d48f2
type OldMessage struct {
AgentFilename string `json:"agentFilename"`
AgentName string `json:"agentName"`
Message chat.Message `json:"message"`
}
// TestBackwardCompatibilityWithOldMessageFormat tests that sessions stored with the old
// message format (with AgentFilename field) can still be read correctly.
// This is a regression test for a bug introduced in commit 3f5d48f2 where the backward
// compatibility logic was inverted.
func TestBackwardCompatibilityWithOldMessageFormat(t *testing.T) {
// Create a temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_old_format.db")
// Open database directly to insert old format data
db, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
// Create the sessions table (old schema - just the basics, migrations will add the rest)
_, err = db.ExecContext(context.Background(), `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
messages TEXT,
created_at TEXT
)
`)
require.NoError(t, err)
// Create old format messages (with AgentFilename field that no longer exists)
oldMessages := []OldMessage{
{
AgentFilename: "demo.yaml",
AgentName: "",
Message: chat.Message{
Role: chat.MessageRoleUser,
Content: "Hello from old format",
CreatedAt: time.Now().Format(time.RFC3339),
},
},
{
AgentFilename: "demo.yaml",
AgentName: "assistant-agent",
Message: chat.Message{
Role: chat.MessageRoleAssistant,
Content: "Response in old format",
CreatedAt: time.Now().Format(time.RFC3339),
},
},
}
// Marshal old messages
messagesJSON, err := json.Marshal(oldMessages)
require.NoError(t, err)
t.Logf("Old format JSON: %s", string(messagesJSON))
// Insert session with old message format
_, err = db.ExecContext(context.Background(),
"INSERT INTO sessions (id, messages, created_at) VALUES (?, ?, ?)",
"old-session-id", string(messagesJSON), time.Now().Format(time.RFC3339))
require.NoError(t, err)
db.Close()
// Now try to read it using the session store
store, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
defer store.(*SQLiteSessionStore).Close()
// Try to retrieve the session
sess, err := store.GetSession(context.Background(), "old-session-id")
require.NoError(t, err, "Should be able to retrieve session with old format messages")
// Verify the messages were correctly converted
assert.Len(t, sess.Messages, 2, "Should have 2 messages")
// First message should be a message item
require.True(t, sess.Messages[0].IsMessage(), "First item should be a message")
assert.Equal(t, "Hello from old format", sess.Messages[0].Message.Message.Content,
"First message content should be preserved from old format")
assert.Equal(t, chat.MessageRoleUser, sess.Messages[0].Message.Message.Role,
"First message role should be preserved")
// Second message should also be a message item
require.True(t, sess.Messages[1].IsMessage(), "Second item should be a message")
assert.Equal(t, "Response in old format", sess.Messages[1].Message.Message.Content,
"Second message content should be preserved from old format")
assert.Equal(t, "assistant-agent", sess.Messages[1].Message.AgentName,
"Second message agent name should be preserved")
assert.Equal(t, chat.MessageRoleAssistant, sess.Messages[1].Message.Message.Role,
"Second message role should be preserved")
}
// TestBackwardCompatibilityWithOldMessageFormatInGetSessions tests the same backward
// compatibility for the GetSessions method
func TestBackwardCompatibilityWithOldMessageFormatInGetSessions(t *testing.T) {
// Create a temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_old_format_list.db")
// Open database directly to insert old format data
db, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
// Create the sessions table
_, err = db.ExecContext(context.Background(), `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
messages TEXT,
created_at TEXT
)
`)
require.NoError(t, err)
// Create old format messages
oldMessages := []OldMessage{
{
AgentFilename: "agent.yaml",
AgentName: "",
Message: chat.Message{
Role: chat.MessageRoleUser,
Content: "Old format user message",
CreatedAt: time.Now().Format(time.RFC3339),
},
},
}
messagesJSON, err := json.Marshal(oldMessages)
require.NoError(t, err)
// Insert multiple sessions with old format
for i := 1; i <= 3; i++ {
_, err = db.ExecContext(context.Background(),
"INSERT INTO sessions (id, messages, created_at) VALUES (?, ?, ?)",
fmt.Sprintf("old-session-%d", i), string(messagesJSON), time.Now().Format(time.RFC3339))
require.NoError(t, err)
}
db.Close()
// Now try to read them using the session store
store, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
defer store.(*SQLiteSessionStore).Close()
// Try to retrieve all sessions
sessions, err := store.GetSessions(context.Background())
require.NoError(t, err, "Should be able to retrieve sessions with old format messages")
assert.Len(t, sessions, 3, "Should have 3 sessions")
// Verify each session has correctly converted messages
for i, sess := range sessions {
assert.Len(t, sess.Messages, 1, "Session %d should have 1 message", i)
require.True(t, sess.Messages[0].IsMessage(), "Session %d first item should be a message", i)
assert.Equal(t, "Old format user message", sess.Messages[0].Message.Message.Content,
"Session %d message content should be preserved from old format", i)
}
}Test output
=== RUN TestBackwardCompatibilityWithOldMessageFormat
store_backward_compat_test.go:75: Old format JSON: [{"agentFilename":"demo.yaml","agentName":"","message":{"role":"user","content":"Hello from old format","created_at":"2025-12-12T19:17:47Z"}},{"agentFilename":"demo.yaml","agentName":"assistant-agent","message":{"role":"assistant","content":"Response in old format","created_at":"2025-12-12T19:17:47Z"}}]
store_backward_compat_test.go:99:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:99
Error: Not equal:
expected: "Hello from old format"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-Hello from old format
+
Test: TestBackwardCompatibilityWithOldMessageFormat
Messages: First message content should be preserved from old format
store_backward_compat_test.go:101:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:101
Error: Not equal:
expected: "user"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1,2 +1,2 @@
-(chat.MessageRole) (len=4) "user"
+(chat.MessageRole) ""
Test: TestBackwardCompatibilityWithOldMessageFormat
Messages: First message role should be preserved
store_backward_compat_test.go:106:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:106
Error: Not equal:
expected: "Response in old format"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-Response in old format
+
Test: TestBackwardCompatibilityWithOldMessageFormat
Messages: Second message content should be preserved from old format
store_backward_compat_test.go:108:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:108
Error: Not equal:
expected: "assistant-agent"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-assistant-agent
+
Test: TestBackwardCompatibilityWithOldMessageFormat
Messages: Second message agent name should be preserved
store_backward_compat_test.go:110:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:110
Error: Not equal:
expected: "assistant"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1,2 +1,2 @@
-(chat.MessageRole) (len=9) "assistant"
+(chat.MessageRole) ""
Test: TestBackwardCompatibilityWithOldMessageFormat
Messages: Second message role should be preserved
--- FAIL: TestBackwardCompatibilityWithOldMessageFormat (0.01s)
=== RUN TestBackwardCompatibilityWithOldMessageFormatInGetSessions
store_backward_compat_test.go:176:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:176
Error: Not equal:
expected: "Old format user message"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-Old format user message
+
Test: TestBackwardCompatibilityWithOldMessageFormatInGetSessions
Messages: Session 0 message content should be preserved from old format
store_backward_compat_test.go:176:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:176
Error: Not equal:
expected: "Old format user message"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-Old format user message
+
Test: TestBackwardCompatibilityWithOldMessageFormatInGetSessions
Messages: Session 1 message content should be preserved from old format
store_backward_compat_test.go:176:
Error Trace: /home/user/cagent/pkg/session/store_backward_compat_test.go:176
Error: Not equal:
expected: "Old format user message"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-Old format user message
+
Test: TestBackwardCompatibilityWithOldMessageFormatInGetSessions
Messages: Session 2 message content should be preserved from old format
--- FAIL: TestBackwardCompatibilityWithOldMessageFormatInGetSessions (0.01s)
FAIL
FAIL github.com/docker/cagent/pkg/session 0.028s
FAIL
Why has this bug gone undetected?
-
Recent introduction: The bug was introduced on November 27, 2025 (commit 3f5d48f), only about 15 days ago.
-
Limited affected data: The old message format was only used before August 21, 2025 (commit 3268ca8). Most active users likely have sessions created after that date in the new format, which works correctly.
-
Casual users less affected: Users who started using the application after August 2025 have no old format data. Only users with sessions from before August 21, 2025 AND who try to access those sessions after November 27, 2025 would encounter this bug.
-
Silent failure: The bug doesn't cause an error - it silently returns empty message content. A user might see their old sessions in a list but with no visible content, which could be mistaken for a display issue or dismissed as expected data loss.
-
No test coverage: There were no tests specifically checking backward compatibility with the old message format. The existing tests in
store_test.goall create sessions using the newItemformat, so they pass successfully. -
Comment is misleading: The code comment mentions checking for
AgentFilenameto determine format, but that field no longer exists in theMessagestruct. This suggests the code was changed mechanically without full understanding of the backward compatibility logic.
Recommended fix
Restore the original backward compatibility logic from commit 3268ca8:
// In GetSession (lines 186-196) and GetSessions (lines 273-283)
var items []Item
if err := json.Unmarshal([]byte(messagesJSON), &items); err != nil { // <-- FIX 🟢 Try new format first
// Try to unmarshal as legacy messages format for backward compatibility
var messages []Message
if err2 := json.Unmarshal([]byte(messagesJSON), &messages); err2 != nil {
return nil, err // Return original error if both fail
}
items = convertMessagesToItems(messages) // <-- FIX 🟢 Convert old format
}This approach:
- First attempts to unmarshal as the new
[]Itemformat - If that fails (because it's old format), falls back to
[]Messageand converts - If both fail, returns an error
- Works correctly for both old and new formats without needing to check for removed fields