diff --git a/core/http/react-ui/src/pages/AgentChat.jsx b/core/http/react-ui/src/pages/AgentChat.jsx index eb19e1e6a4fb..ae4f5c83bf53 100644 --- a/core/http/react-ui/src/pages/AgentChat.jsx +++ b/core/http/react-ui/src/pages/AgentChat.jsx @@ -139,7 +139,8 @@ export default function AgentChat() { id: nextId(), sender, content: data.content || data.message || '', - timestamp: data.timestamp ? Math.floor(data.timestamp / 1e6) : Date.now(), + // Backend sends Unix milliseconds (see core/services/agents events). + timestamp: data.timestamp || Date.now(), } if (data.metadata && Object.keys(data.metadata).length > 0) { msg.metadata = data.metadata diff --git a/core/services/agents/events.go b/core/services/agents/events.go index 9a214acf6f05..b187b295ad25 100644 --- a/core/services/agents/events.go +++ b/core/services/agents/events.go @@ -27,7 +27,7 @@ type AgentEvent struct { Content string `json:"content,omitempty"` MessageID string `json:"message_id,omitempty"` Metadata string `json:"metadata,omitempty"` // JSON metadata - Timestamp int64 `json:"timestamp"` + Timestamp int64 `json:"timestamp"` // Unix milliseconds (set by PublishEvent) } // AgentCancelEvent is the NATS message payload for cancelling agent execution. @@ -61,8 +61,14 @@ func NewEventBridge(nc messaging.MessagingClient, store *AgentStore, instanceID } // PublishEvent publishes an agent event to NATS for SSE bridging. +// +// Timestamp is emitted in Unix milliseconds to match the local dispatcher's +// json_message events (see dispatcher.go) and the React UI, which feeds the +// value straight into `new Date(ts)`. Milliseconds also stay within JS's +// safe-integer range, whereas nanoseconds (~1.7e18) do not and lose precision +// when parsed as a JSON number. func (b *EventBridge) PublishEvent(agentName, userID string, evt AgentEvent) error { - evt.Timestamp = time.Now().UnixNano() + evt.Timestamp = time.Now().UnixMilli() subject := messaging.SubjectAgentEvents(agentName, userID) return b.nats.Publish(subject, evt) } diff --git a/core/services/agents/events_test.go b/core/services/agents/events_test.go new file mode 100644 index 000000000000..f103f7982e57 --- /dev/null +++ b/core/services/agents/events_test.go @@ -0,0 +1,78 @@ +package agents + +import ( + "time" + + "github.com/mudler/LocalAI/core/services/messaging" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeMessagingClient implements messaging.MessagingClient and captures the +// last published payload so tests can assert on it. +type fakeMessagingClient struct { + lastSubject string + lastData any +} + +func (f *fakeMessagingClient) Publish(subject string, data any) error { + f.lastSubject = subject + f.lastData = data + return nil +} + +func (f *fakeMessagingClient) Subscribe(string, func([]byte)) (messaging.Subscription, error) { + return &fakeSub{}, nil +} + +func (f *fakeMessagingClient) QueueSubscribe(string, string, func([]byte)) (messaging.Subscription, error) { + return &fakeSub{}, nil +} + +func (f *fakeMessagingClient) QueueSubscribeReply(string, string, func([]byte, func([]byte))) (messaging.Subscription, error) { + return &fakeSub{}, nil +} + +func (f *fakeMessagingClient) SubscribeReply(string, func([]byte, func([]byte))) (messaging.Subscription, error) { + return &fakeSub{}, nil +} + +func (f *fakeMessagingClient) Request(string, []byte, time.Duration) ([]byte, error) { + return nil, nil +} + +func (f *fakeMessagingClient) IsConnected() bool { return true } +func (f *fakeMessagingClient) Close() {} + +type fakeSub struct{} + +func (s *fakeSub) Unsubscribe() error { return nil } + +var _ = Describe("EventBridge", func() { + Describe("PublishEvent timestamp", func() { + // Regression for #9867: agent chat messages rendered a broken + // timestamp ("Invalid Timestamp" / "12:00 AM") in the web UI because + // this path emitted Unix nanoseconds while the local dispatcher and the + // React UI both expect Unix milliseconds. Nanoseconds also overflow JS's + // safe-integer range. The timestamp must be in milliseconds. + It("emits the timestamp in Unix milliseconds", func() { + fake := &fakeMessagingClient{} + bridge := NewEventBridge(fake, nil, "instance-1") + + before := time.Now().UnixMilli() + err := bridge.PublishMessage("agent", "user", "agent", "hello", "msg-1") + after := time.Now().UnixMilli() + + Expect(err).ToNot(HaveOccurred()) + + evt, ok := fake.lastData.(AgentEvent) + Expect(ok).To(BeTrue(), "published payload should be an AgentEvent") + + // A millisecond timestamp falls within [before, after]; a nanosecond + // one (~1e6 larger) would be far outside this window. + Expect(evt.Timestamp).To(BeNumerically(">=", before)) + Expect(evt.Timestamp).To(BeNumerically("<=", after)) + }) + }) +})