Repository navigation
Expand file tree
/
Copy pathmessageStore.ts
More file actions
1822 lines (1628 loc) · 68.5 KB
/
Copy pathmessageStore.ts
File metadata and controls
1822 lines (1628 loc) · 68.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ============================================
// MessageStore - 消息状态集中管理(OpenCode v2 原生)
// ============================================
//
// 核心设计:
// 1. 每个 session 直接持有**原生** `SessionMessageInfo[]`,按创建时间有序。
// 没有自制的 `{ info, parts }` 视图模型:消息判别走 `message.type`,
// 助手内容读 `message.content: Array<Text | Reasoning | Tool>`。
// 2. SSE 事件逐条对齐官方 client(`packages/client/src/solid/data.ts`):
// 流式增量就地改写(官方同款,配 findLast 定位「最后一个同类片段」),
// 再由 rAF 统一生成新的对象标识 —— React 靠引用变化重渲染,
// 就地改写若不给新引用,界面会停在上一帧。
// 3. Undo/Redo 通过 revertState 实现
// 4. RAF 批量通知 React 组件更新
//
// ## sessionID 的作用域约定(多服务器)
//
// 本 app 支持多服务器,session id 按 server 作用域化成 `${serverId}::${sessionId}`,
// store 也以作用域 id 为键。事件负载里带的是**服务端本地原始 id**,因此:
// 调用方必须先把负载里的 sessionID 改写成作用域 id 再传进来
// (`messageStore.handleX({ ...data, sessionID: scope(data.sessionID) })`)。
// store 只读负载里的 sessionID,**从不**读事件信封上的 sessionID —— 读原始
// id 会让两个服务器上的同 id 会话串台。
import type { ModelRef, SessionStructuredError } from '@opencode/client/promise'
import type {
AssistantContent,
AssistantMessage,
AssistantReasoning,
AssistantText,
AssistantTool,
SessionMessage,
ToolContent,
UserMessage,
} from '../types/api/message'
import type { GlobalEvent, SessionMessageInfo } from '../types/api/event'
import type { SessionInboxInfo } from '@opencode/client/promise'
import type { SessionRevert } from '../types/api/session'
import { contentEntries, isUserMessage, userMessageText } from '../types/api/message'
import type { Attachment } from '../types/ui'
import type { APIError } from '../types/api/common'
import { logger } from '../utils/logger'
import type { RevertState, RevertHistoryItem, SessionState, SendRollbackSnapshot } from './messageStoreTypes'
// Re-export types for consumers
export type { RevertState, RevertHistoryItem, SessionState, SendRollbackSnapshot } from './messageStoreTypes'
type Subscriber = () => void
/** 从 v2 事件联合里取出某个具体事件(保留 `created` / `data` 的精确形状) */
type NativeEvent<T extends GlobalEvent['type']> = Extract<GlobalEvent, { type: T }>
/** 某个原生事件的负载;其中 sessionID 由调用方改写成作用域 id(见文件头约定) */
type EventPayload<T extends GlobalEvent['type']> = NativeEvent<T>['data']
/** 工具生命周期事件:input.started/delta/ended → called → progress* → success | failed */
type ToolEvent = Extract<GlobalEvent, { type: `session.tool.${string}` }>
/** 工具事件负载(sessionID 已作用域化) */
type ToolPayload<T extends ToolEvent['type']> = Extract<ToolEvent, { type: T }>['data']
/**
* 执行结束的三种结局 —— 三者在 store 里走同一段收尾逻辑。
*/
type ExecutionOutcome = 'succeeded' | 'failed' | 'interrupted'
/**
* 事件信封里 store 真正需要、且与 session 归属无关的部分。
*
* 这里**刻意不含 sessionID**:信封上的 sessionID 是服务端本地原始 id,
* 多服务器下会串台,store 一律用负载里调用方改写过的值。
*
* 当前 `src/api/events.ts` 只把 `event.data` 交给事件回调,调用方拿不到
* `id` / `created` / `metadata`;这时 facts 传 undefined,store 用本地时钟与
* 随机 id 兜底。分发层一旦把信封透出,把 `{ id, created, metadata }` 传进来,
* 时间戳就恢复成服务端权威值,派生消息 id 也恢复成可重放的稳定 id。
*/
export interface EventFacts {
/** 事件 id —— 官方 messageIDFromEvent 用它派生 idle / synthetic / shell / compaction 的消息 id */
id?: string
/** 事件创建时间 —— 用于 time.created / time.completed / time.ran */
created?: number
/** 事件信封 metadata —— step.started / shell.started / instructions.updated 会透传 */
metadata?: AssistantMessage['metadata']
}
/**
* `handleToolEvent` 的入参。
*
* 工具负载本身不带判别字段(`type` 在信封上、不在 data 里),因此这里显式
* 带上 `type`,让 TS 把 type 与 data 关联起来(免去手写 cast)。
* `sessionID` 仍在 `data` 内,且必须是**已作用域化**的值。
*/
export type ToolEventInput = {
[T in ToolEvent['type']]: { type: T; data: ToolPayload<T>; facts?: EventFacts }
}[ToolEvent['type']]
/** 压缩消息的三个状态分支 */
type CompactionRunning = Extract<SessionMessage, { type: 'compaction'; status: 'running' }>
type CompactionCompleted = Extract<SessionMessage, { type: 'compaction'; status: 'completed' }>
type CompactionFailed = Extract<SessionMessage, { type: 'compaction'; status: 'failed' }>
/** shell 消息(`session.shell.ended` 要按 shellID 回填) */
type ShellMessage = Extract<SessionMessage, { type: 'shell' }>
const MAX_CACHED_SESSIONS = 10
/**
* 由事件 id 派生消息 id。
*
* 官方 `data.ts:76`:`eventID.replace(/^evt_/, "msg_")`。
* idle / synthetic / shell / compaction / switched 这些消息没有服务端给的
* 消息 id,只能靠事件 id 派生,重连重放时才能保持稳定(不会每次都新增一行)。
*/
function messageIDFromEvent(eventID: string): string {
return eventID.replace(/^evt_/, 'msg_')
}
/** 事件 id → 消息 id(导出给事件消费方做回读,与合成行保持同一变换) */
export { messageIDFromEvent }
/**
* 从事件事实里派生消息 id。
*
* 拿不到事件 id 时退化为随机 id:功能上可用,但重放同一条事件会产生第二行
* (因此一旦分发层透出信封,务必把 id 传进来)。
*/
function messageIDFromFacts(facts: EventFacts | undefined): string {
return facts?.id ? messageIDFromEvent(facts.id) : `msg_${crypto.randomUUID()}`
}
/**
* 同步合并文本:live 更长且与服务端兼容(服务端是前缀)时不回退;
* 服务端更长则跟上;分叉时以服务端为准。
*/
function preferCompatibleText(local: string, incoming: string): string {
if (local === incoming) return incoming
if (local.startsWith(incoming)) return local
if (incoming.startsWith(local)) return incoming
return incoming
}
/** 未定稿的助手消息:`time.completed` 缺省即仍在流式输出中 */
function isOpenAssistant(message: SessionMessageInfo | undefined): message is AssistantMessage {
return !!message && message.type === 'assistant' && message.time.completed == null
}
function isRunningCompaction(message: SessionMessageInfo): message is CompactionRunning {
return message.type === 'compaction' && message.status === 'running'
}
/**
* 克隆一个内容片段。
*
* 工具片段的 `state` / `time` 是就地改写的可变对象(见 handleToolEvent),
* 快照与回滚必须断开引用,否则「回滚」拿到的还是被后续事件改过的状态。
*/
function cloneContent(content: AssistantContent): AssistantContent {
if (content.type === 'tool') {
return { ...content, time: { ...content.time }, state: { ...content.state } }
}
return { ...content }
}
/** 深一层克隆原生消息(数组与两个可变子对象都换新引用) */
function cloneMessage(message: SessionMessageInfo): SessionMessageInfo {
if (message.type === 'assistant') {
return {
...message,
time: { ...message.time },
content: message.content.map(cloneContent),
}
}
return { ...message, time: { ...message.time } }
}
/**
* 服务端整条消息覆盖本地时,保留本地更长的 live 文本。
*
* 只对「两边都还没定稿」的助手消息生效:定稿(completed)以服务端为准。
* 内容片段没有 id,按官方 projection 的派生 id(text/reasoning 各自计数)对齐。
*/
function mergeAssistantPreferLive(local: AssistantMessage, incoming: AssistantMessage): AssistantMessage {
const localById = new Map(contentEntries(local).map(entry => [entry.id, entry.content]))
const content = contentEntries(incoming).map(entry => {
const localItem = localById.get(entry.id)
if (!localItem) return entry.content
return preferLiveText(localItem, entry.content)
})
return { ...incoming, content }
}
function preferLiveText(localItem: AssistantContent, incomingItem: AssistantContent): AssistantContent {
if (localItem.type === 'tool' || incomingItem.type === 'tool') return incomingItem
// 两边都是 text / reasoning(只有这两种带 text 字段)
const text = preferCompatibleText(localItem.text, incomingItem.text)
return text === incomingItem.text ? incomingItem : { ...incomingItem, text }
}
function mergeIncomingMessage(
previous: SessionMessageInfo | undefined,
incoming: SessionMessageInfo,
): SessionMessageInfo {
if (!previous) return incoming
if (isOpenAssistant(previous) && isOpenAssistant(incoming)) return mergeAssistantPreferLive(previous, incoming)
return incoming
}
class MessageStore {
private sessions = new Map<string, SessionState>()
private subscribers = new Set<Subscriber>()
private sessionSubscribers = new Map<string, Map<Subscriber, number>>()
private sessionVersions = new Map<string, number>()
private allSessionsVersion = 0
private changeVersion = 0
private sessionAccessTime = new Map<string, number>()
/** 被分屏 pane 保护的 sessionId 集合,evict 时跳过 */
private protectedSessions = new Set<string>()
private pendingNotify = false
private pendingNotifyAllSessions = false
private pendingSessionNotifyIds = new Set<string>()
private rafId: number | null = null
// delta 批量化:记录本帧内被**就地改写**过的消息与内容片段。
// rAF 回调里只给它们换新引用,同消息内稳定的片段继续复用引用,
// 否则流式期间每个工具结果都会跟着重渲染。
private dirtyContentBySession = new Map<string, Map<string, Set<AssistantContent>>>()
// `session.tool.progress` 先于 called 到达(重连/回放乱序)时暂存的 metadata,
// key = `${sessionID}:${assistantMessageID}:${partId}`,called 时合并、终态清除。
// 主要保护 subagent 的 metadata.sessionID——丢了会导致子会话内联与权限挂载失效。
private pendingToolProgress = new Map<string, ToolPayload<'session.tool.progress'>['metadata']>()
private static toolProgressKey(sessionID: string, assistantMessageID: string, partId: string): string {
return `${sessionID}:${assistantMessageID}:${partId}`
}
private stashPendingToolProgress(
sessionID: string,
assistantMessageID: string,
partId: string,
metadata: ToolPayload<'session.tool.progress'>['metadata'],
) {
this.pendingToolProgress.set(MessageStore.toolProgressKey(sessionID, assistantMessageID, partId), metadata)
}
private takePendingToolProgress(
sessionID: string,
assistantMessageID: string,
partId: string,
): ToolPayload<'session.tool.progress'>['metadata'] | undefined {
const key = MessageStore.toolProgressKey(sessionID, assistantMessageID, partId)
const value = this.pendingToolProgress.get(key)
if (value !== undefined) this.pendingToolProgress.delete(key)
return value
}
// ============================================
// Subscription & Notification
// ============================================
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn)
return () => this.subscribers.delete(fn)
}
subscribeSession(sessionId: string, fn: Subscriber): () => void {
let subscribers = this.sessionSubscribers.get(sessionId)
if (!subscribers) {
subscribers = new Map()
this.sessionSubscribers.set(sessionId, subscribers)
}
subscribers.set(fn, this.getSessionVersion(sessionId))
return () => {
subscribers.delete(fn)
if (subscribers.size === 0) this.sessionSubscribers.delete(sessionId)
}
}
private getSessionVersion(sessionId: string) {
return Math.max(this.sessionVersions.get(sessionId) ?? 0, this.allSessionsVersion)
}
private markPendingSessionNotifications(sessionIds?: Iterable<string> | 'all') {
if (sessionIds === 'all') {
this.changeVersion += 1
this.allSessionsVersion = this.changeVersion
this.pendingNotifyAllSessions = true
this.pendingSessionNotifyIds.clear()
return
}
if (!sessionIds || this.pendingNotifyAllSessions) return
for (const sessionId of sessionIds) {
this.changeVersion += 1
this.sessionVersions.set(sessionId, this.changeVersion)
this.pendingSessionNotifyIds.add(sessionId)
}
}
private notify(sessionIds?: Iterable<string> | 'all') {
this.markPendingSessionNotifications(sessionIds)
if (this.pendingNotify) return
this.pendingNotify = true
if (typeof requestAnimationFrame !== 'undefined') {
this.rafId = requestAnimationFrame(() => {
this.pendingNotify = false
this.rafId = null
this.flushDirtyMessages()
this.subscribers.forEach(fn => fn())
this.flushSessionSubscribers()
})
} else {
this.pendingNotify = false
this.flushDirtyMessages()
this.subscribers.forEach(fn => fn())
this.flushSessionSubscribers()
}
}
private flushSessionSubscribers() {
if (this.pendingNotifyAllSessions) {
this.pendingNotifyAllSessions = false
this.pendingSessionNotifyIds.clear()
this.sessionSubscribers.forEach((subscribers, sessionId) => {
this.flushSubscribersForSession(sessionId, subscribers)
})
return
}
if (this.pendingSessionNotifyIds.size === 0) return
const sessionIds = Array.from(this.pendingSessionNotifyIds)
this.pendingSessionNotifyIds.clear()
for (const sessionId of sessionIds) {
const subscribers = this.sessionSubscribers.get(sessionId)
if (subscribers) this.flushSubscribersForSession(sessionId, subscribers)
}
}
private flushSubscribersForSession(sessionId: string, subscribers: Map<Subscriber, number>) {
const version = this.getSessionVersion(sessionId)
subscribers.forEach((seenVersion, fn) => {
if (seenVersion === version) return
subscribers.set(fn, version)
fn()
})
}
/** 把本帧内被就地改写过的东西标记为「下一帧要换新引用」 */
private markMessageDirty(sessionID: string, messageID: string, touched?: AssistantContent | AssistantContent[]) {
let dirtyByMessage = this.dirtyContentBySession.get(sessionID)
if (!dirtyByMessage) {
dirtyByMessage = new Map<string, Set<AssistantContent>>()
this.dirtyContentBySession.set(sessionID, dirtyByMessage)
}
let dirty = dirtyByMessage.get(messageID)
if (!dirty) {
dirty = new Set<AssistantContent>()
dirtyByMessage.set(messageID, dirty)
}
if (!touched) return
if (Array.isArray(touched)) {
for (const item of touched) dirty.add(item)
} else {
dirty.add(touched)
}
}
/**
* 把 delta 期间就地改写过的消息做一次「不可变快照」:
* - 消息对象换新引用(React 才会重渲染)
* - 只克隆本帧真正动过的内容片段,稳定片段继续复用引用
* 一帧内多个 delta 因此只产生一次拷贝。
*/
private flushDirtyMessages() {
if (this.dirtyContentBySession.size === 0) return
for (const [sessionID, dirtyByMessage] of this.dirtyContentBySession) {
const state = this.sessions.get(sessionID)
if (!state) continue
let changed = false
const messages = state.messages.map(message => {
const dirty = dirtyByMessage.get(message.id)
if (!dirty) return message
changed = true
if (message.type === 'assistant') {
return {
...message,
time: { ...message.time },
content: message.content.map(item => (dirty.has(item) ? cloneContent(item) : item)),
}
}
return { ...message, time: { ...message.time } }
})
if (changed) state.messages = messages
}
this.dirtyContentBySession.clear()
}
private notifyImmediate(sessionIds?: Iterable<string> | 'all') {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.markPendingSessionNotifications(sessionIds)
this.pendingNotify = false
this.flushDirtyMessages()
this.subscribers.forEach(fn => fn())
this.flushSessionSubscribers()
}
// ============================================
// Getters
// ============================================
getSessionState(sessionId: string): SessionState | undefined {
return this.sessions.get(sessionId)
}
/** 可见消息:undo 生效后,撤销点及其之后的消息不再展示 */
getVisibleMessages(sessionId: string | null): SessionMessageInfo[] {
if (!sessionId) return []
const state = this.sessions.get(sessionId)
if (!state) return []
const { messages, revertState } = state
if (!revertState) return messages
// 消息 id 单调递增(msg_ + 大端时间戳前缀),字符串比较即时间比较。
// 边界之前(id 更小)的可见,边界及之后的已撤销隐藏。
// 用 id 比较而非 findIndex:边界消息还没加载进内存时(undo 很深)也能正确隐藏,
// 否则刷新后已撤销的消息会全部「复活」(对齐官方 selectVisibleSessionUserMessages)。
return messages.filter(message => message.id < revertState.messageId)
}
getIsStreaming(sessionId: string | null): boolean {
if (!sessionId) return false
return this.sessions.get(sessionId)?.isStreaming ?? false
}
getRevertState(sessionId: string | null): RevertState | null {
if (!sessionId) return null
return this.sessions.get(sessionId)?.revertState ?? null
}
getHasMoreHistory(sessionId: string | null): boolean {
if (!sessionId) return false
return this.sessions.get(sessionId)?.hasMoreHistory ?? false
}
getSessionDirectory(sessionId: string | null): string {
if (!sessionId) return ''
return this.sessions.get(sessionId)?.directory ?? ''
}
getSessionTitle(sessionId: string | null): string {
if (!sessionId) return ''
return this.sessions.get(sessionId)?.title ?? ''
}
getLoadState(sessionId: string | null): SessionState['loadState'] {
if (!sessionId) return 'idle'
return this.sessions.get(sessionId)?.loadState ?? 'idle'
}
isSessionStale(sessionId: string): boolean {
return this.sessions.get(sessionId)?.isStale ?? false
}
// ============================================
// Session Management
// ============================================
private ensureSession(sessionId: string): SessionState {
this.sessionAccessTime.set(sessionId, Date.now())
let state = this.sessions.get(sessionId)
if (!state) {
this.evictOldSessions()
state = {
messages: [],
revertState: null,
isStreaming: false,
loadState: 'idle',
hasMoreHistory: false,
historyCursor: null,
directory: '',
title: undefined,
agent: undefined,
model: undefined,
cost: undefined,
tokens: undefined,
localMessageIds: new Set(),
loadError: undefined,
isStale: false,
}
this.sessions.set(sessionId, state)
}
return state
}
private evictOldSessions() {
if (this.sessions.size < MAX_CACHED_SESSIONS) return
let oldestId: string | null = null
let oldestTime = Infinity
for (const [id, time] of this.sessionAccessTime) {
if (this.protectedSessions.has(id)) continue
const state = this.sessions.get(id)
if (state?.isStreaming) continue
if (time < oldestTime) {
oldestTime = time
oldestId = id
}
}
if (oldestId) {
logger.log('[MessageStore] Evicting old session:', oldestId)
this.sessions.delete(oldestId)
this.sessionAccessTime.delete(oldestId)
this.dirtyContentBySession.delete(oldestId)
}
}
/** 保护 sessionId 不被 evict(分屏 pane 使用) */
protectSession(sessionId: string) {
this.protectedSessions.add(sessionId)
}
/** 取消保护(pane 关闭或切换 session 时调用) */
unprotectSession(sessionId: string) {
this.protectedSessions.delete(sessionId)
}
updateSessionMetadata(
sessionId: string,
options: {
hasMoreHistory?: boolean
historyCursor?: string | null
directory?: string
title?: string
loadState?: SessionState['loadState']
loadError?: APIError
/** durable 会话级 agent(SessionInfo)——只补空缺,不覆盖 live SSE 更新的值 */
agent?: string
/** durable 会话级模型(SessionInfo)——同上 */
model?: ModelRef
},
) {
const state = this.sessions.get(sessionId)
if (!state) return
if (options.hasMoreHistory !== undefined) state.hasMoreHistory = options.hasMoreHistory
if (options.historyCursor !== undefined) state.historyCursor = options.historyCursor
if (options.directory !== undefined) state.directory = options.directory
if (options.title !== undefined) state.title = options.title
if (options.loadState !== undefined) state.loadState = options.loadState
if (options.loadError !== undefined) state.loadError = options.loadError
if (state.agent === undefined && options.agent !== undefined) state.agent = options.agent
if (state.model === undefined && options.model !== undefined) state.model = options.model
this.notify([sessionId])
}
/**
* inbox 条目物化进转写(官方 data.ts:390-401 的 materializeInboxMessage)。
*
* 队列条目在**投递前**服务端转写里并没有这条消息(只有 inbox 条目),
* 所以本地必须自己顶上去:id 用 inboxID(服务端投递后用同一 id 落库),
* 内容/时间取 inbox 条目的 payload/time。同 id 已存在则整条替换
* (durable 回声、快照同步都会走到这里补齐字段)。
*
* 新增时**追加到末尾**而不是按 time.created 插入(官方 data.ts:398
* 的 message.append 同款):排队条目的入队时间早于它前面那轮的助手回复,
* 按时间插入会让它跑到正在跑的那轮上面去。投递时由 handleInboxDelivered
* 改写为投递事件时间并挪位,才是真实的服务端顺序。
*/
materializeInboxMessage(sessionId: string, item: SessionInboxInfo) {
if (item.type !== 'user') return
const state = this.ensureSession(sessionId)
const existingIndex = state.messages.findIndex(message => message.id === item.id)
const row = {
id: item.id,
type: 'user',
time: { created: item.time.created },
...item.payload,
} as SessionMessageInfo
if (existingIndex >= 0) {
const messages = state.messages.slice()
messages[existingIndex] = row
state.messages = messages
} else {
state.messages = [...state.messages, row]
}
// 服务端列表尚未包含它,setMessages 不能冲掉
state.localMessageIds.add(item.id)
this.notify([sessionId])
}
/**
* `session.inbox.delivered`(官方 data.ts:749-763 同款)。
*
* 投递 = 服务端开始处理这条排队 prompt:出队,并把转写里那条
* **改写为投递事件的时间戳、挪到末尾** —— 队列期间它排在哪由入队顺序决定,
* 投递后它才真正成为"当前回合的输入"。只用入队时间排序会让上屏顺序
* 与真实服务器事件不符。
*/
handleInboxDelivered(sessionId: string, inboxID: string, deliveredAt: number) {
const state = this.sessions.get(sessionId)
if (!state) return
const index = state.messages.findIndex(message => message.id === inboxID)
if (index < 0) return
const message = state.messages[index]
const messages = state.messages.slice()
messages[index] = { ...message, time: { ...message.time, created: deliveredAt } }
const [admitted] = messages.splice(index, 1)
messages.push(admitted)
state.messages = messages
this.notify([sessionId])
}
/**
* 本地乐观插入消息(用户刚发出、服务端还没确认)。
*
* v2 的原生消息**不带 sessionID**(会话归属是事件上下文给的),
* 因此 sessionId 必须显式传入,且必须是作用域 id。
*/
upsertLocalMessage(sessionId: string, message: SessionMessageInfo) {
const state = this.ensureSession(sessionId)
const existingIndex = state.messages.findIndex(item => item.id === message.id)
if (existingIndex >= 0) {
const messages = state.messages.slice()
messages[existingIndex] = message
state.messages = messages
} else {
state.messages = [...state.messages, message].sort((a, b) => a.time.created - b.time.created)
}
// 记下「本地有、服务端列表还没有」的 id:setMessages 不能把它冲掉
state.localMessageIds.add(message.id)
this.notify([sessionId])
}
removeMessage(sessionId: string, messageId: string) {
const state = this.sessions.get(sessionId)
if (!state) return
const nextMessages = state.messages.filter(message => message.id !== messageId)
if (nextMessages.length === state.messages.length) return
state.messages = nextMessages
state.localMessageIds.delete(messageId)
this.notify([sessionId])
}
markAllSessionsStale() {
let updated = false
for (const state of this.sessions.values()) {
if (state.loadState !== 'loaded' || state.isStale) continue
state.isStale = true
updated = true
}
if (updated) this.notify('all')
}
setLoadState(sessionId: string, loadState: SessionState['loadState']) {
const state = this.ensureSession(sessionId)
state.loadState = loadState
if (loadState !== 'error') state.loadError = undefined
this.notify([sessionId])
}
setLoadError(sessionId: string, error: APIError) {
const state = this.ensureSession(sessionId)
state.loadState = 'error'
state.loadError = error
this.notify([sessionId])
}
// ============================================
// Message CRUD
// ============================================
setMessages(
sessionId: string,
apiMessages: SessionMessageInfo[],
options?: {
directory?: string
title?: string
hasMoreHistory?: boolean
historyCursor?: string | null
revertState?: SessionRevert | null
/** durable 会话级 agent(SessionInfo)——只补空缺,不覆盖 live SSE 更新的值 */
agent?: string
/** durable 会话级模型(SessionInfo)——同上 */
model?: ModelRef
},
) {
const state = this.ensureSession(sessionId)
const previousMessages = state.messages
const previousById = new Map(previousMessages.map(message => [message.id, message]))
// 定稿(completed)强制采用服务端;仅「两边都还在流式」时才保留本地更长的 live 文本
let messages = apiMessages.map(apiMessage => mergeIncomingMessage(previousById.get(apiMessage.id), apiMessage))
// 保留「本地有、服务端列表还没返回」的消息:
// - 正在流式输出的助手消息(v2 的 prompt 是入队语义,step.started 会先建出
// 助手消息,而同期的一次 message.list 可能还没包含它,直接覆盖会让正在
// 输出的内容短暂消失)
// - 本地乐观插入、服务端尚未确认的用户消息(官方 outbox 的本地等价物)
const apiIds = new Set(messages.map(message => message.id))
const localOnly = previousMessages.filter(
message => !apiIds.has(message.id) && (isOpenAssistant(message) || state.localMessageIds.has(message.id)),
)
if (localOnly.length > 0) {
messages = [...messages, ...localOnly].sort((a, b) => a.time.created - b.time.created)
}
state.messages = messages
// 服务端已确认的本地消息不再需要保护
for (const id of Array.from(state.localMessageIds)) {
if (apiIds.has(id)) state.localMessageIds.delete(id)
}
state.loadState = 'loaded'
state.loadError = undefined
state.hasMoreHistory = options?.hasMoreHistory ?? false
if (options?.historyCursor !== undefined) state.historyCursor = options.historyCursor
state.directory = options?.directory ?? ''
if (options?.title !== undefined) state.title = options.title
// durable 会话级 agent/model(SessionInfo):只补空缺——live SSE 的
// session.agent/model.selected 可能更新过,服务端快照不覆盖它们
if (state.agent === undefined && options?.agent !== undefined) state.agent = options.agent
if (state.model === undefined && options?.model !== undefined) state.model = options.model
state.isStale = false
// Revert 状态
const revert = options?.revertState
if (revert?.messageID) {
// 始终先存边界标记:可见性(id 比较)与 redo 全恢复(revert.clear)只依赖 messageId,
// 不需要边界消息本体。边界不在首屏时(undo 很深)history 先留空,
// 由 loadSession 往回翻页补齐后调 rebuildRevertHistory 重建。
state.revertState = {
messageId: revert.messageID,
history: this.computeRevertHistory(state, sessionId, revert.messageID),
}
} else {
state.revertState = null
}
// 最后一条助手消息还没定稿 → 会话仍在输出
const lastMessage = state.messages.at(-1)
state.isStreaming = isOpenAssistant(lastMessage)
this.notify([sessionId])
}
prependMessages(sessionId: string, apiMessages: SessionMessageInfo[], hasMore: boolean, historyCursor?: string | null) {
const state = this.sessions.get(sessionId)
if (!state) return
// 去重后整体前插(历史页总是更早的消息)
const existingIds = new Set(state.messages.map(message => message.id))
const unique = apiMessages.filter(message => !existingIds.has(message.id))
if (unique.length > 0) {
state.messages = [...unique, ...state.messages]
}
state.hasMoreHistory = hasMore
if (historyCursor !== undefined) state.historyCursor = historyCursor
this.notify([sessionId])
}
clearAll() {
this.sessions.clear()
this.sessionAccessTime.clear()
this.dirtyContentBySession.clear()
this.pendingToolProgress.clear()
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.pendingNotify = false
this.notifyImmediate('all')
}
clearSession(sessionId: string) {
this.sessions.delete(sessionId)
this.sessionAccessTime.delete(sessionId)
this.dirtyContentBySession.delete(sessionId)
for (const key of this.pendingToolProgress.keys()) {
if (key.startsWith(`${sessionId}:`)) this.pendingToolProgress.delete(key)
}
this.notify([sessionId])
}
/**
* 用一条权威消息整条落库(重新读取 message.list / 单条 message.get 后的回填)。
*
* v2 没有 v1 的 `message.updated` 事件,但「拿到一条完整消息就覆盖」的入口
* 仍然需要:未定稿时保留本地更长的 live 文本,定稿后以服务端为准。
*/
handleMessageUpdated(apiMessage: SessionMessageInfo, sessionID: string) {
const state = this.ensureSession(sessionID)
const existingIndex = state.messages.findIndex(message => message.id === apiMessage.id)
if (existingIndex >= 0) {
const messages = state.messages.slice()
messages[existingIndex] = mergeIncomingMessage(state.messages[existingIndex], apiMessage)
state.messages = messages
} else {
this.appendMessage(state, apiMessage)
}
if (apiMessage.type === 'assistant') {
state.isStreaming = apiMessage.time.completed == null
}
this.notify([sessionID])
}
/**
* 助手内容整体替换(`session.message.content.updated` 重放 / 完整消息回读)。
*
* content 数组是权威快照,直接替换,不做增量合并。
*/
handleMessageContent(messageID: string, sessionID: string, content: AssistantContent[]) {
// 内容快照可能早于任何文本/推理事件到达(例如一轮里只有工具调用),
// 甚至早于会话被登记;用 ensureSession 而不是「没有就 return」,
// 否则首轮快照会被整条丢弃。
this.ensureSession(sessionID)
this.ensureAssistantMessage(sessionID, messageID)
const state = this.sessions.get(sessionID)
if (!state) return
const message = this.findAssistant(state, messageID)
if (!message) return
message.content = [...content]
this.markMessageDirty(sessionID, messageID, message.content)
this.notify([sessionID])
}
// ============================================
// SSE Event Handlers —— step / 文本 / 推理 / 工具
// ============================================
/**
* `session.step.started`:创建(或就地重置)助手消息,官方 `data.ts:838-871`。
*
* 「已存在同 id」的分支**不清空 content**:text.started 可能先于本事件到达
* (本地占位消息已经累积了内容),清空会把已经流出来的内容丢掉。
*/
handleStepStarted(data: EventPayload<'session.step.started'>, facts?: EventFacts) {
const { sessionID, assistantMessageID } = data
const state = this.ensureSession(sessionID)
const existing = this.findMessage(state, assistantMessageID)
if (existing && existing.type === 'assistant') {
existing.agent = data.agent
existing.model = data.model
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.created = data.started
existing.time.streamed = undefined
existing.time.completed = undefined
if (data.snapshot) existing.snapshot = { ...existing.snapshot, start: data.snapshot }
this.markMessageDirty(sessionID, assistantMessageID, existing.content)
state.isStreaming = true
this.notify([sessionID])
return
}
// 上一条仍开着的助手消息在这一步开始时定稿(官方同一位置)
const active = this.activeAssistant(state.messages)
if (active) {
active.retry = undefined
active.time.completed = facts?.created ?? Date.now()
this.markMessageDirty(sessionID, active.id)
}
this.appendMessage(state, {
id: assistantMessageID,
type: 'assistant',
agent: data.agent,
model: data.model,
metadata: facts?.metadata,
content: [],
snapshot: data.snapshot ? { start: data.snapshot } : undefined,
time: { created: data.started },
})
state.isStreaming = true
this.notify([sessionID])
}
handleStepStreamed(data: EventPayload<'session.step.streamed'>, facts?: EventFacts) {
const state = this.sessions.get(data.sessionID)
if (!state) return
const message = this.findAssistant(state, data.assistantMessageID)
if (!message) return
message.time.streamed = facts?.created ?? Date.now()
this.markMessageDirty(data.sessionID, message.id)
this.notify([data.sessionID])
}
handleStepEnded(data: EventPayload<'session.step.ended'>, facts?: EventFacts) {
const { sessionID, assistantMessageID } = data
const state = this.sessions.get(sessionID)
if (!state) return
const message = this.findAssistant(state, assistantMessageID)
if (!message) return
message.time.completed = facts?.created ?? Date.now()
message.finish = data.finish
message.rawFinish = data.rawFinish
message.providerState = data.providerState
message.cost = data.cost
message.tokens = data.tokens
if (data.snapshot) message.snapshot = { ...message.snapshot, end: data.snapshot }
this.markMessageDirty(sessionID, assistantMessageID)
this.notify([sessionID])
}
handleStepFailed(data: EventPayload<'session.step.failed'>, facts?: EventFacts) {
const { sessionID, assistantMessageID } = data
const state = this.sessions.get(sessionID)
if (!state) return
const message = this.findAssistant(state, assistantMessageID)
if (!message) return
message.time.completed = facts?.created ?? Date.now()
message.finish = data.finish ?? 'error'
message.rawFinish = data.rawFinish
message.providerState = data.providerState
message.error = data.error
message.retry = undefined
if (data.cost !== undefined && data.tokens !== undefined) {
message.cost = data.cost
message.tokens = data.tokens
}
this.markMessageDirty(sessionID, assistantMessageID)
this.notify([sessionID])
}
/** `session.text.started`:往 content 里追加一个空文本片段 */
handleTextStarted(data: EventPayload<'session.text.started'>) {
const { sessionID, assistantMessageID } = data
this.ensureAssistantMessage(sessionID, assistantMessageID)
this.editAssistant(sessionID, assistantMessageID, assistant => {
const text: AssistantText = { type: 'text', text: '' }
assistant.content.push(text)
return text
})
this.notify([sessionID])
}
/**
* `session.text.delta`:追加到**最后一个**文本片段(官方用 findLast)。
*
* 注意不能用 ordinal 定位:ordinal 是「第几段文本」,而重试/多次 step 会让
* content 里同时存在多段文本,按 ordinal 找会写错片段。
*/
handleTextDelta(data: EventPayload<'session.text.delta'>) {
const { sessionID, assistantMessageID, delta } = data
this.editText(sessionID, assistantMessageID, text => {
text.text += delta
})
this.notify([sessionID])
}
/** `session.text.ended`:用权威文本覆盖(delta 是易失的,以此对齐) */
handleTextEnded(data: EventPayload<'session.text.ended'>) {
const { sessionID, assistantMessageID, text } = data
this.editText(sessionID, assistantMessageID, item => {
item.text = text
})
this.notify([sessionID])
}
handleReasoningStarted(data: EventPayload<'session.reasoning.started'>, facts?: EventFacts) {
const { sessionID, assistantMessageID, state: providerState } = data
const created = facts?.created ?? Date.now()
this.ensureAssistantMessage(sessionID, assistantMessageID)
this.editAssistant(sessionID, assistantMessageID, assistant => {
const reasoning: AssistantReasoning = {
type: 'reasoning',
text: '',
state: providerState,
time: { created },
}
assistant.content.push(reasoning)