diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 4318dd28d..4bc8f2ee4 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -686,10 +686,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 492205114..a63598f4e 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 c77a4393e..219f4e3ee 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -1058,6 +1058,137 @@ 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 650bcb287..b60c46701 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"` @@ -87,6 +89,7 @@ type Message struct { Role string `json:"role"` Content string `json:"content"` ModelName string `json:"model_name,omitempty"` + 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/web/backend/api/session.go b/web/backend/api/session.go index e221386ca..7375216ee 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -51,6 +51,7 @@ type sessionChatMessage struct { Content string `json:"content"` Kind string `json:"kind,omitempty"` ModelName string `json:"model_name,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"` @@ -512,6 +513,7 @@ func sessionTranscriptMessages( Role: "user", Content: msg.Content, ModelName: msg.ModelName, + CreatedAt: msg.CreatedAt, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -533,8 +535,9 @@ func sessionTranscriptMessages( msg.ToolCalls, msg.ModelName, toolFeedbackMaxArgsLength, + msg.CreatedAt, ) - visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.ModelName) + visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.ModelName, 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 @@ -560,6 +563,7 @@ func sessionTranscriptMessages( Role: "assistant", Content: content, ModelName: msg.ModelName, + CreatedAt: msg.CreatedAt, Media: append([]string(nil), msg.Media...), Attachments: attachments, } @@ -690,6 +694,7 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { Content: reasoning, Kind: "thought", ModelName: msg.ModelName, + CreatedAt: msg.CreatedAt, }, true } @@ -697,6 +702,7 @@ func assistantToolCallsMessage( toolCalls []providers.ToolCall, modelName string, toolFeedbackMaxArgsLength int, + createdAt *time.Time, ) (sessionChatMessage, bool) { if len(toolCalls) == 0 { return sessionChatMessage{}, false @@ -714,6 +720,7 @@ func assistantToolCallsMessage( Role: "assistant", Kind: "tool_calls", ModelName: modelName, + CreatedAt: createdAt, ToolCalls: visibleToolCalls, }, true } @@ -725,7 +732,7 @@ func visibleAssistantToolArgsPreview( return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) } -func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName string) []sessionChatMessage { +func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName string, createdAt *time.Time) []sessionChatMessage { if len(toolCalls) == 0 { return nil } @@ -744,6 +751,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName stri Role: "assistant", Content: content, ModelName: modelName, + CreatedAt: createdAt, }) } @@ -926,6 +934,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 002a3c5d7..8c0c5e90c 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" model_name?: string media?: string[] diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index c8d0480f0..6527562fe 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -117,12 +117,17 @@ export function AssistantMessage({ {trimmedModelName} )} - + {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 3b4cac730..3b158843c 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -384,6 +384,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 44b873aac..80ebc7ca0 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,17 +1,23 @@ import { IconCheck, IconCopy } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" +import { formatMessageTime } from "@/hooks/use-pico-chat" import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" -import { useTranslation } from "react-i18next" interface UserMessageProps { content: string attachments?: ChatAttachment[] + timestamp?: string | number } -export function UserMessage({ content, attachments = [] }: UserMessageProps) { +export function UserMessage({ + content, + attachments = [], + timestamp = "", +}: UserMessageProps) { const { t } = useTranslation() const { copy, isCopied } = useCopyToClipboard() const hasText = content.trim().length > 0 @@ -22,6 +28,8 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { const copyMessageLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyMessage") + const formattedTimestamp = + timestamp !== "" ? formatMessageTime(timestamp) : "" return (
@@ -81,6 +89,12 @@ 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 72e9ff332..fffae5060 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, @@ -59,7 +57,7 @@ export async function loadSessionMessages( media: message.media, attachments: message.attachments, }), - timestamp: fallbackTime, + timestamp: message.created_at ?? detail.updated, })) }