@veltra/ai 2.2.1 → 2.2.3

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 (41) hide show
  1. package/dist/chat/fold.js +14 -2
  2. package/dist/chat/fold.js.map +1 -1
  3. package/dist/chat/session.d.ts +13 -6
  4. package/dist/chat/session.js.map +1 -1
  5. package/dist/chat/transports/openai.d.ts +2 -1
  6. package/dist/chat/transports/openai.js +20 -213
  7. package/dist/chat/transports/openai.js.map +1 -1
  8. package/dist/chat/use-chat-session.js +3 -13
  9. package/dist/chat/use-chat-session.js.map +1 -1
  10. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  11. package/dist/components/ai-chat/approval-banner.js.map +1 -1
  12. package/dist/components/ai-chat/ask-question.js.map +1 -1
  13. package/dist/components/ai-chat/chat-input.js.map +1 -1
  14. package/dist/components/ai-chat/job-bar.js.map +1 -1
  15. package/dist/components/ai-chat/message-item.js.map +1 -1
  16. package/dist/components/ai-chat/message-list.js.map +1 -1
  17. package/dist/components/ai-chat/model-picker.js.map +1 -1
  18. package/dist/components/ai-chat/queue-list.js.map +1 -1
  19. package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
  20. package/dist/components/ai-chat/side-panel.js.map +1 -1
  21. package/dist/components/ai-chat/tool-call.js.map +1 -1
  22. package/dist/components/ai-chat/tool-json.js.map +1 -1
  23. package/dist/components/ai-chat/turn-process.js.map +1 -1
  24. package/dist/components/ai-orb/ai-orb.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
  27. package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
  28. package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
  29. package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
  30. package/src/chat/__test__/fold.test.ts +26 -1
  31. package/src/chat/__test__/session.test.ts +1 -1
  32. package/src/chat/__test__/use-chat.test.ts +6 -3
  33. package/src/chat/fold.ts +16 -1
  34. package/src/chat/session.ts +13 -6
  35. package/src/chat/transports/__test__/openai.test.ts +1 -1
  36. package/src/chat/transports/openai.ts +25 -288
  37. package/src/chat/use-chat-session.ts +4 -5
  38. package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
  39. package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
  40. package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
  41. package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
@@ -0,0 +1,735 @@
1
+ /**
2
+ * Vendored from bedrock web/src/api(自包含快照,避免测试依赖仓库外绝对路径)。
3
+ * 上游更新时同步此处;依赖面:@veltra/ai 类型 + ./http + ./types。
4
+ */
5
+ import type { ChatSessionAdapter, ChatSessionEvent } from '@veltra/ai'
6
+
7
+ import { getAccessToken, http } from './http'
8
+ import type { PageResult } from './types'
9
+
10
+ /**
11
+ * Harness session domain client (api/harness.md): REST endpoints plus the
12
+ * /ws/harness/sessions/:id/events unified-frame stream, and a
13
+ * ChatSessionAdapter that feeds bedrock frames into @veltra/ai session mode.
14
+ */
15
+
16
+ // --- REST shapes ---
17
+
18
+ export interface HarnessModelRef {
19
+ provider: string
20
+ id: string
21
+ }
22
+
23
+ export interface HarnessPromptAck {
24
+ id: string
25
+ admitted_seq: number
26
+ }
27
+
28
+ /** Content part of GET /harness/sessions/:id/messages (provider passthrough). */
29
+ export type HarnessMessagePart =
30
+ | { type: 'text'; id?: string; text: string }
31
+ | { type: 'reasoning'; id?: string; text: string }
32
+ | {
33
+ type: 'tool'
34
+ id?: string
35
+ callID: string
36
+ name?: string
37
+ state: {
38
+ status: 'pending' | 'running' | 'completed' | 'error'
39
+ input?: unknown
40
+ output?: string
41
+ error?: string
42
+ }
43
+ }
44
+
45
+ export interface HarnessMessage {
46
+ id: string
47
+ role: string
48
+ agent?: string
49
+ model?: HarnessModelRef
50
+ content?: HarnessMessagePart[]
51
+ }
52
+
53
+ // --- WS unified frame (api/harness.md HarnessFrame) ---
54
+
55
+ export type HarnessFrameKind =
56
+ | 'status'
57
+ | 'message_delta'
58
+ | 'message_text'
59
+ | 'reasoning_delta'
60
+ | 'tool_call'
61
+ | 'tool_result'
62
+ | 'permission'
63
+ | 'question'
64
+
65
+ export interface HarnessFrame {
66
+ seq: number
67
+ eventId?: string
68
+ sessionId: string
69
+ kind: HarnessFrameKind
70
+ status?: {
71
+ name:
72
+ | 'prompt_admitted'
73
+ | 'prompted'
74
+ | 'step_started'
75
+ | 'step_ended'
76
+ | 'step_failed'
77
+ | 'error'
78
+ | 'idle'
79
+ messageId?: string
80
+ assistantMessageId?: string
81
+ error?: string
82
+ }
83
+ messageDelta?: { assistantMessageId: string; textId: string; delta: string }
84
+ messageText?: { assistantMessageId: string; textId: string; text: string }
85
+ reasoningDelta?: { assistantMessageId: string; delta: string }
86
+ toolCall?: { assistantMessageId: string; callId: string; tool: string; input?: unknown }
87
+ toolResult?: { assistantMessageId: string; callId: string; output?: unknown; error?: string }
88
+ permission?: { requestId: string; action: string; resources?: string[]; resolved?: string }
89
+ question?: {
90
+ requestId: string
91
+ questions: { question: string; options?: string[] }[]
92
+ resolved?: string
93
+ }
94
+ }
95
+
96
+ const MESSAGES_PAGE_SIZE = 100
97
+ /** Bounds the history page loop against a misbehaving server. */
98
+ const MAX_HISTORY_PAGES = 100
99
+
100
+ export async function listSessionMessages(sessionId: string): Promise<HarnessMessage[]> {
101
+ const items: HarnessMessage[] = []
102
+ let page = 1
103
+ for (let i = 0; i < MAX_HISTORY_PAGES; i++) {
104
+ const { body } = await http.get<PageResult<HarnessMessage>>(
105
+ `/harness/sessions/${sessionId}/messages`,
106
+ { query: { page, page_size: MESSAGES_PAGE_SIZE } }
107
+ )
108
+ items.push(...(body.items ?? []))
109
+ if (page >= (body.total_pages ?? 1)) break
110
+ page++
111
+ }
112
+ return items
113
+ }
114
+
115
+ export async function sendSessionMessage(
116
+ sessionId: string,
117
+ text: string,
118
+ delivery: 'queue' | 'steer' = 'queue'
119
+ ): Promise<HarnessPromptAck> {
120
+ const { body } = await http.post<HarnessPromptAck>(`/harness/sessions/${sessionId}/messages`, {
121
+ text,
122
+ delivery
123
+ })
124
+ return body
125
+ }
126
+
127
+ export async function interruptSession(sessionId: string): Promise<void> {
128
+ await http.post(`/harness/sessions/${sessionId}/interrupt`, {})
129
+ }
130
+
131
+ export async function replySessionPermission(
132
+ sessionId: string,
133
+ requestId: string,
134
+ reply: 'once' | 'always' | 'reject'
135
+ ): Promise<void> {
136
+ await http.post(`/harness/sessions/${sessionId}/permissions/${requestId}`, { reply })
137
+ }
138
+
139
+ export async function replySessionQuestion(
140
+ sessionId: string,
141
+ requestId: string,
142
+ answers: string[][]
143
+ ): Promise<void> {
144
+ await http.post(`/harness/sessions/${sessionId}/questions/${requestId}`, { answers })
145
+ }
146
+
147
+ export function harnessSessionEventsWSURL(sessionId: string, token: string, after = 0): string {
148
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
149
+ return `${proto}//${location.host}/ws/harness/sessions/${encodeURIComponent(sessionId)}/events?token=${encodeURIComponent(token)}&after=${after}`
150
+ }
151
+
152
+ // --- ChatSessionAdapter ---
153
+
154
+ function stringifyArg(value: unknown): string {
155
+ if (value == null) return ''
156
+ return typeof value === 'string' ? value : JSON.stringify(value)
157
+ }
158
+
159
+ type HarnessTextPart = Extract<HarnessMessagePart, { type: 'text' | 'reasoning' }>
160
+
161
+ function isTextPart(part: HarnessMessagePart): part is HarnessTextPart {
162
+ return part.type === 'text' || part.type === 'reasoning'
163
+ }
164
+
165
+ function textOfParts(parts: HarnessMessagePart[] | undefined, type: 'text' | 'reasoning'): string {
166
+ return (parts ?? [])
167
+ .filter(isTextPart)
168
+ .filter((part) => part.type === type)
169
+ .map((part) => part.text)
170
+ .join('')
171
+ }
172
+
173
+ /*
174
+ * Transient frames (deltas) and history-derived events omit the `seq` field
175
+ * on purpose: the transport-level seq gate drops any event whose seq does
176
+ * not advance the cursor, and transient frames carry seq 0. History messages
177
+ * have no seq at all, so the objects are cast at the emission sites.
178
+ */
179
+
180
+ export interface HarnessSessionAdapterOptions {
181
+ initialPrompt?: string
182
+ }
183
+
184
+ /**
185
+ * History-replay event: carries the `history: true` marker the fold uses to
186
+ * keep live streaming state authoritative (typed as an intersection until the
187
+ * installed @veltra/ai version ships the field natively).
188
+ */
189
+ type HistoryChatSessionEvent = ChatSessionEvent & { history?: boolean }
190
+
191
+ /** Synthetic user-message id seeding the run prompt before backend history has it. */
192
+ const SYNTHETIC_PROMPT_ID = 'run-initial-prompt'
193
+
194
+ function cleanUserContent(raw: string, initialPrompt?: string): string {
195
+ if (!initialPrompt) return raw
196
+ const trimmed = initialPrompt.trim()
197
+ if (trimmed && raw.startsWith(trimmed)) {
198
+ return initialPrompt
199
+ }
200
+ return raw
201
+ }
202
+
203
+ /**
204
+ * Bedrock harness session adapter for @veltra/ai session mode.
205
+ *
206
+ * History replay (REST messages) and the WS unified-frame stream fold through
207
+ * one runtime (createServerTransport); the seam between them is deduplicated
208
+ * here by message id / text part id / tool call id, and pending asks are
209
+ * idempotent by requestId (the WS handler re-sends them on connect).
210
+ */
211
+ export function createHarnessSessionAdapter(
212
+ sessionId: string,
213
+ options?: HarnessSessionAdapterOptions
214
+ ): ChatSessionAdapter {
215
+ let handlers: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void } | null = null
216
+ let ws: WebSocket | null = null
217
+ let disposed = true
218
+ /** WS frames are buffered until the initial history replay has been applied. */
219
+ let synced = false
220
+ const buffer: HarnessFrame[] = []
221
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined
222
+ let reconnectDelay = 1000
223
+ /** Highest durable seq delivered; reconnects resume the stream after it. */
224
+ let lastDurableSeq = 0
225
+
226
+ const seenUserIds = new Set<string>()
227
+ const knownTextKeys = new Set<string>()
228
+ const knownCallIds = new Set<string>()
229
+ const finalCallIds = new Set<string>()
230
+ const activePermissions = new Set<string>()
231
+ let activeQuestionId: string | null = null
232
+ /** message id -> finished text parts in arrival order. */
233
+ const textParts = new Map<string, { id: string; text: string }[]>()
234
+ /** message id -> accumulated reasoning text to prevent message_text wiping it out. */
235
+ const reasoningParts = new Map<string, string>()
236
+ /** Assistant messages already materialised in the fold (history or live). */
237
+ const seenAssistantIds = new Set<string>()
238
+ /**
239
+ * Live assistant messages still in fold `streaming` status. History replay
240
+ * emits `assistant/message` (done); live reasoning/tool steps only emit
241
+ * `assistant/chunk`, so these must be closed explicitly or they stay
242
+ * in-progress after the session is idle.
243
+ */
244
+ const openAssistantIds = new Set<string>()
245
+
246
+ function emit(event: ChatSessionEvent): void {
247
+ handlers?.onEvent(event)
248
+ }
249
+
250
+ function assistantContent(messageId: string): string {
251
+ return (textParts.get(messageId) ?? []).map((part) => part.text).join('')
252
+ }
253
+
254
+ /** Fold treats `assistant/message` as the done marker; `finish` only clears running. */
255
+ function finalizeAssistant(messageId: string): void {
256
+ if (!messageId || !openAssistantIds.has(messageId)) return
257
+ openAssistantIds.delete(messageId)
258
+ emit({
259
+ type: 'assistant/message',
260
+ messageId,
261
+ content: assistantContent(messageId),
262
+ reasoning: reasoningParts.get(messageId) || undefined
263
+ } as ChatSessionEvent)
264
+ }
265
+
266
+ function finalizeOpenAssistants(except?: string): void {
267
+ const ids: string[] = []
268
+ for (const id of openAssistantIds) {
269
+ if (id !== except) ids.push(id)
270
+ }
271
+ for (const id of ids) finalizeAssistant(id)
272
+ }
273
+
274
+ /**
275
+ * Open `messageId` as the sole in-progress assistant. Closes any other
276
+ * streaming thinking block first. `stub` creates an empty streaming
277
+ * message so a subsequent tool/call attaches to this id, not the previous.
278
+ */
279
+ function beginAssistant(messageId: string, stub = false): void {
280
+ if (!messageId) return
281
+ finalizeOpenAssistants(messageId)
282
+ if (!seenAssistantIds.has(messageId)) {
283
+ seenAssistantIds.add(messageId)
284
+ if (stub) {
285
+ emit({ type: 'assistant/chunk', messageId, delta: '' } as ChatSessionEvent)
286
+ }
287
+ }
288
+ openAssistantIds.add(messageId)
289
+ }
290
+
291
+ function onMessageText(messageId: string, textId: string, text: string, seq: number): void {
292
+ const key = `${messageId}:${textId}`
293
+ if (knownTextKeys.has(key)) return
294
+ knownTextKeys.add(key)
295
+ const parts = textParts.get(messageId) ?? []
296
+ const index = parts.findIndex((part) => part.id === textId)
297
+ if (index >= 0) parts[index] = { id: textId, text }
298
+ else parts.push({ id: textId, text })
299
+ textParts.set(messageId, parts)
300
+ beginAssistant(messageId)
301
+ emit({
302
+ type: 'assistant/message',
303
+ messageId,
304
+ seq,
305
+ content: parts.map((part) => part.text).join(''),
306
+ reasoning: reasoningParts.get(messageId) || undefined
307
+ })
308
+ // Text-ended already marks the fold message done.
309
+ openAssistantIds.delete(messageId)
310
+ }
311
+
312
+ function onFrame(frame: HarnessFrame): void {
313
+ if (!synced) {
314
+ buffer.push(frame)
315
+ return
316
+ }
317
+ deliverFrame(frame)
318
+ }
319
+
320
+ function deliverFrame(frame: HarnessFrame): void {
321
+ if (frame.seq > lastDurableSeq) lastDurableSeq = frame.seq
322
+ switch (frame.kind) {
323
+ case 'status': {
324
+ const status = frame.status
325
+ if (!status) return
326
+ switch (status.name) {
327
+ case 'prompt_admitted':
328
+ case 'prompted':
329
+ if (
330
+ status.messageId &&
331
+ !seenUserIds.has(status.messageId) &&
332
+ options?.initialPrompt?.trim()
333
+ ) {
334
+ // Only mark seen when we actually emit: otherwise history replay
335
+ // can still surface the composed prompt (workspace hint, etc.).
336
+ seenUserIds.add(status.messageId)
337
+ // The synthetic prompt turn may already be seeded for this run
338
+ // (chat mounted before the prompt hit backend history): adopting
339
+ // the id keeps later history replays quiet without emitting a
340
+ // duplicate user turn.
341
+ if (!seenUserIds.has(SYNTHETIC_PROMPT_ID)) {
342
+ emit({
343
+ type: 'user/message',
344
+ messageId: status.messageId,
345
+ content: options.initialPrompt
346
+ } as ChatSessionEvent)
347
+ }
348
+ }
349
+ emit({ type: 'running', running: true })
350
+ return
351
+ case 'step_started':
352
+ // Next LLM step: close leftover thinking so at most one block is in-progress.
353
+ finalizeOpenAssistants(status.assistantMessageId)
354
+ return
355
+ case 'step_ended':
356
+ if (status.assistantMessageId) finalizeAssistant(status.assistantMessageId)
357
+ else finalizeOpenAssistants()
358
+ return
359
+ case 'step_failed':
360
+ finalizeOpenAssistants()
361
+ emit({ type: 'error', code: 'step_failed', message: status.error || 'step failed' })
362
+ return
363
+ case 'error':
364
+ finalizeOpenAssistants()
365
+ emit({ type: 'error', code: 'harness_error', message: status.error || 'harness error' })
366
+ return
367
+ case 'idle':
368
+ finalizeOpenAssistants()
369
+ if (textParts.size === 0) {
370
+ // Prep window can finish the turn before the chat mounts; pull
371
+ // history so a late assistant message is not dropped by finish.
372
+ void fetchHistory().then(
373
+ ({ events }) => {
374
+ if (disposed) return
375
+ for (const event of events) emit(event)
376
+ emit({ type: 'finish' })
377
+ },
378
+ () => {
379
+ if (!disposed) emit({ type: 'finish' })
380
+ }
381
+ )
382
+ return
383
+ }
384
+ emit({ type: 'finish' })
385
+ return
386
+ default:
387
+ return
388
+ }
389
+ }
390
+ case 'message_delta': {
391
+ const delta = frame.messageDelta
392
+ if (!delta) return
393
+ beginAssistant(delta.assistantMessageId)
394
+ emit({
395
+ type: 'assistant/chunk',
396
+ messageId: delta.assistantMessageId,
397
+ delta: delta.delta
398
+ } as ChatSessionEvent)
399
+ return
400
+ }
401
+ case 'reasoning_delta': {
402
+ const delta = frame.reasoningDelta
403
+ if (!delta) return
404
+ const msgId = delta.assistantMessageId
405
+ beginAssistant(msgId)
406
+ reasoningParts.set(msgId, (reasoningParts.get(msgId) ?? '') + delta.delta)
407
+ emit({
408
+ type: 'assistant/chunk',
409
+ messageId: msgId,
410
+ delta: '',
411
+ reasoningDelta: delta.delta
412
+ } as ChatSessionEvent)
413
+ return
414
+ }
415
+ case 'message_text': {
416
+ const text = frame.messageText
417
+ if (!text) return
418
+ onMessageText(text.assistantMessageId, text.textId, text.text, frame.seq)
419
+ return
420
+ }
421
+ case 'tool_call': {
422
+ const call = frame.toolCall
423
+ if (!call || knownCallIds.has(call.callId)) return
424
+ knownCallIds.add(call.callId)
425
+ // Stub first if this assistant message is new so the call attaches here.
426
+ beginAssistant(call.assistantMessageId, true)
427
+ emit({
428
+ type: 'tool/call',
429
+ callId: call.callId,
430
+ name: call.tool,
431
+ arguments: stringifyArg(call.input),
432
+ seq: frame.seq
433
+ })
434
+ return
435
+ }
436
+ case 'tool_result': {
437
+ const result = frame.toolResult
438
+ if (!result || finalCallIds.has(result.callId)) return
439
+ finalCallIds.add(result.callId)
440
+ emit({
441
+ type: 'tool/result',
442
+ callId: result.callId,
443
+ status: result.error ? 'error' : 'success',
444
+ result: result.output == null ? undefined : stringifyArg(result.output),
445
+ error: result.error || undefined,
446
+ seq: frame.seq
447
+ })
448
+ return
449
+ }
450
+ case 'permission': {
451
+ const permission = frame.permission
452
+ if (!permission) return
453
+ // The bridge echoes every applied answer (auto-approve, another
454
+ // client, TTL reject) as a resolved frame: close the card, never ask.
455
+ if (permission.resolved) {
456
+ if (activePermissions.delete(permission.requestId)) {
457
+ emit({
458
+ type: 'approval/resolved',
459
+ approvalId: permission.requestId,
460
+ outcome: permission.resolved
461
+ })
462
+ }
463
+ return
464
+ }
465
+ if (activePermissions.has(permission.requestId)) return
466
+ activePermissions.add(permission.requestId)
467
+ emit({
468
+ type: 'approval/requested',
469
+ approvalId: permission.requestId,
470
+ toolName: permission.action,
471
+ reason: permission.resources?.join(', ') || undefined,
472
+ rpcId: permission.requestId
473
+ })
474
+ return
475
+ }
476
+ case 'question': {
477
+ const question = frame.question
478
+ if (!question) return
479
+ if (question.resolved) {
480
+ if (activeQuestionId != null) {
481
+ emit({
482
+ type: 'question/resolved',
483
+ questionRpcId: activeQuestionId,
484
+ outcome: 'cancelled'
485
+ })
486
+ activeQuestionId = null
487
+ }
488
+ return
489
+ }
490
+ if (activeQuestionId === question.requestId) return
491
+ activeQuestionId = question.requestId
492
+ emit({
493
+ type: 'question/requested',
494
+ questions: question.questions.map((item) => ({
495
+ question: item.question,
496
+ options: item.options?.length ? item.options : undefined
497
+ })),
498
+ rpcId: question.requestId
499
+ })
500
+ return
501
+ }
502
+ }
503
+ }
504
+
505
+ function openWS(): void {
506
+ if (disposed) return
507
+ const token = getAccessToken()
508
+ if (!token) {
509
+ handlers?.onDisconnect?.()
510
+ return
511
+ }
512
+ const socket = new WebSocket(harnessSessionEventsWSURL(sessionId, token, lastDurableSeq))
513
+ ws = socket
514
+ socket.onmessage = (event) => {
515
+ try {
516
+ onFrame(JSON.parse(String(event.data)) as HarnessFrame)
517
+ } catch {
518
+ /* skip malformed frames */
519
+ }
520
+ }
521
+ socket.onclose = () => {
522
+ if (disposed || ws !== socket) return
523
+ ws = null
524
+ handlers?.onDisconnect?.()
525
+ reconnectDelay = Math.min(reconnectDelay * 2, 15_000)
526
+ reconnectTimer = setTimeout(openWS, reconnectDelay)
527
+ }
528
+ socket.onerror = () => {
529
+ socket.close()
530
+ }
531
+ }
532
+
533
+ function flushBuffer(): void {
534
+ synced = true
535
+ for (const frame of buffer.splice(0)) {
536
+ deliverFrame(frame)
537
+ }
538
+ }
539
+
540
+ async function fetchHistory(): Promise<{ events: ChatSessionEvent[]; hasMore: boolean }> {
541
+ const messages = await listSessionMessages(sessionId)
542
+ const events: ChatSessionEvent[] = []
543
+ let foundUserMessage = false
544
+ let foundOpenAssistant = false
545
+
546
+ for (const message of messages) {
547
+ if (message.role === 'user') {
548
+ if (seenUserIds.has(message.id)) {
549
+ foundUserMessage = true
550
+ continue
551
+ }
552
+ seenUserIds.add(message.id)
553
+ foundUserMessage = true
554
+ const rawText = textOfParts(message.content, 'text')
555
+ events.push({
556
+ type: 'user/message',
557
+ messageId: message.id,
558
+ content: cleanUserContent(rawText, options?.initialPrompt),
559
+ history: true
560
+ } as HistoryChatSessionEvent)
561
+ continue
562
+ }
563
+ if (message.role !== 'assistant') continue
564
+ // 仍在直播流式中的消息:历史必然是落后快照(甚至为空),跳过回放 ——
565
+ // 否则断线补拉会把流式消息标记成 done、清空/缩短内容,重连续流又翻回
566
+ // streaming,消息在「完成 / 思考中」间来回翻转;终态由重连回放的 durable
567
+ // 帧(message_text / tool_result)落定。去重键(textKeys / callIds)也
568
+ // 不登记,避免重连后的 durable 终态帧被误判重复而丢弃。
569
+ if (openAssistantIds.has(message.id)) {
570
+ foundOpenAssistant = true
571
+ continue
572
+ }
573
+ seenAssistantIds.add(message.id)
574
+ const toolCalls: {
575
+ id: string
576
+ name: string
577
+ arguments: string
578
+ status: 'running' | 'success' | 'error'
579
+ result?: string
580
+ error?: string
581
+ }[] = []
582
+ for (const part of message.content ?? []) {
583
+ if (part.type === 'text' || part.type === 'reasoning') {
584
+ if (part.id) {
585
+ knownTextKeys.add(`${message.id}:${part.id}`)
586
+ if (part.type === 'reasoning' && part.text) {
587
+ reasoningParts.set(message.id, (reasoningParts.get(message.id) ?? '') + part.text)
588
+ }
589
+ }
590
+ continue
591
+ }
592
+ knownCallIds.add(part.callID)
593
+ const status =
594
+ part.state?.status === 'completed'
595
+ ? 'success'
596
+ : part.state?.status === 'error'
597
+ ? 'error'
598
+ : 'running'
599
+ if (status !== 'running') finalCallIds.add(part.callID)
600
+
601
+ let result = part.state?.output
602
+ if (result == null && (part.state as { content?: unknown })?.content != null) {
603
+ const c = (part.state as { content?: unknown }).content
604
+ if (typeof c === 'string') {
605
+ result = c
606
+ } else if (Array.isArray(c)) {
607
+ result = c
608
+ .map((item: unknown) => {
609
+ if (typeof item === 'string') return item
610
+ if (
611
+ item &&
612
+ typeof item === 'object' &&
613
+ 'text' in item &&
614
+ typeof (item as { text: unknown }).text === 'string'
615
+ ) {
616
+ return (item as { text: string }).text
617
+ }
618
+ return stringifyArg(item)
619
+ })
620
+ .join('')
621
+ } else {
622
+ result = stringifyArg(c)
623
+ }
624
+ }
625
+
626
+ toolCalls.push({
627
+ id: part.callID,
628
+ name: part.name ?? '',
629
+ arguments: stringifyArg(part.state?.input),
630
+ status,
631
+ result: result == null ? undefined : result,
632
+ error: part.state?.error || undefined
633
+ })
634
+ }
635
+ const reasoning = textOfParts(message.content, 'reasoning')
636
+ if (reasoning) {
637
+ reasoningParts.set(message.id, reasoning)
638
+ }
639
+ events.push({
640
+ type: 'assistant/message',
641
+ messageId: message.id,
642
+ content: textOfParts(message.content, 'text'),
643
+ reasoning: reasoning || undefined,
644
+ toolCalls: toolCalls.length ? toolCalls : undefined,
645
+ history: true
646
+ } as HistoryChatSessionEvent)
647
+ }
648
+
649
+ // If initial history fetch yielded no user message yet (prompt still being submitted in backend),
650
+ // and an assistant message is not already streaming, seed it so the user sees their prompt immediately.
651
+ if (!foundUserMessage && !foundOpenAssistant && options?.initialPrompt?.trim()) {
652
+ const syntheticId = SYNTHETIC_PROMPT_ID
653
+ if (!seenUserIds.has(syntheticId)) {
654
+ seenUserIds.add(syntheticId)
655
+ events.unshift({
656
+ type: 'user/message',
657
+ messageId: syntheticId,
658
+ content: options.initialPrompt
659
+ } as HistoryChatSessionEvent)
660
+ }
661
+ }
662
+
663
+ return { events, hasMore: false }
664
+ }
665
+
666
+ return {
667
+ subscribe(registrant) {
668
+ handlers = registrant
669
+ disposed = false
670
+ openWS()
671
+ // The adapter owns the initial history replay: emit its events first,
672
+ // then flush the buffered WS frames so the seam order is deterministic
673
+ // (old -> new) and deduplicated. The runtime's own fetchHistory call
674
+ // reuses the same dedup state and converges to no-op.
675
+ fetchHistory().then(
676
+ ({ events }) => {
677
+ for (const event of events) emit(event)
678
+ flushBuffer()
679
+ },
680
+ () => {
681
+ flushBuffer()
682
+ handlers?.onDisconnect?.()
683
+ }
684
+ )
685
+ return () => {
686
+ disposed = true
687
+ handlers = null
688
+ if (reconnectTimer) clearTimeout(reconnectTimer)
689
+ ws?.close()
690
+ ws = null
691
+ buffer.length = 0
692
+ synced = false
693
+ }
694
+ },
695
+ async send(content) {
696
+ const ack = await sendSessionMessage(sessionId, content)
697
+ if (disposed || seenUserIds.has(ack.id)) return
698
+ seenUserIds.add(ack.id)
699
+ emit({ type: 'user/message', messageId: ack.id, content } as ChatSessionEvent)
700
+ },
701
+ cancel() {
702
+ return interruptSession(sessionId)
703
+ },
704
+ async respond(rpcId, ok, value) {
705
+ if (activePermissions.has(rpcId)) {
706
+ try {
707
+ await replySessionPermission(sessionId, rpcId, ok ? 'once' : 'reject')
708
+ } catch (err) {
709
+ // The bridge already answered the ask (auto-approve beat the click);
710
+ // anything else is a real failure.
711
+ if (!(err instanceof Error) || !err.message.includes('harness-pending-not-found'))
712
+ throw err
713
+ }
714
+ activePermissions.delete(rpcId)
715
+ emit({ type: 'approval/resolved', approvalId: rpcId, outcome: ok ? 'once' : 'rejected' })
716
+ return
717
+ }
718
+ if (activeQuestionId === rpcId) {
719
+ const list = (
720
+ Array.isArray(value) ? value : (value as { answers?: unknown } | undefined)?.answers
721
+ ) as { answer?: string }[] | undefined
722
+ const answers = (list ?? []).map((item) => [item?.answer ?? ''])
723
+ await replySessionQuestion(sessionId, rpcId, answers)
724
+ activeQuestionId = null
725
+ emit({ type: 'question/resolved', questionRpcId: rpcId, outcome: 'answered' })
726
+ }
727
+ },
728
+ fetchHistory,
729
+ // No mid-session model switch exists in the contract and the chat shows
730
+ // no model picker, so this is never invoked.
731
+ async selectModel() {
732
+ /* not supported */
733
+ }
734
+ }
735
+ }