@steerable/agent-ui 0.7.4 → 0.8.0

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.
@@ -48,6 +48,21 @@ function reducer(state, action) {
48
48
  next[idx] = patched;
49
49
  return { messages: next };
50
50
  }
51
+ case 'replace-content': {
52
+ // A retried attempt restarts the reply, so the accumulated text is no
53
+ // longer an append target: replacing it is the only way the live view
54
+ // can match what the backend will persist.
55
+ const idx = findLastAssistantIndex(state.messages);
56
+ if (idx === -1)
57
+ return state;
58
+ const patched = {
59
+ ...state.messages[idx],
60
+ content: action.content,
61
+ };
62
+ const next = state.messages.slice();
63
+ next[idx] = patched;
64
+ return { messages: next };
65
+ }
51
66
  case 'append-tool-call': {
52
67
  const idx = findLastAssistantIndex(state.messages);
53
68
  if (idx === -1)
@@ -202,6 +217,18 @@ export function useChatStream(options) {
202
217
  return;
203
218
  }
204
219
  case 'agent':
220
+ // `stream_restart`: the backend discarded the attempt it was
221
+ // streaming and started the request again. It carries the
222
+ // authoritative replacement text rather than a length to cut, so
223
+ // the live view cannot drift from the persisted reply. Handled here
224
+ // because the text lives in this hook's state; still forwarded, as
225
+ // every other `agent` event is.
226
+ if (event.event === 'stream_restart') {
227
+ const content = typeof event.payload?.content === 'string' ? event.payload.content : '';
228
+ dispatch({ type: 'replace-content', content });
229
+ }
230
+ options.onUnknownEvent?.(event);
231
+ return;
205
232
  case 'orchestration':
206
233
  case 'loader-hint':
207
234
  case 'keepalive':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-ui",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Steerable framework Tier 4 — headless React hooks (useAgentSession, useChatStream, useToolCallStatus) and Tailwind-themed components (ChatPanel, MessageList, OrchestrationPlanCard, ToolCallRenderer, SSEStreamView) for embedding Steerable agents in any React app.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/storybook/",
@@ -57,7 +57,7 @@
57
57
  "README.md"
58
58
  ],
59
59
  "dependencies": {
60
- "@steerable/agent-protocol": "0.7.4"
60
+ "@steerable/agent-protocol": "0.8.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "react": ">=18.0.0",
@@ -185,6 +185,33 @@ describe('useChatStream', () => {
185
185
  expect(onUnknownEvent.mock.calls[1]?.[0].type).toBe('agent');
186
186
  });
187
187
 
188
+ it('stream_restart replaces the streamed text instead of appending to it', async () => {
189
+ // A retry after partial output discards the abandoned attempt. The host
190
+ // has already handed those deltas over, so the only way the live view can
191
+ // match the persisted reply is to take the replacement text.
192
+ const { transport } = makeTransport([
193
+ [
194
+ { type: 'content', content: 'half a sen' },
195
+ { type: 'agent', event: 'stream_restart', payload: { content: '' } },
196
+ { type: 'content', content: 'complete reply' },
197
+ { type: 'done' },
198
+ ],
199
+ ]);
200
+ const onUnknownEvent = vi.fn();
201
+ const { result } = renderHook(() => useChatStream({ transport, onUnknownEvent }));
202
+
203
+ await act(async () => {
204
+ await result.current.sendUserMessage({ content: 'go' });
205
+ });
206
+
207
+ await waitFor(() => {
208
+ const last = result.current.messages.at(-1);
209
+ expect(last?.content).toBe('complete reply');
210
+ });
211
+ // Still forwarded: callers that track agent events keep seeing them.
212
+ expect(onUnknownEvent.mock.calls.some(([e]) => e.event === 'stream_restart')).toBe(true);
213
+ });
214
+
188
215
  it('emits an error overlay onto the assistant message when the stream throws', async () => {
189
216
  const transport: ChatStreamTransport = {
190
217
  stream: async () => {
@@ -150,6 +150,7 @@ type Action =
150
150
  | { type: 'insert-before-last-assistant'; message: ChatMessage }
151
151
  | { type: 'patch-last-assistant'; patch: Partial<ChatMessage> }
152
152
  | { type: 'append-content'; delta: string }
153
+ | { type: 'replace-content'; content: string }
153
154
  | { type: 'append-tool-call'; call: ToolCall }
154
155
  | { type: 'attach-tool-result'; result: ToolResult }
155
156
  | { type: 'finalize-assistant' };
@@ -194,6 +195,20 @@ function reducer(state: State, action: Action): State {
194
195
  next[idx] = patched;
195
196
  return { messages: next };
196
197
  }
198
+ case 'replace-content': {
199
+ // A retried attempt restarts the reply, so the accumulated text is no
200
+ // longer an append target: replacing it is the only way the live view
201
+ // can match what the backend will persist.
202
+ const idx = findLastAssistantIndex(state.messages);
203
+ if (idx === -1) return state;
204
+ const patched: ChatMessage = {
205
+ ...state.messages[idx],
206
+ content: action.content,
207
+ } as ChatMessage;
208
+ const next = state.messages.slice();
209
+ next[idx] = patched;
210
+ return { messages: next };
211
+ }
197
212
  case 'append-tool-call': {
198
213
  const idx = findLastAssistantIndex(state.messages);
199
214
  if (idx === -1) return state;
@@ -354,6 +369,19 @@ export function useChatStream(
354
369
  return;
355
370
  }
356
371
  case 'agent':
372
+ // `stream_restart`: the backend discarded the attempt it was
373
+ // streaming and started the request again. It carries the
374
+ // authoritative replacement text rather than a length to cut, so
375
+ // the live view cannot drift from the persisted reply. Handled here
376
+ // because the text lives in this hook's state; still forwarded, as
377
+ // every other `agent` event is.
378
+ if (event.event === 'stream_restart') {
379
+ const content =
380
+ typeof event.payload?.content === 'string' ? event.payload.content : '';
381
+ dispatch({ type: 'replace-content', content });
382
+ }
383
+ options.onUnknownEvent?.(event);
384
+ return;
357
385
  case 'orchestration':
358
386
  case 'loader-hint':
359
387
  case 'keepalive':