@optilogic/chat 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optilogic/chat",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Chat UI components for Optilogic - AgentResponse and related components for LLM interactions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -24,8 +24,8 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@optilogic/editor": "1.3.3",
28
- "@optilogic/core": "1.3.3"
27
+ "@optilogic/editor": "1.3.4",
28
+ "@optilogic/core": "1.3.4"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "react": "^18.0.0 || ^19.0.0",
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * useAgentResponseAccumulator Hook
3
3
  *
4
- * Accumulates agent response messages into a unified state
4
+ * Accumulates agent response messages into a unified state.
5
+ *
6
+ * Thin wrapper around `reduceAgentMessage` — the pure reducer can be used
7
+ * directly outside React for replaying historical events.
5
8
  */
6
9
 
7
10
  import { useState, useCallback } from "react";
@@ -10,14 +13,8 @@ import {
10
13
  type AgentResponseState,
11
14
  type AgentMessage,
12
15
  type GenericWebSocketMessage,
13
- type ToolCall,
14
- type KnowledgeItem,
15
- type MemoryItem,
16
- type StatusItem,
17
- type ThinkingStep,
18
- type PotentialResponse,
19
16
  } from "../types";
20
- import { buildTimelineEntries } from "../../agent-timeline/utils";
17
+ import { reduceAgentMessage } from "../reducer";
21
18
 
22
19
  export interface UseAgentResponseAccumulatorOptions {
23
20
  /** WebSocket topic to filter messages (optional, for convenience) */
@@ -54,7 +51,6 @@ export function useAgentResponseAccumulator(
54
51
 
55
52
  const handleMessage = useCallback(
56
53
  (message: unknown) => {
57
- // If topic filter is provided, check for matching topic
58
54
  let payload: AgentMessage;
59
55
 
60
56
  if (topic) {
@@ -62,216 +58,10 @@ export function useAgentResponseAccumulator(
62
58
  if (msg.topic !== topic) return;
63
59
  payload = msg.message;
64
60
  } else {
65
- // Assume message is the payload directly
66
61
  payload = message as AgentMessage;
67
62
  }
68
63
 
69
- setState((prev) => {
70
- // If we receive a non-status message while idle, transition to processing
71
- let newStatus = prev.status;
72
- const isFirstMessage = prev.status === "idle" && payload.type !== "status";
73
- if (isFirstMessage) {
74
- newStatus = "processing";
75
- }
76
-
77
- // Track first message time for total time calculation
78
- const firstMessageTime =
79
- prev.firstMessageTime ?? (isFirstMessage ? Date.now() : null);
80
-
81
- switch (payload.type) {
82
- case "status":
83
- // "Harness connected" resets to idle
84
- if (
85
- payload.message === "Harness connected" ||
86
- payload.status === "Harness connected"
87
- ) {
88
- return { ...initialAgentResponseState };
89
- }
90
- return { ...prev, status: newStatus };
91
-
92
- case "thinking": {
93
- // Check if this is a structured thinking step
94
- if (payload.thinkingStep) {
95
- const newStep: ThinkingStep = {
96
- id: payload.thinkingStep.id || `step-${Date.now()}`,
97
- label: payload.thinkingStep.label,
98
- content: payload.thinkingStep.content,
99
- depth: payload.thinkingStep.depth ?? payload.depth ?? 0,
100
- isCollapsed: payload.thinkingStep.isCollapsed,
101
- timestamp: Date.now(),
102
- agentName: payload.agentName,
103
- parentAgent: payload.parentAgent,
104
- };
105
- const thinkingStartTime = prev.thinkingStartTime ?? Date.now();
106
- const next = {
107
- ...prev,
108
- status: newStatus,
109
- thinkingSteps: [...(prev.thinkingSteps || []), newStep],
110
- thinkingStartTime,
111
- firstMessageTime,
112
- };
113
- return { ...next, timelineEntries: buildTimelineEntries(next) };
114
- }
115
-
116
- // Plain text thinking — concatenate for backward compat AND
117
- // push a ThinkingStep so the timeline gets individual entries.
118
- const newThinking = payload.message || payload.content || "";
119
- // Add line break between thinking messages
120
- const separator = prev.thinking && newThinking ? "\n\n" : "";
121
- // Set thinkingStartTime on first thinking message
122
- const thinkingStartTime =
123
- prev.thinkingStartTime ?? (newThinking ? Date.now() : null);
124
- const prevSteps = prev.thinkingSteps || [];
125
- const plainStep: ThinkingStep = {
126
- id: `step-${prevSteps.length}`,
127
- label: newThinking,
128
- content: newThinking,
129
- depth: payload.depth ?? 0,
130
- timestamp: Date.now(),
131
- agentName: payload.agentName,
132
- parentAgent: payload.parentAgent,
133
- };
134
- const next = {
135
- ...prev,
136
- status: newStatus,
137
- thinking: prev.thinking + separator + newThinking,
138
- thinkingSteps: [...prevSteps, plainStep],
139
- thinkingStartTime,
140
- firstMessageTime,
141
- };
142
- return { ...next, timelineEntries: buildTimelineEntries(next) };
143
- }
144
-
145
- case "tool_call": {
146
- // Handle both formats: { message: "ToolName" } or { tool: { id, name, arguments } }
147
- const toolName = payload.message || payload.tool?.name;
148
- if (toolName) {
149
- const newToolCall: ToolCall = {
150
- id: payload.tool?.id || `tool-${Date.now()}`,
151
- name: toolName,
152
- arguments: payload.tool?.arguments,
153
- timestamp: Date.now(),
154
- agentName: payload.agentName,
155
- parentAgent: payload.parentAgent,
156
- depth: payload.depth,
157
- };
158
- const next = {
159
- ...prev,
160
- status: newStatus,
161
- toolCalls: [...prev.toolCalls, newToolCall],
162
- firstMessageTime,
163
- };
164
- return { ...next, timelineEntries: buildTimelineEntries(next) };
165
- }
166
- return { ...prev, status: newStatus, firstMessageTime };
167
- }
168
-
169
- case "knowledge": {
170
- // Handle both formats: { message: "content" } or { knowledge: { id, source, content } }
171
- const knowledgeContent = payload.message || payload.knowledge?.content;
172
- if (knowledgeContent) {
173
- const newKnowledge: KnowledgeItem = {
174
- id: payload.knowledge?.id || `knowledge-${Date.now()}`,
175
- source: payload.knowledge?.source || "unknown",
176
- content: knowledgeContent,
177
- timestamp: Date.now(),
178
- agentName: payload.agentName,
179
- parentAgent: payload.parentAgent,
180
- depth: payload.depth,
181
- };
182
- const next = {
183
- ...prev,
184
- status: newStatus,
185
- knowledge: [...prev.knowledge, newKnowledge],
186
- firstMessageTime,
187
- };
188
- return { ...next, timelineEntries: buildTimelineEntries(next) };
189
- }
190
- return { ...prev, status: newStatus, firstMessageTime };
191
- }
192
-
193
- case "memory": {
194
- // Handle both formats: { message: "content" } or { memory: { id, type, content } }
195
- const memoryContent = payload.message || payload.memory?.content;
196
- if (memoryContent) {
197
- const newMemory: MemoryItem = {
198
- id: payload.memory?.id || `memory-${Date.now()}`,
199
- type: payload.memory?.type || "unknown",
200
- content: memoryContent,
201
- timestamp: Date.now(),
202
- agentName: payload.agentName,
203
- parentAgent: payload.parentAgent,
204
- depth: payload.depth,
205
- };
206
- const next = {
207
- ...prev,
208
- status: newStatus,
209
- memory: [...prev.memory, newMemory],
210
- firstMessageTime,
211
- };
212
- return { ...next, timelineEntries: buildTimelineEntries(next) };
213
- }
214
- return { ...prev, status: newStatus, firstMessageTime };
215
- }
216
-
217
- case "response":
218
- return {
219
- ...prev,
220
- status: "complete",
221
- response: payload.message || payload.content || "",
222
- responseCompleteTime: Date.now(),
223
- firstMessageTime: prev.firstMessageTime ?? Date.now(),
224
- };
225
-
226
- case "status_update": {
227
- const statusMessage = payload.message || payload.statusUpdate?.message;
228
- if (statusMessage) {
229
- const newStatusItem: StatusItem = {
230
- id: payload.statusUpdate?.id || `status-${Date.now()}`,
231
- message: statusMessage,
232
- agent: payload.statusUpdate?.agent,
233
- timestamp: Date.now(),
234
- agentName: payload.agentName,
235
- parentAgent: payload.parentAgent,
236
- depth: payload.depth,
237
- };
238
- const next = {
239
- ...prev,
240
- status: newStatus,
241
- statusUpdates: [...prev.statusUpdates, newStatusItem],
242
- firstMessageTime,
243
- };
244
- return { ...next, timelineEntries: buildTimelineEntries(next) };
245
- }
246
- return { ...prev, status: newStatus, firstMessageTime };
247
- }
248
-
249
- case "potential_response": {
250
- const respContent = payload.message || payload.content || "";
251
- if (respContent) {
252
- const newResp: PotentialResponse = {
253
- id: `resp-${Date.now()}`,
254
- content: respContent,
255
- timestamp: Date.now(),
256
- agentName: payload.agentName,
257
- parentAgent: payload.parentAgent,
258
- depth: payload.depth,
259
- };
260
- const next = {
261
- ...prev,
262
- status: newStatus,
263
- potentialResponses: [...(prev.potentialResponses || []), newResp],
264
- firstMessageTime,
265
- };
266
- return { ...next, timelineEntries: buildTimelineEntries(next) };
267
- }
268
- return { ...prev, status: newStatus, firstMessageTime };
269
- }
270
-
271
- default:
272
- return { ...prev, status: newStatus, firstMessageTime };
273
- }
274
- });
64
+ setState((prev) => reduceAgentMessage(prev, payload));
275
65
  },
276
66
  [topic]
277
67
  );
@@ -52,6 +52,9 @@ export type {
52
52
 
53
53
  export { initialAgentResponseState } from "./types";
54
54
 
55
+ // Pure reducer (for non-React replay of AgentMessage streams)
56
+ export { reduceAgentMessage } from "./reducer";
57
+
55
58
  // Utilities
56
59
  export { formatTime, formatTotalTime } from "./utils";
57
60
 
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Pure reducer for agent response messages.
3
+ *
4
+ * Used by `useAgentResponseAccumulator` for live streaming, and exported so
5
+ * non-React callers (e.g. server-side or store-based replay of historical
6
+ * conversations) can rebuild the same `AgentResponseState` from a sequence of
7
+ * `AgentMessage` events without rendering a component.
8
+ *
9
+ * Replay note on stable IDs: when a payload omits a per-item id
10
+ * (`tool.id`, `thinkingStep.id`, `knowledge.id`, `memory.id`,
11
+ * `statusUpdate.id`), the reducer falls back to `${type}-${Date.now()}`. That
12
+ * is fine for live streams but will produce different React keys on each
13
+ * replay. Callers reconstructing `AgentMessage` events from persisted rows
14
+ * should populate these ids from a stable source (e.g. the supplement-row
15
+ * primary key) so timeline keys remain consistent across reloads.
16
+ */
17
+
18
+ import {
19
+ initialAgentResponseState,
20
+ type AgentResponseState,
21
+ type AgentMessage,
22
+ type ToolCall,
23
+ type KnowledgeItem,
24
+ type MemoryItem,
25
+ type StatusItem,
26
+ type ThinkingStep,
27
+ type PotentialResponse,
28
+ } from "./types";
29
+ import { buildTimelineEntries } from "../agent-timeline/utils";
30
+
31
+ /**
32
+ * Pure reducer: apply a single `AgentMessage` to the accumulated state.
33
+ *
34
+ * `payload.timestamp` (epoch ms), if supplied, is used for the new item's
35
+ * `timestamp` and any state-level timing fields this call sets
36
+ * (`firstMessageTime`, `thinkingStartTime`, `responseCompleteTime`). When
37
+ * absent, `Date.now()` is used — matching the prior live-streaming behaviour.
38
+ *
39
+ * @example
40
+ * const state = events.reduce(reduceAgentMessage, initialAgentResponseState);
41
+ */
42
+ export function reduceAgentMessage(
43
+ prev: AgentResponseState,
44
+ payload: AgentMessage,
45
+ ): AgentResponseState {
46
+ const now = payload.timestamp ?? Date.now();
47
+
48
+ // If we receive a non-status message while idle, transition to processing
49
+ let newStatus = prev.status;
50
+ const isFirstMessage = prev.status === "idle" && payload.type !== "status";
51
+ if (isFirstMessage) {
52
+ newStatus = "processing";
53
+ }
54
+
55
+ // Track first message time for total time calculation
56
+ const firstMessageTime =
57
+ prev.firstMessageTime ?? (isFirstMessage ? now : null);
58
+
59
+ switch (payload.type) {
60
+ case "status":
61
+ // "Harness connected" resets to idle
62
+ if (
63
+ payload.message === "Harness connected" ||
64
+ payload.status === "Harness connected"
65
+ ) {
66
+ return { ...initialAgentResponseState };
67
+ }
68
+ return { ...prev, status: newStatus };
69
+
70
+ case "thinking": {
71
+ // Check if this is a structured thinking step
72
+ if (payload.thinkingStep) {
73
+ const newStep: ThinkingStep = {
74
+ id: payload.thinkingStep.id || `step-${now}`,
75
+ label: payload.thinkingStep.label,
76
+ content: payload.thinkingStep.content,
77
+ depth: payload.thinkingStep.depth ?? payload.depth ?? 0,
78
+ isCollapsed: payload.thinkingStep.isCollapsed,
79
+ timestamp: now,
80
+ agentName: payload.agentName,
81
+ parentAgent: payload.parentAgent,
82
+ };
83
+ const thinkingStartTime = prev.thinkingStartTime ?? now;
84
+ const next = {
85
+ ...prev,
86
+ status: newStatus,
87
+ thinkingSteps: [...(prev.thinkingSteps || []), newStep],
88
+ thinkingStartTime,
89
+ firstMessageTime,
90
+ };
91
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
92
+ }
93
+
94
+ // Plain text thinking — concatenate for backward compat AND
95
+ // push a ThinkingStep so the timeline gets individual entries.
96
+ const newThinking = payload.message || payload.content || "";
97
+ // Add line break between thinking messages
98
+ const separator = prev.thinking && newThinking ? "\n\n" : "";
99
+ // Set thinkingStartTime on first thinking message
100
+ const thinkingStartTime =
101
+ prev.thinkingStartTime ?? (newThinking ? now : null);
102
+ const prevSteps = prev.thinkingSteps || [];
103
+ const plainStep: ThinkingStep = {
104
+ id: `step-${prevSteps.length}`,
105
+ label: newThinking,
106
+ content: newThinking,
107
+ depth: payload.depth ?? 0,
108
+ timestamp: now,
109
+ agentName: payload.agentName,
110
+ parentAgent: payload.parentAgent,
111
+ };
112
+ const next = {
113
+ ...prev,
114
+ status: newStatus,
115
+ thinking: prev.thinking + separator + newThinking,
116
+ thinkingSteps: [...prevSteps, plainStep],
117
+ thinkingStartTime,
118
+ firstMessageTime,
119
+ };
120
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
121
+ }
122
+
123
+ case "tool_call": {
124
+ // Handle both formats: { message: "ToolName" } or { tool: { id, name, arguments } }
125
+ const toolName = payload.message || payload.tool?.name;
126
+ if (toolName) {
127
+ const newToolCall: ToolCall = {
128
+ id: payload.tool?.id || `tool-${now}`,
129
+ name: toolName,
130
+ arguments: payload.tool?.arguments,
131
+ timestamp: now,
132
+ agentName: payload.agentName,
133
+ parentAgent: payload.parentAgent,
134
+ depth: payload.depth,
135
+ };
136
+ const next = {
137
+ ...prev,
138
+ status: newStatus,
139
+ toolCalls: [...prev.toolCalls, newToolCall],
140
+ firstMessageTime,
141
+ };
142
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
143
+ }
144
+ return { ...prev, status: newStatus, firstMessageTime };
145
+ }
146
+
147
+ case "knowledge": {
148
+ // Handle both formats: { message: "content" } or { knowledge: { id, source, content } }
149
+ const knowledgeContent = payload.message || payload.knowledge?.content;
150
+ if (knowledgeContent) {
151
+ const newKnowledge: KnowledgeItem = {
152
+ id: payload.knowledge?.id || `knowledge-${now}`,
153
+ source: payload.knowledge?.source || "unknown",
154
+ content: knowledgeContent,
155
+ timestamp: now,
156
+ agentName: payload.agentName,
157
+ parentAgent: payload.parentAgent,
158
+ depth: payload.depth,
159
+ };
160
+ const next = {
161
+ ...prev,
162
+ status: newStatus,
163
+ knowledge: [...prev.knowledge, newKnowledge],
164
+ firstMessageTime,
165
+ };
166
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
167
+ }
168
+ return { ...prev, status: newStatus, firstMessageTime };
169
+ }
170
+
171
+ case "memory": {
172
+ // Handle both formats: { message: "content" } or { memory: { id, type, content } }
173
+ const memoryContent = payload.message || payload.memory?.content;
174
+ if (memoryContent) {
175
+ const newMemory: MemoryItem = {
176
+ id: payload.memory?.id || `memory-${now}`,
177
+ type: payload.memory?.type || "unknown",
178
+ content: memoryContent,
179
+ timestamp: now,
180
+ agentName: payload.agentName,
181
+ parentAgent: payload.parentAgent,
182
+ depth: payload.depth,
183
+ };
184
+ const next = {
185
+ ...prev,
186
+ status: newStatus,
187
+ memory: [...prev.memory, newMemory],
188
+ firstMessageTime,
189
+ };
190
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
191
+ }
192
+ return { ...prev, status: newStatus, firstMessageTime };
193
+ }
194
+
195
+ case "response":
196
+ return {
197
+ ...prev,
198
+ status: "complete",
199
+ response: payload.message || payload.content || "",
200
+ responseCompleteTime: now,
201
+ firstMessageTime: prev.firstMessageTime ?? now,
202
+ };
203
+
204
+ case "status_update": {
205
+ const statusMessage = payload.message || payload.statusUpdate?.message;
206
+ if (statusMessage) {
207
+ const newStatusItem: StatusItem = {
208
+ id: payload.statusUpdate?.id || `status-${now}`,
209
+ message: statusMessage,
210
+ agent: payload.statusUpdate?.agent,
211
+ timestamp: now,
212
+ agentName: payload.agentName,
213
+ parentAgent: payload.parentAgent,
214
+ depth: payload.depth,
215
+ };
216
+ const next = {
217
+ ...prev,
218
+ status: newStatus,
219
+ statusUpdates: [...prev.statusUpdates, newStatusItem],
220
+ firstMessageTime,
221
+ };
222
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
223
+ }
224
+ return { ...prev, status: newStatus, firstMessageTime };
225
+ }
226
+
227
+ case "potential_response": {
228
+ const respContent = payload.message || payload.content || "";
229
+ if (respContent) {
230
+ const newResp: PotentialResponse = {
231
+ id: `resp-${now}`,
232
+ content: respContent,
233
+ timestamp: now,
234
+ agentName: payload.agentName,
235
+ parentAgent: payload.parentAgent,
236
+ depth: payload.depth,
237
+ };
238
+ const next = {
239
+ ...prev,
240
+ status: newStatus,
241
+ potentialResponses: [...(prev.potentialResponses || []), newResp],
242
+ firstMessageTime,
243
+ };
244
+ return { ...next, timelineEntries: buildTimelineEntries(next) };
245
+ }
246
+ return { ...prev, status: newStatus, firstMessageTime };
247
+ }
248
+
249
+ default:
250
+ return { ...prev, status: newStatus, firstMessageTime };
251
+ }
252
+ }
@@ -165,6 +165,14 @@ export interface AgentMessage {
165
165
  message?: string;
166
166
  /** Alternative content field */
167
167
  content?: string;
168
+ /**
169
+ * Optional event timestamp (epoch ms). When supplied, the reducer uses this
170
+ * for the item's `timestamp` and any state-level timing fields it sets
171
+ * (`firstMessageTime`, `thinkingStartTime`, `responseCompleteTime`) instead
172
+ * of `Date.now()`. Provide this when replaying historical events so durations
173
+ * reflect the original run rather than load time.
174
+ */
175
+ timestamp?: number;
168
176
  /** For status messages */
169
177
  status?: string;
170
178
  /** Agent name (multi-agent scenarios) */
package/src/index.ts CHANGED
@@ -48,6 +48,9 @@ export {
48
48
  // Constants
49
49
  initialAgentResponseState,
50
50
 
51
+ // Pure reducer (for non-React replay of AgentMessage streams)
52
+ reduceAgentMessage,
53
+
51
54
  // Utilities
52
55
  formatTime,
53
56
  formatTotalTime,