diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index a75919912f..b4f231baef 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -370,6 +370,8 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) { defaultAgent.Sessions.SetHistory(sessionKey, initialHistory) defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.") + initialHistory = defaultAgent.Sessions.GetHistory(sessionKey) + response, err := al.processMessage(context.Background(), msg) if err != nil { t.Fatalf("processMessage() error = %v", err) @@ -488,6 +490,8 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) { } defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory) + initialHistory = defaultAgent.Sessions.GetHistory(mainSessionKey) + // Process a /btw command response, err := al.processMessage(context.Background(), bus.InboundMessage{ Channel: "telegram", diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 25e06d7a26..0d8648f1b1 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -1306,6 +1306,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } defaultAgent.Sessions.SetHistory(sessionKey, originalHistory) + originalHistory = defaultAgent.Sessions.GetHistory(sessionKey) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( t, al, diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 85e7dd3c0e..4165a8d610 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -511,10 +511,25 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error { return agent.Sessions.Save(ts.sessionKey) } +// messagesContentEqual compares two message slices by content only, ignoring CreatedAt. +// JSON roundtrip loses the monotonic clock portion of time.Time, so direct +// reflect.DeepEqual would always differ on messages that roundtripped through +// the JSONL store. +func messagesContentEqual(a, b []providers.Message) bool { + for i := range a { + aCopy, bCopy := a[i], b[i] + aCopy.CreatedAt, bCopy.CreatedAt = nil, nil + if !reflect.DeepEqual(aCopy, bCopy) { + return false + } + } + return true +} + func matchingTurnMessageTail(history, persisted []providers.Message) int { maxMatch := min(len(history), len(persisted)) for size := maxMatch; size > 0; size-- { - if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) { + if messagesContentEqual(history[len(history)-size:], persisted[len(persisted)-size:]) { return size } } diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 4922051147..a63598f4e3 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -561,6 +561,12 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { l.Lock() defer l.Unlock() + now := time.Now() + + if msg.CreatedAt == nil { + msg.CreatedAt = &now + } + // Append the message as a single JSON line. line, err := json.Marshal(msg) if err != nil { @@ -598,7 +604,6 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { if err != nil { return err } - now := time.Now() if meta.Count == 0 && meta.CreatedAt.IsZero() { meta.CreatedAt = now } @@ -726,6 +731,12 @@ func (s *JSONLStore) SetHistory( meta.Count = len(history) meta.UpdatedAt = now + for i := range history { + if history[i].CreatedAt == nil { + history[i].CreatedAt = &now + } + } + // Write meta BEFORE rewriting the JSONL file. If we crash between // the two writes, meta has Skip=0 and the old file is still intact, // so GetHistory reads from line 1 — returning "too many" messages diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 3a7b981308..7601deab59 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -1032,6 +1032,143 @@ func TestMultipleSessions_Isolation(t *testing.T) { } } +func TestStore_SetsCreatedAtWhenNil(t *testing.T) { + type writeOp struct { + name string + fn func(store *JSONLStore, key string) (expectedCount int) + } + + ops := []writeOp{ + { + name: "AddMessage", + fn: func(store *JSONLStore, key string) int { + if err := store.AddMessage(context.Background(), key, "user", "hello"); err != nil { + t.Fatalf("AddMessage: %v", err) + } + return 1 + }, + }, + { + name: "AddFullMessage", + fn: func(store *JSONLStore, key string) int { + if err := store.AddFullMessage(context.Background(), key, providers.Message{ + Role: "user", + Content: "hello from full", + }); err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + return 1 + }, + }, + { + name: "SetHistory", + fn: func(store *JSONLStore, key string) int { + if err := store.SetHistory(context.Background(), key, []providers.Message{ + {Role: "user", Content: "msg1"}, + {Role: "assistant", Content: "msg2"}, + }); err != nil { + t.Fatalf("SetHistory: %v", err) + } + return 2 + }, + }, + } + + for _, op := range ops { + t.Run(op.name, func(t *testing.T) { + store := newTestStore(t) + key := "s1" + + before := time.Now().Add(-time.Second) + expectedCount := op.fn(store, key) + after := time.Now().Add(time.Second) + + history, err := store.GetHistory(context.Background(), key) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != expectedCount { + t.Fatalf("expected %d messages, got %d", expectedCount, len(history)) + } + for i := range history { + if history[i].CreatedAt == nil || history[i].CreatedAt.IsZero() { + t.Errorf("message %d CreatedAt is zero — not set by %s", i, op.name) + } + if history[i].CreatedAt.Before(before) || history[i].CreatedAt.After(after) { + t.Errorf( + "message %d CreatedAt %v outside expected window [%v, %v]", + i, history[i].CreatedAt, before, after, + ) + } + } + }) + } +} + +func TestStore_PreservesExistingCreatedAt(t *testing.T) { + t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC) + + type writeOp struct { + name string + fn func(store *JSONLStore, key string) + wantTimes []time.Time + } + + ops := []writeOp{ + { + name: "AddFullMessage", + fn: func(store *JSONLStore, key string) { + if err := store.AddFullMessage(context.Background(), key, providers.Message{ + Role: "user", + Content: "custom time", + CreatedAt: &t1, + }); err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + }, + wantTimes: []time.Time{t1}, + }, + { + name: "SetHistory", + fn: func(store *JSONLStore, key string) { + if err := store.SetHistory(context.Background(), key, []providers.Message{ + {Role: "user", Content: "msg1", CreatedAt: &t1}, + {Role: "assistant", Content: "msg2", CreatedAt: &t2}, + }); err != nil { + t.Fatalf("SetHistory: %v", err) + } + }, + wantTimes: []time.Time{t1, t2}, + }, + } + + for _, op := range ops { + t.Run(op.name, func(t *testing.T) { + store := newTestStore(t) + key := "s1" + + op.fn(store, key) + + history, err := store.GetHistory(context.Background(), key) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != len(op.wantTimes) { + t.Fatalf("expected %d messages, got %d", len(op.wantTimes), len(history)) + } + for i, want := range op.wantTimes { + if history[i].CreatedAt == nil || !history[i].CreatedAt.Equal(want) { + t.Errorf( + "message %d CreatedAt = %v, want %v (should preserve caller-provided time)", + i, history[i].CreatedAt, want, + ) + } + } + }) + } +} + func BenchmarkAddMessage(b *testing.B) { dir := b.TempDir() store, err := NewJSONLStore(dir) diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index bab4433e7a..2c95ac5f88 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -1,5 +1,7 @@ package protocoltypes +import "time" + type ToolCall struct { ID string `json:"id"` Type string `json:"type,omitempty"` @@ -81,6 +83,7 @@ type Attachment struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + CreatedAt *time.Time `json:"created_at,omitempty"` Media []string `json:"media,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 1d6fa31062..7ca03744d2 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -87,8 +87,13 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag sm.sessions[sessionKey] = session } + now := time.Now() + if msg.CreatedAt == nil { + msg.CreatedAt = &now + } + session.Messages = append(session.Messages, msg) - session.Updated = time.Now() + session.Updated = now } func (sm *SessionManager) GetHistory(key string) []providers.Message { @@ -300,7 +305,13 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { // from the caller's slice. msgs := make([]providers.Message, len(history)) copy(msgs, history) + now := time.Now() + for i := range msgs { + if msgs[i].CreatedAt == nil { + msgs[i].CreatedAt = &now + } + } session.Messages = msgs - session.Updated = time.Now() + session.Updated = now } } diff --git a/web/backend/api/session.go b/web/backend/api/session.go index cc18ee6e1a..1dd4904182 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -50,6 +50,7 @@ type sessionChatMessage struct { Role string `json:"role"` Content string `json:"content"` Kind string `json:"kind,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` Media []string `json:"media,omitempty"` Attachments []sessionChatAttachment `json:"attachments,omitempty"` ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"` @@ -510,6 +511,7 @@ func sessionTranscriptMessages( chatMsg := sessionChatMessage{ Role: "user", Content: msg.Content, + CreatedAt: msg.CreatedAt, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -530,8 +532,9 @@ func sessionTranscriptMessages( toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage( msg.ToolCalls, toolFeedbackMaxArgsLength, + msg.CreatedAt, ) - visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) + visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.CreatedAt) // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed @@ -556,6 +559,7 @@ func sessionTranscriptMessages( chatMsg := sessionChatMessage{ Role: "assistant", Content: content, + CreatedAt: msg.CreatedAt, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -682,15 +686,17 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { return sessionChatMessage{}, false } return sessionChatMessage{ - Role: "assistant", - Content: reasoning, - Kind: "thought", + Role: "assistant", + Content: reasoning, + Kind: "thought", + CreatedAt: msg.CreatedAt, }, true } func assistantToolCallsMessage( toolCalls []providers.ToolCall, toolFeedbackMaxArgsLength int, + createdAt *time.Time, ) (sessionChatMessage, bool) { if len(toolCalls) == 0 { return sessionChatMessage{}, false @@ -707,6 +713,7 @@ func assistantToolCallsMessage( return sessionChatMessage{ Role: "assistant", Kind: "tool_calls", + CreatedAt: createdAt, ToolCalls: visibleToolCalls, }, true } @@ -718,7 +725,7 @@ func visibleAssistantToolArgsPreview( return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) } -func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { +func visibleAssistantToolMessages(toolCalls []providers.ToolCall, createdAt *time.Time) []sessionChatMessage { if len(toolCalls) == 0 { return nil } @@ -734,8 +741,9 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM continue } messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: content, + Role: "assistant", + Content: content, + CreatedAt: createdAt, }) } @@ -918,6 +926,11 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } } + for i := range sess.Messages { + if sess.Messages[i].CreatedAt == nil { + sess.Messages[i].CreatedAt = &sess.Updated + } + } messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) w.Header().Set("Content-Type", "application/json") diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index edd7d7c27b..3202e1b59d 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -14,6 +14,7 @@ export interface SessionDetail { messages: { role: "user" | "assistant" content: string + created_at?: string kind?: "normal" | "thought" | "tool_calls" media?: string[] attachments?: { diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 157ca636fe..b8a6026c06 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -132,12 +132,17 @@ export function AssistantMessage({ )} {collapsedLabel} - + {formattedTimestamp && ( + {formattedTimestamp} )} - /> + + )} {(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && ( diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 3ad811dae7..c84a9719e6 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -346,6 +346,7 @@ export function ChatPage() { )} diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 8bfdf24c9f..419a3457e6 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,17 +1,25 @@ +import { formatMessageTime } from "@/hooks/use-pico-chat" import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" interface UserMessageProps { content: string attachments?: ChatAttachment[] + timestamp?: string | number } -export function UserMessage({ content, attachments = [] }: UserMessageProps) { +export function UserMessage({ + content, + attachments = [], + timestamp = "", +}: UserMessageProps) { const hasText = content.trim().length > 0 const isCommand = content.trim().startsWith("/") const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) + const formattedTimestamp = + timestamp !== "" ? formatMessageTime(timestamp) : "" return (
@@ -49,6 +57,10 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { )}
)} + + {formattedTimestamp && ( + {formattedTimestamp} + )} ) } diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts index 9fc35bc1e3..b56227c6a0 100644 --- a/web/frontend/src/features/chat/history.ts +++ b/web/frontend/src/features/chat/history.ts @@ -43,8 +43,6 @@ export async function loadSessionMessages( sessionId: string, ): Promise { const detail = await getSessionHistory(sessionId) - const fallbackTime = detail.updated - return detail.messages.map((message, index) => ({ id: `hist-${index}-${Date.now()}`, role: message.role, @@ -58,7 +56,7 @@ export async function loadSessionMessages( media: message.media, attachments: message.attachments, }), - timestamp: fallbackTime, + timestamp: message.created_at ?? detail.updated, })) }