@nextclaw/ui 0.9.14 → 0.9.16

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/dist/assets/ChannelsList-DhM0gvDV.js +1 -0
  4. package/dist/assets/ChatPage-4niJBFCu.js +41 -0
  5. package/dist/assets/DocBrowser-DpXDQNhb.js +1 -0
  6. package/dist/assets/LogoBadge-nqabOtgk.js +1 -0
  7. package/dist/assets/MarketplacePage-CrkTftqZ.js +49 -0
  8. package/dist/assets/McpMarketplacePage-DH1qKJqo.js +40 -0
  9. package/dist/assets/ModelConfig-CrrxPK_y.js +1 -0
  10. package/dist/assets/ProvidersList-BG36JlSJ.js +1 -0
  11. package/dist/assets/RemoteAccessPage-Dcj2Pzpt.js +1 -0
  12. package/dist/assets/RuntimeConfig-BrxgUzjJ.js +1 -0
  13. package/dist/assets/SearchConfig-D-NLwowp.js +1 -0
  14. package/dist/assets/SecretsConfig-DjNqBB05.js +3 -0
  15. package/dist/assets/SessionsConfig-DdlsWXQc.js +2 -0
  16. package/dist/assets/chat-message-B7THd1Mh.js +3 -0
  17. package/dist/assets/config-hints-CApS3K_7.js +1 -0
  18. package/dist/assets/config-layout-BHnOoweL.js +1 -0
  19. package/dist/assets/index-CgqD0Jfg.js +8 -0
  20. package/dist/assets/index-UC08nscf.css +1 -0
  21. package/dist/assets/label-B-TkPZRF.js +1 -0
  22. package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
  23. package/dist/assets/page-layout-BTVBRo6H.js +1 -0
  24. package/dist/assets/popover-DBZvpGcL.js +1 -0
  25. package/dist/assets/provider-models-BOeNnjk9.js +1 -0
  26. package/dist/assets/security-config-DotxwVFR.js +1 -0
  27. package/dist/assets/skeleton-DGtduHZV.js +1 -0
  28. package/dist/assets/status-dot-BCUTVN2R.js +1 -0
  29. package/dist/assets/switch-Bp2mda29.js +1 -0
  30. package/dist/assets/tabs-custom-BE8yZ2kE.js +1 -0
  31. package/dist/assets/useConfirmDialog-DCy-eYnV.js +1 -0
  32. package/dist/assets/vendor-DJt0Azq5.js +451 -0
  33. package/dist/index.html +18 -0
  34. package/dist/logo.svg +5 -0
  35. package/dist/logos/aihubmix.png +0 -0
  36. package/dist/logos/anthropic.svg +1 -0
  37. package/dist/logos/dashscope.png +0 -0
  38. package/dist/logos/deepseek.png +0 -0
  39. package/dist/logos/dingtalk.svg +1 -0
  40. package/dist/logos/discord.svg +1 -0
  41. package/dist/logos/email.svg +1 -0
  42. package/dist/logos/feishu.svg +12 -0
  43. package/dist/logos/gemini.svg +1 -0
  44. package/dist/logos/groq.svg +1 -0
  45. package/dist/logos/minimax.svg +1 -0
  46. package/dist/logos/mochat.svg +6 -0
  47. package/dist/logos/moonshot.png +0 -0
  48. package/dist/logos/openai.svg +1 -0
  49. package/dist/logos/openrouter.svg +1 -0
  50. package/dist/logos/qq.svg +1 -0
  51. package/dist/logos/slack.svg +1 -0
  52. package/dist/logos/telegram.svg +1 -0
  53. package/dist/logos/vllm.svg +1 -0
  54. package/dist/logos/wecom.svg +11 -0
  55. package/dist/logos/weixin.svg +5 -0
  56. package/dist/logos/whatsapp.svg +1 -0
  57. package/dist/logos/zhipu.svg +15 -0
  58. package/package.json +16 -17
  59. package/src/api/channel-auth.ts +35 -0
  60. package/src/api/channel-auth.types.ts +28 -0
  61. package/src/api/types.ts +7 -26
  62. package/src/components/chat/chat-stream/transport.ts +42 -2
  63. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +0 -9
  64. package/src/components/chat/ncp/ncp-app-client-fetch.ts +0 -10
  65. package/src/components/config/ChannelForm.tsx +41 -128
  66. package/src/components/config/ChannelsList.test.tsx +71 -10
  67. package/src/components/config/channel-form-fields-section.tsx +155 -0
  68. package/src/components/config/weixin-channel-auth-section.tsx +242 -0
  69. package/src/hooks/use-channel-auth.ts +16 -0
  70. package/src/lib/i18n.channel-auth.ts +37 -0
  71. package/src/lib/i18n.ts +2 -4
  72. package/src/transport/local.transport.ts +5 -7
  73. package/src/transport/remote.transport.ts +8 -7
  74. package/src/transport/sse-stream.test.ts +5 -19
  75. package/src/transport/sse-stream.ts +5 -60
  76. package/src/transport/transport.types.ts +0 -2
@@ -0,0 +1,242 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { Button } from '@/components/ui/button';
4
+ import { formatDateTime, t } from '@/lib/i18n';
5
+ import { cn } from '@/lib/utils';
6
+ import { toast } from 'sonner';
7
+ import { ExternalLink, Loader2, MessageCircleMore, QrCode } from 'lucide-react';
8
+ import { usePollChannelAuth, useStartChannelAuth } from '@/hooks/use-channel-auth';
9
+ import type { ChannelAuthPollResult, ChannelAuthStartResult } from '@/api/channel-auth.types';
10
+
11
+ type WeixinChannelAuthSectionProps = {
12
+ channelConfig: Record<string, unknown>;
13
+ formData: Record<string, unknown>;
14
+ disabled?: boolean;
15
+ };
16
+
17
+ function resolveConnectedAccountIds(channelConfig: Record<string, unknown>): string[] {
18
+ const accounts = channelConfig.accounts;
19
+ const ids = new Set<string>();
20
+ if (typeof channelConfig.defaultAccountId === 'string' && channelConfig.defaultAccountId.trim()) {
21
+ ids.add(channelConfig.defaultAccountId.trim());
22
+ }
23
+ if (accounts && typeof accounts === 'object' && !Array.isArray(accounts)) {
24
+ for (const accountId of Object.keys(accounts)) {
25
+ const trimmed = accountId.trim();
26
+ if (trimmed) {
27
+ ids.add(trimmed);
28
+ }
29
+ }
30
+ }
31
+ return [...ids];
32
+ }
33
+
34
+ function resolveBaseUrl(formData: Record<string, unknown>, channelConfig: Record<string, unknown>): string | undefined {
35
+ if (typeof formData.baseUrl === 'string' && formData.baseUrl.trim()) {
36
+ return formData.baseUrl.trim();
37
+ }
38
+ if (typeof channelConfig.baseUrl === 'string' && channelConfig.baseUrl.trim()) {
39
+ return channelConfig.baseUrl.trim();
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ export function WeixinChannelAuthSection({
45
+ channelConfig,
46
+ formData,
47
+ disabled = false
48
+ }: WeixinChannelAuthSectionProps) {
49
+ const queryClient = useQueryClient();
50
+ const startChannelAuth = useStartChannelAuth();
51
+ const pollChannelAuth = usePollChannelAuth();
52
+ const [activeSession, setActiveSession] = useState<ChannelAuthStartResult | null>(null);
53
+ const [authState, setAuthState] = useState<ChannelAuthPollResult | null>(null);
54
+
55
+ const connectedAccountIds = useMemo(() => resolveConnectedAccountIds(channelConfig), [channelConfig]);
56
+ const primaryAccountId = connectedAccountIds[0];
57
+ const baseUrl = resolveBaseUrl(formData, channelConfig);
58
+ const hasConnectedAccount = connectedAccountIds.length > 0;
59
+
60
+ useEffect(() => {
61
+ if (!activeSession) {
62
+ return;
63
+ }
64
+
65
+ let cancelled = false;
66
+ let timer: ReturnType<typeof setTimeout> | null = null;
67
+
68
+ const runPoll = async () => {
69
+ try {
70
+ const result = await pollChannelAuth.mutateAsync({
71
+ channel: 'weixin',
72
+ data: { sessionId: activeSession.sessionId }
73
+ });
74
+ if (cancelled) {
75
+ return;
76
+ }
77
+
78
+ setAuthState(result);
79
+
80
+ if (result.status === 'authorized') {
81
+ await queryClient.invalidateQueries({ queryKey: ['config'] });
82
+ await queryClient.invalidateQueries({ queryKey: ['config-meta'] });
83
+ toast.success(result.message || t('weixinAuthAuthorized'));
84
+ setActiveSession(null);
85
+ return;
86
+ }
87
+
88
+ if (result.status === 'expired' || result.status === 'error') {
89
+ toast.error(result.message || t('weixinAuthRetryRequired'));
90
+ setActiveSession(null);
91
+ return;
92
+ }
93
+
94
+ timer = setTimeout(runPoll, result.nextPollMs ?? activeSession.intervalMs);
95
+ } catch (error) {
96
+ if (cancelled) {
97
+ return;
98
+ }
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ toast.error(`${t('error')}: ${message}`);
101
+ setActiveSession(null);
102
+ }
103
+ };
104
+
105
+ timer = setTimeout(runPoll, activeSession.intervalMs);
106
+
107
+ return () => {
108
+ cancelled = true;
109
+ if (timer) {
110
+ clearTimeout(timer);
111
+ }
112
+ };
113
+ }, [activeSession, pollChannelAuth, queryClient]);
114
+
115
+ const handleStartAuth = async () => {
116
+ try {
117
+ const result = await startChannelAuth.mutateAsync({
118
+ channel: 'weixin',
119
+ data: {
120
+ baseUrl,
121
+ accountId:
122
+ typeof formData.defaultAccountId === 'string' && formData.defaultAccountId.trim()
123
+ ? formData.defaultAccountId.trim()
124
+ : undefined
125
+ }
126
+ });
127
+ setActiveSession(result);
128
+ setAuthState({
129
+ channel: 'weixin',
130
+ status: 'pending',
131
+ message: result.note,
132
+ nextPollMs: result.intervalMs
133
+ });
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ toast.error(`${t('error')}: ${message}`);
137
+ }
138
+ };
139
+
140
+ const statusLabel = activeSession
141
+ ? authState?.status === 'scanned'
142
+ ? t('weixinAuthScanned')
143
+ : t('weixinAuthWaiting')
144
+ : hasConnectedAccount
145
+ ? t('weixinAuthAuthorized')
146
+ : t('weixinAuthNotConnected');
147
+
148
+ const connectButtonLabel = startChannelAuth.isPending
149
+ ? t('weixinAuthStarting')
150
+ : activeSession
151
+ ? t('weixinAuthWaiting')
152
+ : hasConnectedAccount
153
+ ? t('weixinAuthReconnect')
154
+ : t('weixinAuthConnect');
155
+
156
+ return (
157
+ <section className="rounded-2xl border border-primary/20 bg-gradient-to-br from-primary-50/70 via-white to-emerald-50/60 p-5">
158
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
159
+ <div className="space-y-3">
160
+ <div className="inline-flex items-center gap-2 rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-primary shadow-sm">
161
+ <QrCode className="h-3.5 w-3.5" />
162
+ {t('weixinAuthTitle')}
163
+ </div>
164
+ <div>
165
+ <h4 className="text-base font-semibold text-gray-900">{t('weixinAuthDescription')}</h4>
166
+ <p className="mt-1 text-sm text-gray-600">{t('weixinAuthHint')}</p>
167
+ </div>
168
+ <div
169
+ className={cn(
170
+ 'inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium',
171
+ activeSession
172
+ ? 'bg-amber-50 text-amber-700'
173
+ : hasConnectedAccount
174
+ ? 'bg-emerald-50 text-emerald-700'
175
+ : 'bg-gray-100 text-gray-600'
176
+ )}
177
+ >
178
+ {activeSession ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MessageCircleMore className="h-3.5 w-3.5" />}
179
+ {statusLabel}
180
+ </div>
181
+ <div className="space-y-1 text-sm text-gray-600">
182
+ <p>{t('weixinAuthCapabilityHint')}</p>
183
+ {primaryAccountId ? (
184
+ <p>
185
+ {t('weixinAuthPrimaryAccount')}: <span className="font-mono text-xs text-gray-900">{primaryAccountId}</span>
186
+ </p>
187
+ ) : null}
188
+ {connectedAccountIds.length > 1 ? (
189
+ <p>
190
+ {t('weixinAuthConnectedAccounts')}: <span className="font-mono text-xs text-gray-900">{connectedAccountIds.join(', ')}</span>
191
+ </p>
192
+ ) : null}
193
+ {baseUrl ? (
194
+ <p>
195
+ {t('weixinAuthBaseUrl')}: <span className="font-mono text-xs text-gray-900">{baseUrl}</span>
196
+ </p>
197
+ ) : null}
198
+ </div>
199
+ <Button
200
+ type="button"
201
+ onClick={handleStartAuth}
202
+ disabled={disabled || startChannelAuth.isPending || Boolean(activeSession)}
203
+ className="rounded-xl"
204
+ >
205
+ {connectButtonLabel}
206
+ </Button>
207
+ </div>
208
+
209
+ <div className="w-full max-w-sm rounded-2xl border border-dashed border-primary/25 bg-white/85 p-4 shadow-sm">
210
+ {activeSession ? (
211
+ <div className="space-y-3">
212
+ <div className="overflow-hidden rounded-2xl border border-gray-100 bg-white p-3">
213
+ <img src={activeSession.qrCodeUrl} alt={t('weixinAuthQrAlt')} className="mx-auto aspect-square w-full max-w-[240px] object-contain" />
214
+ </div>
215
+ <div className="space-y-1 text-xs text-gray-500">
216
+ <p>{authState?.message || activeSession.note || t('weixinAuthScanPrompt')}</p>
217
+ <p>
218
+ {t('weixinAuthExpiresAt')}: {formatDateTime(activeSession.expiresAt)}
219
+ </p>
220
+ </div>
221
+ <a
222
+ href={activeSession.qrCodeUrl}
223
+ target="_blank"
224
+ rel="noreferrer"
225
+ className="inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover"
226
+ >
227
+ <ExternalLink className="h-3.5 w-3.5" />
228
+ {t('weixinAuthOpenQr')}
229
+ </a>
230
+ </div>
231
+ ) : (
232
+ <div className="flex min-h-[280px] flex-col items-center justify-center rounded-2xl bg-gray-50/80 px-6 text-center">
233
+ <QrCode className="h-9 w-9 text-gray-300" />
234
+ <p className="mt-3 text-sm font-medium text-gray-700">{t('weixinAuthReadyTitle')}</p>
235
+ <p className="mt-1 text-xs leading-5 text-gray-500">{t('weixinAuthReadyDescription')}</p>
236
+ </div>
237
+ )}
238
+ </div>
239
+ </div>
240
+ </section>
241
+ );
242
+ }
@@ -0,0 +1,16 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { pollChannelAuth, startChannelAuth } from '@/api/channel-auth';
3
+
4
+ export function useStartChannelAuth() {
5
+ return useMutation({
6
+ mutationFn: ({ channel, data }: { channel: string; data?: unknown }) =>
7
+ startChannelAuth(channel, data as Parameters<typeof startChannelAuth>[1])
8
+ });
9
+ }
10
+
11
+ export function usePollChannelAuth() {
12
+ return useMutation({
13
+ mutationFn: ({ channel, data }: { channel: string; data: unknown }) =>
14
+ pollChannelAuth(channel, data as Parameters<typeof pollChannelAuth>[1])
15
+ });
16
+ }
@@ -0,0 +1,37 @@
1
+ export const CHANNEL_AUTH_LABELS: Record<string, { zh: string; en: string }> = {
2
+ weixinAuthTitle: { zh: '扫码连接微信', en: 'Connect Weixin by QR' },
3
+ weixinAuthDescription: { zh: '微信渠道现在以扫码连接为主流程。', en: 'Weixin now uses QR login as the primary setup flow.' },
4
+ weixinAuthHint: {
5
+ zh: '通常只需要点击按钮并扫码确认,连接成功后会自动写入配置。',
6
+ en: 'In most cases you only need to start the flow, scan the QR code, and confirm on your phone. The config will be saved automatically.'
7
+ },
8
+ weixinAuthCapabilityHint: {
9
+ zh: '连接成功后,Agent 可以通过微信渠道向已知微信用户主动发消息。',
10
+ en: 'After connecting, the agent can proactively message known Weixin users through this channel.'
11
+ },
12
+ weixinAuthPrimaryAccount: { zh: '当前默认账号', en: 'Current default account' },
13
+ weixinAuthConnectedAccounts: { zh: '已连接账号', en: 'Connected accounts' },
14
+ weixinAuthBaseUrl: { zh: '当前接口地址', en: 'Current API base URL' },
15
+ weixinAuthConnect: { zh: '扫码连接微信', en: 'Scan QR to connect Weixin' },
16
+ weixinAuthReconnect: { zh: '重新扫码连接', en: 'Reconnect with QR' },
17
+ weixinAuthStarting: { zh: '正在生成二维码...', en: 'Generating QR code...' },
18
+ weixinAuthWaiting: { zh: '等待扫码确认', en: 'Waiting for scan confirmation' },
19
+ weixinAuthScanned: { zh: '已扫码,等待确认', en: 'Scanned, waiting for confirmation' },
20
+ weixinAuthAuthorized: { zh: '已连接', en: 'Connected' },
21
+ weixinAuthNotConnected: { zh: '未连接', en: 'Not connected' },
22
+ weixinAuthRetryRequired: { zh: '二维码已失效,请重新扫码。', en: 'QR session expired. Please start again.' },
23
+ weixinAuthQrAlt: { zh: '微信登录二维码', en: 'Weixin login QR code' },
24
+ weixinAuthScanPrompt: { zh: '请用微信扫码,并在手机上确认登录。', en: 'Scan with Weixin and confirm the login on your phone.' },
25
+ weixinAuthExpiresAt: { zh: '二维码过期时间', en: 'QR expires at' },
26
+ weixinAuthOpenQr: { zh: '新窗口打开二维码', en: 'Open QR code in new tab' },
27
+ weixinAuthReadyTitle: { zh: '准备连接微信', en: 'Ready to connect Weixin' },
28
+ weixinAuthReadyDescription: {
29
+ zh: '点击左侧按钮后,这里会显示二维码。整个首配流程默认不需要手动填写底层参数。',
30
+ en: 'After you start the flow, the QR code will appear here. Most first-time setups do not require filling low-level fields manually.'
31
+ },
32
+ weixinAuthAdvancedTitle: { zh: '高级设置', en: 'Advanced settings' },
33
+ weixinAuthAdvancedDescription: {
34
+ zh: '仅在你需要自定义接口地址、账号映射或白名单时再展开这些字段。',
35
+ en: 'Expand these fields only when you need to customize the API base URL, account mapping, or allowlist.'
36
+ }
37
+ };
package/src/lib/i18n.ts CHANGED
@@ -1,16 +1,13 @@
1
1
  import { CHANNEL_LABELS } from './i18n.channels';
2
+ import { CHANNEL_AUTH_LABELS } from './i18n.channel-auth';
2
3
  import { MARKETPLACE_LABELS } from './i18n.marketplace';
3
4
  import { REMOTE_LABELS } from './i18n.remote';
4
-
5
5
  export type I18nLanguage = 'zh' | 'en';
6
-
7
6
  const I18N_STORAGE_KEY = 'nextclaw.ui.language';
8
-
9
7
  export const LANGUAGE_OPTIONS: Array<{ value: I18nLanguage; label: string }> = [
10
8
  { value: 'en', label: 'English' },
11
9
  { value: 'zh', label: '中文' }
12
10
  ];
13
-
14
11
  const LANGUAGE_TO_LOCALE: Record<I18nLanguage, string> = {
15
12
  en: 'en-US',
16
13
  zh: 'zh-CN'
@@ -737,6 +734,7 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
737
734
  docBrowserNewTab: { zh: '新建标签', en: 'New Tab' },
738
735
  docBrowserCloseTab: { zh: '关闭标签', en: 'Close Tab' },
739
736
  docBrowserTabUntitled: { zh: '未命名', en: 'Untitled' },
737
+ ...CHANNEL_AUTH_LABELS,
740
738
  };
741
739
 
742
740
  export function t(key: string, lang: I18nLanguage = getLanguage()): string {
@@ -101,6 +101,7 @@ class LocalRealtimeGateway {
101
101
 
102
102
  export class LocalAppTransport implements AppTransport {
103
103
  private readonly realtimeGateway: LocalRealtimeGateway;
104
+ private readonly apiBase: string;
104
105
 
105
106
  constructor(
106
107
  private readonly options: {
@@ -108,8 +109,8 @@ export class LocalAppTransport implements AppTransport {
108
109
  wsPath?: string;
109
110
  } = {}
110
111
  ) {
111
- const apiBase = options.apiBase ?? API_BASE;
112
- this.realtimeGateway = new LocalRealtimeGateway(resolveTransportWebSocketUrl(apiBase, options.wsPath ?? '/ws'));
112
+ this.apiBase = options.apiBase ?? API_BASE;
113
+ this.realtimeGateway = new LocalRealtimeGateway(resolveTransportWebSocketUrl(this.apiBase, options.wsPath ?? '/ws'));
113
114
  }
114
115
 
115
116
  async request<T>(input: RequestInput): Promise<T> {
@@ -135,7 +136,7 @@ export class LocalAppTransport implements AppTransport {
135
136
  }
136
137
 
137
138
  const finished = (async () => {
138
- const response = await fetch(`${API_BASE}${input.path}`, {
139
+ const response = await fetch(`${this.apiBase}${input.path}`, {
139
140
  method: input.method,
140
141
  credentials: 'include',
141
142
  headers: {
@@ -151,10 +152,7 @@ export class LocalAppTransport implements AppTransport {
151
152
  throw new Error(text.trim() || `HTTP ${response.status}`);
152
153
  }
153
154
  try {
154
- return await readSseStreamResult<TFinal>(response, input.onEvent, {
155
- terminalEventNames: input.terminalEventNames,
156
- terminalEventPayloadTypes: input.terminalEventPayloadTypes
157
- });
155
+ return await readSseStreamResult<TFinal>(response, input.onEvent);
158
156
  } finally {
159
157
  input.signal?.removeEventListener('abort', abort);
160
158
  }
@@ -7,8 +7,6 @@ type RemoteTarget = {
7
7
  method: string;
8
8
  path: string;
9
9
  body?: unknown;
10
- terminalEventNames?: readonly string[];
11
- terminalEventPayloadTypes?: Partial<Record<string, readonly string[]>>;
12
10
  };
13
11
 
14
12
  type RemoteBrowserFrame =
@@ -16,7 +14,7 @@ type RemoteBrowserFrame =
16
14
  | { type: 'response'; id: string; status: number; body?: unknown }
17
15
  | { type: 'request.error'; id: string; message: string; code?: string }
18
16
  | { type: 'stream.event'; streamId: string; event: string; payload?: unknown }
19
- | { type: 'stream.end'; streamId: string; result?: unknown }
17
+ | { type: 'stream.end'; streamId: string }
20
18
  | { type: 'stream.error'; streamId: string; message: string; code?: string }
21
19
  | { type: 'event'; event: AppEvent }
22
20
  | { type: 'connection.error'; message: string; code?: string };
@@ -33,6 +31,7 @@ type PendingRequest = {
33
31
 
34
32
  type PendingStream = {
35
33
  onEvent: StreamInput['onEvent'];
34
+ finalResult: unknown;
36
35
  resolve: (value: unknown) => void;
37
36
  reject: (error: Error) => void;
38
37
  };
@@ -133,6 +132,7 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
133
132
 
134
133
  this.pendingStreams.set(streamId, {
135
134
  onEvent: input.onEvent,
135
+ finalResult: undefined,
136
136
  resolve: (value) => {
137
137
  if (settled) {
138
138
  return;
@@ -169,9 +169,7 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
169
169
  target: {
170
170
  method: input.method,
171
171
  path: input.path,
172
- ...(input.body !== undefined ? { body: input.body } : {}),
173
- ...(input.terminalEventNames ? { terminalEventNames: input.terminalEventNames } : {}),
174
- ...(input.terminalEventPayloadTypes ? { terminalEventPayloadTypes: input.terminalEventPayloadTypes } : {})
172
+ ...(input.body !== undefined ? { body: input.body } : {})
175
173
  }
176
174
  });
177
175
  })
@@ -355,6 +353,9 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
355
353
  }
356
354
  if (frame.type === 'stream.event') {
357
355
  try {
356
+ if (frame.event === 'final') {
357
+ pending.finalResult = frame.payload;
358
+ }
358
359
  pending.onEvent({ name: frame.event, payload: frame.payload });
359
360
  } catch (error) {
360
361
  this.pendingStreams.delete(frame.streamId);
@@ -364,7 +365,7 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
364
365
  }
365
366
  this.pendingStreams.delete(frame.streamId);
366
367
  if (frame.type === 'stream.end') {
367
- pending.resolve(frame.result);
368
+ pending.resolve(pending.finalResult);
368
369
  return;
369
370
  }
370
371
  pending.reject(new Error(frame.message));
@@ -22,24 +22,22 @@ function encodeFrame(event: string, payload: unknown): string {
22
22
  }
23
23
 
24
24
  describe('readSseStreamResult', () => {
25
- it('accepts terminal-event streams without final frame when configured', async () => {
25
+ it('preserves final frames for callers while still resolving with the final payload', async () => {
26
26
  const events: Array<{ name: string; payload?: unknown }> = [];
27
27
  const response = createSseResponse([
28
28
  encodeFrame('ncp-event', { type: 'message.text-delta', payload: { delta: 'hello' } }),
29
- encodeFrame('run.finished', { sessionId: 's1' })
29
+ encodeFrame('final', { sessionId: 's1', reply: 'hello' })
30
30
  ]);
31
31
 
32
32
  const result = await readSseStreamResult(response, (event) => {
33
33
  events.push(event);
34
- }, {
35
- terminalEventNames: ['run.finished']
36
34
  });
37
35
 
38
- expect(result).toBeUndefined();
39
- expect(events.map((event) => event.name)).toEqual(['ncp-event', 'run.finished']);
36
+ expect(result).toEqual({ sessionId: 's1', reply: 'hello' });
37
+ expect(events.map((event) => event.name)).toEqual(['ncp-event', 'final']);
40
38
  });
41
39
 
42
- it('accepts terminal payload-type streams without final frame when configured', async () => {
40
+ it('allows passthrough SSE streams to end without a final frame', async () => {
43
41
  const events: Array<{ name: string; payload?: unknown }> = [];
44
42
  const response = createSseResponse([
45
43
  encodeFrame('ncp-event', { type: 'message.text-delta', payload: { delta: 'hello' } }),
@@ -48,21 +46,9 @@ describe('readSseStreamResult', () => {
48
46
 
49
47
  const result = await readSseStreamResult(response, (event) => {
50
48
  events.push(event);
51
- }, {
52
- terminalEventPayloadTypes: {
53
- 'ncp-event': ['run.finished']
54
- }
55
49
  });
56
50
 
57
51
  expect(result).toBeUndefined();
58
52
  expect(events.map((event) => event.name)).toEqual(['ncp-event', 'ncp-event']);
59
53
  });
60
-
61
- it('still rejects streams that end without final or configured terminal event', async () => {
62
- const response = createSseResponse([
63
- encodeFrame('ncp-event', { type: 'message.text-delta', payload: { delta: 'hello' } })
64
- ]);
65
-
66
- await expect(readSseStreamResult(response, () => undefined)).rejects.toThrow('stream ended without final event');
67
- });
68
54
  });
@@ -1,12 +1,6 @@
1
1
  import type { StreamEvent } from './transport.types';
2
2
 
3
- type SseErrorPayload = { message?: string } | string | undefined;
4
3
  type FinalResultSink = (value: unknown) => void;
5
- type TerminalEventSink = (frame: StreamEvent) => void;
6
- type TerminalDetectionOptions = {
7
- terminalEventNames?: readonly string[];
8
- terminalEventPayloadTypes?: Partial<Record<string, readonly string[]>>;
9
- };
10
4
 
11
5
  function parseSseFrame(frame: string): StreamEvent | null {
12
6
  const lines = frame.split('\n');
@@ -42,35 +36,10 @@ function parseSseFrame(frame: string): StreamEvent | null {
42
36
  return { name, payload };
43
37
  }
44
38
 
45
- function readSseErrorMessage(payload: SseErrorPayload, fallback: string): string {
46
- return typeof payload === 'string'
47
- ? payload
48
- : payload?.message ?? fallback;
49
- }
50
-
51
- function matchesTerminalFrame(frame: StreamEvent, options: TerminalDetectionOptions): boolean {
52
- if ((options.terminalEventNames ?? []).includes(frame.name)) {
53
- return true;
54
- }
55
- const payloadTypes = options.terminalEventPayloadTypes?.[frame.name];
56
- if (!payloadTypes || payloadTypes.length === 0) {
57
- return false;
58
- }
59
- const payloadType =
60
- typeof frame.payload === 'object' &&
61
- frame.payload &&
62
- 'type' in frame.payload &&
63
- typeof (frame.payload as { type?: unknown }).type === 'string'
64
- ? (frame.payload as { type: string }).type
65
- : null;
66
- return payloadType !== null && payloadTypes.includes(payloadType);
67
- }
68
-
69
39
  function processSseFrame(
70
40
  rawFrame: string,
71
41
  onEvent: (event: StreamEvent) => void,
72
- setFinalResult: FinalResultSink,
73
- setTerminalEvent: TerminalEventSink
42
+ setFinalResult: FinalResultSink
74
43
  ): void {
75
44
  const frame = parseSseFrame(rawFrame);
76
45
  if (!frame) {
@@ -78,24 +47,18 @@ function processSseFrame(
78
47
  }
79
48
  if (frame.name === 'final') {
80
49
  setFinalResult(frame.payload);
81
- return;
82
- }
83
- if (frame.name === 'error') {
84
- throw new Error(readSseErrorMessage(frame.payload as SseErrorPayload, 'chat stream failed'));
85
50
  }
86
- setTerminalEvent(frame);
87
51
  onEvent(frame);
88
52
  }
89
53
 
90
54
  function flushBufferedFrames(
91
55
  bufferState: { value: string },
92
56
  onEvent: (event: StreamEvent) => void,
93
- setFinalResult: FinalResultSink,
94
- setTerminalEvent: TerminalEventSink
57
+ setFinalResult: FinalResultSink
95
58
  ): void {
96
59
  let boundary = bufferState.value.indexOf('\n\n');
97
60
  while (boundary !== -1) {
98
- processSseFrame(bufferState.value.slice(0, boundary), onEvent, setFinalResult, setTerminalEvent);
61
+ processSseFrame(bufferState.value.slice(0, boundary), onEvent, setFinalResult);
99
62
  bufferState.value = bufferState.value.slice(boundary + 2);
100
63
  boundary = bufferState.value.indexOf('\n\n');
101
64
  }
@@ -103,8 +66,7 @@ function flushBufferedFrames(
103
66
 
104
67
  export async function readSseStreamResult<TFinal>(
105
68
  response: Response,
106
- onEvent: (event: StreamEvent) => void,
107
- options: TerminalDetectionOptions = {}
69
+ onEvent: (event: StreamEvent) => void
108
70
  ): Promise<TFinal> {
109
71
  const reader = response.body?.getReader();
110
72
  if (!reader) {
@@ -114,7 +76,6 @@ export async function readSseStreamResult<TFinal>(
114
76
  const decoder = new TextDecoder();
115
77
  const bufferState = { value: '' };
116
78
  let finalResult: unknown = undefined;
117
- let sawTerminalEvent = false;
118
79
  try {
119
80
  while (true) {
120
81
  const { value, done } = await reader.read();
@@ -124,32 +85,16 @@ export async function readSseStreamResult<TFinal>(
124
85
  bufferState.value += decoder.decode(value, { stream: true });
125
86
  flushBufferedFrames(bufferState, onEvent, (nextValue) => {
126
87
  finalResult = nextValue;
127
- }, (event) => {
128
- if (matchesTerminalFrame(event, options)) {
129
- sawTerminalEvent = true;
130
- }
131
88
  });
132
89
  }
133
90
  if (bufferState.value.trim()) {
134
- processSseFrame(bufferState.value, (event) => {
135
- if (matchesTerminalFrame(event, options)) {
136
- sawTerminalEvent = true;
137
- }
138
- onEvent(event);
139
- }, (nextValue) => {
91
+ processSseFrame(bufferState.value, onEvent, (nextValue) => {
140
92
  finalResult = nextValue;
141
- }, (event) => {
142
- if (matchesTerminalFrame(event, options)) {
143
- sawTerminalEvent = true;
144
- }
145
93
  });
146
94
  }
147
95
  } finally {
148
96
  reader.releaseLock();
149
97
  }
150
98
 
151
- if (finalResult === undefined && !sawTerminalEvent) {
152
- throw new Error('stream ended without final event');
153
- }
154
99
  return finalResult as TFinal;
155
100
  }
@@ -18,8 +18,6 @@ export type StreamInput = {
18
18
  path: string;
19
19
  body?: unknown;
20
20
  signal?: AbortSignal;
21
- terminalEventNames?: readonly string[];
22
- terminalEventPayloadTypes?: Partial<Record<string, readonly string[]>>;
23
21
  onEvent: (event: StreamEvent) => void;
24
22
  };
25
23