From 0e5402af78f2d00154e43ebb1073386eb62e69ce Mon Sep 17 00:00:00 2001 From: aniruddh909 Date: Wed, 10 Jun 2026 21:58:23 +0200 Subject: [PATCH] fix(agents): emit chat event timestamps in milliseconds (#9867) Agent chat replies rendered a broken timestamp in the web UI ("Invalid Timestamp" / "12:00 AM", identical for every reply) because the SSE timestamp unit was inconsistent across producers. EventBridge.PublishEvent emitted Unix nanoseconds while the local dispatcher (dispatcher.go) already emitted Unix milliseconds, and the React UI fed the value straight into `new Date(ts)` after dividing by 1e6. Nanoseconds also overflow JS's safe-integer range (~1.7e18). Standardize on Unix milliseconds: switch PublishEvent to UnixMilli and drop the /1e6 conversion in AgentChat.jsx so both SSE paths agree and match the React UI's expectation. Add a regression test asserting the published timestamp is in milliseconds. --- core/http/react-ui/src/pages/AgentChat.jsx | 3 +- core/services/agents/events.go | 10 ++- core/services/agents/events_test.go | 78 ++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 core/services/agents/events_test.go 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)) + }) + }) +})