@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.
- package/dist/chat/fold.js +14 -2
- package/dist/chat/fold.js.map +1 -1
- package/dist/chat/session.d.ts +13 -6
- package/dist/chat/session.js.map +1 -1
- package/dist/chat/transports/openai.d.ts +2 -1
- package/dist/chat/transports/openai.js +20 -213
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/use-chat-session.js +3 -13
- package/dist/chat/use-chat-session.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/approval-banner.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/job-bar.js.map +1 -1
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/tool-json.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js.map +1 -1
- package/package.json +3 -3
- package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
- package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
- package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
- package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
- package/src/chat/__test__/fold.test.ts +26 -1
- package/src/chat/__test__/session.test.ts +1 -1
- package/src/chat/__test__/use-chat.test.ts +6 -3
- package/src/chat/fold.ts +16 -1
- package/src/chat/session.ts +13 -6
- package/src/chat/transports/__test__/openai.test.ts +1 -1
- package/src/chat/transports/openai.ts +25 -288
- package/src/chat/use-chat-session.ts +4 -5
- package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
- package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
- package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
- 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
|
+
}
|