This commit is contained in:
LiusCraft 2026-05-15 11:47:19 +08:00 committed by GitHub
commit 2185a7709e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 233 additions and 20 deletions

View file

@ -440,6 +440,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)
@ -558,6 +560,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",

View file

@ -1618,6 +1618,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
}
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
originalHistory = defaultAgent.Sessions.GetHistory(sessionKey)
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
t,
al,

View file

@ -677,10 +677,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
}
}

View file

@ -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

View file

@ -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)

View file

@ -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"`

View file

@ -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
}
}

View file

@ -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")

View file

@ -14,6 +14,7 @@ export interface SessionDetail {
messages: {
role: "user" | "assistant"
content: string
created_at?: string
kind?: "normal" | "thought" | "tool_calls"
media?: string[]
attachments?: {

View file

@ -132,12 +132,17 @@ export function AssistantMessage({
)}
<span>{collapsedLabel}</span>
</div>
<IconChevronDown
className={cn(
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
isExpanded ? "rotate-180" : "",
<div className="flex items-center gap-2">
{formattedTimestamp && (
<span className="opacity-50">{formattedTimestamp}</span>
)}
/>
<IconChevronDown
className={cn(
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
isExpanded ? "rotate-180" : "",
)}
/>
</div>
</div>
)}
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (

View file

@ -346,6 +346,7 @@ export function ChatPage() {
<UserMessage
content={msg.content}
attachments={msg.attachments}
timestamp={msg.timestamp}
/>
)}
</div>

View file

@ -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 (
<div className="flex w-full flex-col items-end gap-1.5">
@ -49,6 +57,10 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)}
</div>
)}
{formattedTimestamp && (
<span className="px-1 text-[12px] text-zinc-400">{formattedTimestamp}</span>
)}
</div>
)
}

View file

@ -43,8 +43,6 @@ export async function loadSessionMessages(
sessionId: string,
): Promise<ChatMessage[]> {
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,
}))
}