@runtypelabs/persona 4.2.0 → 4.2.1

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": "@runtypelabs/persona",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -1784,6 +1784,134 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1784
1784
  });
1785
1785
  });
1786
1786
 
1787
+ describe('AgentWidgetClient - flow continuation stream without execution_start', () => {
1788
+ // A tool-driven `/resume` continues a flow on a brand-new stream that does NOT
1789
+ // re-emit `execution_start` (`flow_start`). Each stream resolves its execution
1790
+ // kind independently and defaults to `"agent"`, so the continuation used to
1791
+ // mis-route the final prompt-step finalization: the streamed text block was
1792
+ // sealed via the agent path (which never records it as the sealed flow bubble),
1793
+ // then `step_complete.result.response` re-rendered the same text as a SECOND
1794
+ // bubble. The client now recovers the flow kind from the leading `step_*`
1795
+ // frame (a `stepType` is flow-only), so the finalization reconciles in place.
1796
+ function collectAssistantTexts(events: AgentWidgetEvent[]) {
1797
+ const messagesById = new Map<string, AgentWidgetMessage>();
1798
+ for (const event of events) {
1799
+ if (event.type === 'message') messagesById.set(event.message.id, event.message);
1800
+ }
1801
+ return Array.from(messagesById.values())
1802
+ .filter(m => m.role === 'assistant' && !m.variant)
1803
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
1804
+ }
1805
+
1806
+ it('does not duplicate the final message when a flow resumes without execution_start', async () => {
1807
+ const events: AgentWidgetEvent[] = [];
1808
+
1809
+ // No `flow_start`/`execution_start`: this is exactly the wire a tool-driven
1810
+ // `/resume` continuation delivers.
1811
+ global.fetch = createAgentStreamFetch([
1812
+ sseEvent('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 0, totalSteps: 1 }),
1813
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1814
+ sseEvent('step_delta', { id: 's1', text: 'Done! Added to your cart.' }),
1815
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1816
+ // The authoritative final response mirrors the streamed text.
1817
+ sseEvent('step_complete', { id: 's1', name: 'Prompt', stepType: 'prompt', success: true, result: { response: 'Done! Added to your cart.' }, executionTime: 500 }),
1818
+ sseEvent('flow_complete', { success: true }),
1819
+ ]);
1820
+
1821
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1822
+ await client.dispatch(
1823
+ { messages: [{ id: 'usr_1', role: 'user', content: 'add it to my cart', createdAt: new Date().toISOString() }] },
1824
+ (event) => events.push(event)
1825
+ );
1826
+
1827
+ const assistantTexts = collectAssistantTexts(events);
1828
+
1829
+ // Exactly ONE assistant bubble — not duplicated by the step_complete finalization.
1830
+ expect(assistantTexts.length).toBe(1);
1831
+ expect(assistantTexts[0].content).toBe('Done! Added to your cart.');
1832
+ expect(assistantTexts[0].streaming).toBe(false);
1833
+ });
1834
+
1835
+ it('still reconciles in place when execution_start IS present (initial flow stream)', async () => {
1836
+ const events: AgentWidgetEvent[] = [];
1837
+
1838
+ // Same shape but WITH flow_start — the pre-existing, already-correct path.
1839
+ global.fetch = createAgentStreamFetch([
1840
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1, executionId: 'exec_f1' }),
1841
+ sseEvent('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 0, totalSteps: 1 }),
1842
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1843
+ sseEvent('step_delta', { id: 's1', text: 'Done! Added to your cart.' }),
1844
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1845
+ sseEvent('step_complete', { id: 's1', name: 'Prompt', stepType: 'prompt', success: true, result: { response: 'Done! Added to your cart.' }, executionTime: 500 }),
1846
+ sseEvent('flow_complete', { success: true }),
1847
+ ]);
1848
+
1849
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1850
+ await client.dispatch(
1851
+ { messages: [{ id: 'usr_1', role: 'user', content: 'add it to my cart', createdAt: new Date().toISOString() }] },
1852
+ (event) => events.push(event)
1853
+ );
1854
+
1855
+ const assistantTexts = collectAssistantTexts(events);
1856
+ expect(assistantTexts.length).toBe(1);
1857
+ expect(assistantTexts[0].content).toBe('Done! Added to your cart.');
1858
+ expect(assistantTexts[0].streaming).toBe(false);
1859
+ });
1860
+
1861
+ it('does not misclassify an agent stream as a flow (no stepType present)', async () => {
1862
+ const events: AgentWidgetEvent[] = [];
1863
+ const execId = 'exec_a1';
1864
+
1865
+ // Agent loops use `agent_*`/turn_* frames and never carry a `stepType`, so the
1866
+ // flow recovery must not engage. A single streamed turn yields exactly one
1867
+ // bubble (regression guard so the recovery can't over-fire on agents).
1868
+ global.fetch = createAgentStreamFetch([
1869
+ sseEvent('agent_start', {
1870
+ executionId: execId, agentId: 'virtual', agentName: 'Test',
1871
+ maxTurns: 1, startedAt: new Date().toISOString(), seq: 1,
1872
+ }),
1873
+ sseEvent('agent_iteration_start', {
1874
+ executionId: execId, iteration: 1, maxTurns: 1,
1875
+ startedAt: new Date().toISOString(), seq: 2,
1876
+ }),
1877
+ sseEvent('agent_turn_start', {
1878
+ executionId: execId, iteration: 1, turnIndex: 0,
1879
+ role: 'assistant', turnId: 'turn_1', seq: 3,
1880
+ }),
1881
+ sseEvent('agent_turn_delta', {
1882
+ executionId: execId, iteration: 1, delta: 'Hi there!',
1883
+ contentType: 'text', turnId: 'turn_1', seq: 4,
1884
+ }),
1885
+ sseEvent('agent_turn_complete', {
1886
+ executionId: execId, iteration: 1, role: 'assistant',
1887
+ turnId: 'turn_1', completedAt: new Date().toISOString(), seq: 5,
1888
+ }),
1889
+ sseEvent('agent_iteration_complete', {
1890
+ executionId: execId, iteration: 1, toolCallsMade: 0,
1891
+ stopConditionMet: false, completedAt: new Date().toISOString(), seq: 6,
1892
+ }),
1893
+ sseEvent('agent_complete', {
1894
+ executionId: execId, agentId: 'virtual', success: true,
1895
+ iterations: 1, stopReason: 'max_iterations',
1896
+ completedAt: new Date().toISOString(), seq: 7,
1897
+ }),
1898
+ ]);
1899
+
1900
+ const client = new AgentWidgetClient({
1901
+ apiUrl: 'http://localhost:8000',
1902
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
1903
+ });
1904
+ await client.dispatch(
1905
+ { messages: [{ id: 'usr_1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
1906
+ (event) => events.push(event)
1907
+ );
1908
+
1909
+ const assistantTexts = collectAssistantTexts(events);
1910
+ expect(assistantTexts.length).toBe(1);
1911
+ expect(assistantTexts[0].content).toBe('Hi there!');
1912
+ });
1913
+ });
1914
+
1787
1915
  describe('AgentWidgetClient - nested flow-as-tool (parentToolCallId)', () => {
1788
1916
  // PR #4602: a flow running as a tool enriches its streamed text/reasoning with
1789
1917
  // toolContext.toolId; the unified wire surfaces it as text_start/reasoning_start
package/src/client.ts CHANGED
@@ -1969,6 +1969,15 @@ export class AgentWidgetClient {
1969
1969
  // Execution kind, resolved from the leading `execution_start` frame. Drives
1970
1970
  // the agent-vs-flow branches that the single unified vocabulary collapses.
1971
1971
  let executionKind: "agent" | "flow" = "agent";
1972
+ // Whether `executionKind` was set authoritatively by an `execution_start`
1973
+ // frame. Continuation streams (e.g. a tool-driven `/resume`) do NOT re-emit
1974
+ // `execution_start`, so a fresh `streamResponse` for the continuation starts
1975
+ // with the default `"agent"`. For a flow that mis-routes the final
1976
+ // prompt-step finalization and duplicates the last message (the streamed
1977
+ // text block is sealed, then `step_complete.result.response` re-renders it as
1978
+ // a second bubble). When `execution_start` is absent we recover the flow kind
1979
+ // from the first flow `step_*` frame below.
1980
+ let executionKindResolved = false;
1972
1981
  // Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
1973
1982
  // own block id, not the turn id, so the turn id is threaded onto agentMetadata
1974
1983
  // from here.
@@ -1987,6 +1996,21 @@ export class AgentWidgetClient {
1987
1996
  const payloadType = seqReadyQueue[i].payloadType;
1988
1997
  const payload = seqReadyQueue[i].payload;
1989
1998
 
1999
+ // Recover the execution kind on continuation streams that omit
2000
+ // `execution_start` (e.g. a tool-driven `/resume`). Flow `step_*` frames
2001
+ // carry a `stepType`; agent loops never do (they use `turn_*`). Without
2002
+ // this, the continuation defaults to `"agent"` and a flow's final
2003
+ // prompt-step finalization is duplicated. We only infer when no
2004
+ // `execution_start` resolved the kind, so an explicit `agent` is never
2005
+ // overridden.
2006
+ if (
2007
+ !executionKindResolved &&
2008
+ executionKind !== "flow" &&
2009
+ typeof (payload as { stepType?: unknown }).stepType === "string"
2010
+ ) {
2011
+ executionKind = "flow";
2012
+ }
2013
+
1990
2014
  if (payloadType === "reasoning_start") {
1991
2015
  // Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
1992
2016
  const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
@@ -2543,6 +2567,7 @@ export class AgentWidgetClient {
2543
2567
  // ================================================================
2544
2568
  } else if (payloadType === "execution_start") {
2545
2569
  executionKind = payload.kind === "flow" ? "flow" : "agent";
2570
+ executionKindResolved = true;
2546
2571
  if (executionKind === "agent") {
2547
2572
  agentExecution = {
2548
2573
  executionId: payload.executionId,