Merge edde942b97 into 215d98aa78
This commit is contained in:
commit
2185a7709e
13 changed files with 233 additions and 20 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface SessionDetail {
|
|||
messages: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
created_at?: string
|
||||
kind?: "normal" | "thought" | "tool_calls"
|
||||
media?: string[]
|
||||
attachments?: {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ export function ChatPage() {
|
|||
<UserMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue