@morlay/ui-conversation-message-actions 0.0.12 → 0.0.14-alpha.0

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/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Service, type Context } from "@deepseek-ai/cordis";
2
- import type { AssistantMessage, ContentBlock, UserMessage } from "@deepseek-ai/dsh-llm";
2
+ import type { AssistantMessage, UserMessage } from "@deepseek-ai/dsh-llm";
3
3
  import type {
4
4
  Session,
5
5
  SessionEvent,
@@ -7,16 +7,11 @@ import type {
7
7
  SurfaceEventType,
8
8
  SurfaceIntent,
9
9
  } from "@deepseek-ai/dsh-session";
10
- import { randomUUID } from "node:crypto";
11
10
  import {
12
- SESSION_BRANCH_VERSION_SCHEMA,
13
11
  SessionBranchError,
14
12
  balanceRewindPrefix,
15
13
  type BranchBoundary,
16
14
  type BranchTimeline,
17
- type EditableBlockKind,
18
- type SessionBranchEffect,
19
- type SessionBranchVersionEvent,
20
15
  } from "@morlay/session-branch";
21
16
  import type {
22
17
  EditOperation,
@@ -33,6 +28,14 @@ import {
33
28
  type SessionEditorTimeline,
34
29
  toTimelinePayload,
35
30
  } from "./shared.ts";
31
+ import {
32
+ closedTurns,
33
+ editableMessages,
34
+ editPlan,
35
+ retryPlan,
36
+ rerollPlan,
37
+ retryableTurns,
38
+ } from "./plan.ts";
36
39
 
37
40
  export type { CascadePolicy, EditableBlockKind } from "@morlay/session-branch";
38
41
  export type {
@@ -60,6 +63,16 @@ declare module "@deepseek-ai/cordis" {
60
63
  export interface EditorAgent {
61
64
  readonly session: Session;
62
65
  followup(message: UserMessage): void;
66
+ /** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
67
+ whenIdle(): Promise<void>;
68
+ /**
69
+ * 队列中是否有待处理输入(next-turn / next-step)。rewind 截断会删除这些
70
+ * 输入所属轮次的事件,残留的 inbox 状态会与截断后的 log 失配,使后续
71
+ * splice 落库后无法从日志重放——编辑前必须清空。
72
+ */
73
+ readonly inboxPending: boolean;
74
+ /** 清空待处理输入(落库 canceled splice,须在 rewind 前调用)。 */
75
+ clearInbox(): void;
63
76
  }
64
77
 
65
78
  export interface EditorAgentHandle {
@@ -87,306 +100,6 @@ export interface EditorAgentRegistry {
87
100
  }): Promise<EditorAgentHandle>;
88
101
  }
89
102
 
90
- interface ClosedTurn {
91
- turn: number;
92
- startSeq: number;
93
-
94
- endSeq?: number;
95
-
96
- closed: boolean;
97
- user?: SessionEvent<"user/message">;
98
- assistants: SessionEvent<"assistant/message">[];
99
- }
100
-
101
- function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
102
- const result: ClosedTurn[] = [];
103
- let current: Omit<ClosedTurn, "endSeq" | "closed"> | undefined;
104
- for (const event of events) {
105
- if (event.type === "turn/start") {
106
- current = { turn: event.data.turn, startSeq: event.seq, assistants: [] };
107
- continue;
108
- }
109
- if (current === undefined) continue;
110
- if (
111
- event.type === "user/message" &&
112
- current.user === undefined &&
113
- event.data.source.kind === "user"
114
- ) {
115
- current.user = event;
116
- continue;
117
- }
118
- if (event.type === "assistant/message" && event.data.turn === current.turn) {
119
- current.assistants.push(event);
120
- continue;
121
- }
122
- if (event.type === "turn/end" && event.data.turn === current.turn) {
123
- result.push({ ...current, endSeq: event.seq, closed: true });
124
- current = undefined;
125
- }
126
- }
127
- // 未闭合轮次(无 turn/end)也保留:user 消息已落定,编辑时 rewind 到该
128
- // 消息(exclusive drop)重放即可。
129
- if (current !== undefined) result.push({ ...current, closed: false });
130
- return result;
131
- }
132
-
133
- function isTextualBlock(
134
- block: ContentBlock | undefined,
135
- ): block is Extract<ContentBlock, { type: "text" | "reasoning" }> {
136
- return block?.type === "text" || block?.type === "reasoning";
137
- }
138
-
139
- function userText(message: UserMessage): string {
140
- return message.content
141
- .filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
142
- .map((block) => block.text)
143
- .join("\n");
144
- }
145
-
146
- function cloneUser(
147
- message: UserMessage,
148
- content: ContentBlock[] = structuredClone(message.content),
149
- ): UserMessage {
150
- return Object.freeze({
151
- id: randomUUID(),
152
- role: "user",
153
- content: Object.freeze(content),
154
- source: Object.freeze({ kind: "user" }),
155
- }) as UserMessage;
156
- }
157
-
158
- function replaceTextBlock(
159
- content: readonly ContentBlock[],
160
- blockIndex: number,
161
- text: string,
162
- ): ContentBlock[] {
163
- const block = content[blockIndex];
164
- if (!isTextualBlock(block))
165
- throw new SessionBranchError("所选内容块不是可编辑文本。", "INVALID_BOUNDARY");
166
- return content.map((candidate, index) =>
167
- index === blockIndex ? ({ ...candidate, text } as ContentBlock) : structuredClone(candidate),
168
- );
169
- }
170
-
171
- function editableMessages(turns: readonly ClosedTurn[]): EditableMessageBlock[] {
172
- const result: EditableMessageBlock[] = [];
173
- for (const turn of turns) {
174
- if (turn.user !== undefined) {
175
- for (const [blockIndex, block] of turn.user.data.content.entries()) {
176
- if (block.type !== "text") continue;
177
- result.push({
178
- key: `${String(turn.user.seq)}:${String(blockIndex)}`,
179
- turn: turn.turn,
180
- eventSeq: turn.user.seq,
181
- blockIndex,
182
- kind: "user",
183
- text: block.text,
184
- time: turn.user.time,
185
- });
186
- }
187
- }
188
- // 未闭合轮次的助手消息是流式 partial,不可编辑。
189
- if (!turn.closed) continue;
190
- for (const event of turn.assistants) {
191
- for (const [blockIndex, block] of event.data.message.content.entries()) {
192
- if (!isTextualBlock(block)) continue;
193
- result.push({
194
- key: `${String(event.seq)}:${String(blockIndex)}`,
195
- turn: turn.turn,
196
- eventSeq: event.seq,
197
- blockIndex,
198
- kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
199
- text: block.text,
200
- time: event.time,
201
- });
202
- }
203
- }
204
- }
205
- return result;
206
- }
207
-
208
- function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
209
- return turns.flatMap((turn): RetryableTurn[] =>
210
- // 未闭合轮次无已落定回复可重生成,不可重试。
211
- turn.user === undefined || !turn.closed
212
- ? []
213
- : [
214
- {
215
- turn: turn.turn,
216
- userEventSeq: turn.user.seq,
217
- preview: userText(turn.user.data),
218
- time: turn.user.time,
219
- },
220
- ],
221
- );
222
- }
223
-
224
- function downstreamUsers(turns: readonly ClosedTurn[], start: number): UserMessage[] {
225
- return turns
226
- .slice(start)
227
- .flatMap((turn): UserMessage[] => (turn.user === undefined ? [] : [cloneUser(turn.user.data)]));
228
- }
229
-
230
- function assistantReplacement(
231
- event: SessionEvent<"assistant/message">,
232
- blockIndex: number,
233
- text: string,
234
- ): AssistantMessage {
235
- const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter(
236
- (block) => block.type === "text" || block.type === "reasoning",
237
- );
238
- return Object.freeze({
239
- id: randomUUID(),
240
- role: "assistant",
241
- content: Object.freeze(replaced),
242
- source: Object.freeze({
243
- kind: "model",
244
- provider: event.data.message.source.provider,
245
- model: event.data.message.source.model,
246
- }),
247
- }) as AssistantMessage;
248
- }
249
-
250
- interface OperationPlan {
251
- anchorSeq: number;
252
-
253
- rewindBoundary?: number;
254
- version: SessionBranchVersionEvent;
255
-
256
- manualTurn?: { turn: number; user: UserMessage; assistant: AssistantMessage };
257
-
258
- queuedUsers: UserMessage[];
259
- }
260
-
261
- function pairVersionEffect(
262
- sourceSessionId: SessionId,
263
- effect: Omit<SessionBranchEffect, "id">,
264
- ): SessionBranchVersionEvent {
265
- return {
266
- schemaVersion: SESSION_BRANCH_VERSION_SCHEMA,
267
- effect: { ...effect, id: randomUUID() },
268
- inverse: { kind: "restore-version", sessionId: sourceSessionId },
269
- };
270
- }
271
-
272
- function editPlan(operation: EditOperation, turns: readonly ClosedTurn[]): OperationPlan {
273
- const turnIndex = turns.findIndex(
274
- (turn) =>
275
- operation.eventSeq > turn.startSeq &&
276
- (turn.endSeq === undefined || operation.eventSeq < turn.endSeq),
277
- );
278
- const turn = turns[turnIndex];
279
- if (turn === undefined)
280
- throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
281
- const event =
282
- turn.user?.seq === operation.eventSeq
283
- ? turn.user
284
- : turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
285
- if (event === undefined)
286
- throw new SessionBranchError("所选消息不存在或不可编辑。", "INVALID_BOUNDARY");
287
-
288
- if (event.type === "user/message") {
289
- const before = event.data.content[operation.blockIndex];
290
- if (before?.type !== "text")
291
- throw new SessionBranchError("所选用户消息块不是文本。", "INVALID_BOUNDARY");
292
- const edited = cloneUser(
293
- event.data,
294
- replaceTextBlock(event.data.content, operation.blockIndex, operation.text),
295
- );
296
- const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
297
- return {
298
- anchorSeq: turn.startSeq,
299
- // 未闭合轮次:rewind 到该 user 消息(exclusive drop),由编辑版替换;
300
- // 闭合轮次走前一轮 turn/end 的 inclusive 语义。
301
- ...(turn.closed ? {} : { rewindBoundary: event.seq }),
302
- version: pairVersionEffect(operation.sessionId, {
303
- operation: "edit",
304
- cascade: operation.cascade,
305
- targetTurn: turn.turn,
306
- targetEventSeq: event.seq,
307
- targetBlockIndex: operation.blockIndex,
308
- blockKind: "user",
309
- before: before.text,
310
- after: operation.text,
311
- }),
312
- queuedUsers: [edited, ...later],
313
- };
314
- }
315
-
316
- // 未闭合轮次的助手消息是流式 partial,无最终内容可编辑。
317
- if (!turn.closed)
318
- throw new SessionBranchError("未闭合轮次的助手消息不可编辑。", "INVALID_BOUNDARY");
319
- const before = event.data.message.content[operation.blockIndex];
320
- if (!isTextualBlock(before))
321
- throw new SessionBranchError("所选助手消息块不是文本或思考。", "INVALID_BOUNDARY");
322
- const blockKind: EditableBlockKind =
323
- before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
324
- if (turn.user === undefined)
325
- throw new SessionBranchError("所选助手消息没有可重建的用户输入。", "INVALID_BOUNDARY");
326
- return {
327
- anchorSeq: turn.startSeq,
328
- version: pairVersionEffect(operation.sessionId, {
329
- operation: "edit",
330
- cascade: operation.cascade,
331
- targetTurn: turn.turn,
332
- targetEventSeq: event.seq,
333
- targetBlockIndex: operation.blockIndex,
334
- blockKind,
335
- before: before.text,
336
- after: operation.text,
337
- }),
338
- manualTurn: {
339
- turn: turn.turn,
340
- user: cloneUser(turn.user.data),
341
- assistant: assistantReplacement(event, operation.blockIndex, operation.text),
342
- },
343
- queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [],
344
- };
345
- }
346
-
347
- function retryPlan(operation: RetryOperation, turns: readonly ClosedTurn[]): OperationPlan {
348
- const turnIndex = turns.findIndex((turn) => turn.turn === operation.turn);
349
- const turn = turns[turnIndex];
350
- if (turn?.user === undefined)
351
- throw new SessionBranchError("所选回合没有可重放的用户输入。", "INVALID_BOUNDARY");
352
- return {
353
- anchorSeq: turn.startSeq,
354
- version: pairVersionEffect(operation.sessionId, {
355
- operation: "retry",
356
- cascade: operation.cascade,
357
- targetTurn: turn.turn,
358
- targetEventSeq: turn.user.seq,
359
- }),
360
- queuedUsers:
361
- operation.cascade === "preserve"
362
- ? downstreamUsers(turns, turnIndex)
363
- : [cloneUser(turn.user.data)],
364
- };
365
- }
366
-
367
- function rerollPlan(operation: RerollOperation, turns: readonly ClosedTurn[]): OperationPlan {
368
- for (let index = turns.length - 1; index >= 0; index -= 1) {
369
- const turn = turns[index];
370
- // 未闭合轮次无已落定的助手回复可重生成。
371
- if (turn?.user === undefined || !turn.closed) continue;
372
- const target = turn.assistants.findLast((event) =>
373
- event.data.message.content.some(isTextualBlock),
374
- );
375
- if (target === undefined) continue;
376
- return {
377
- anchorSeq: turn.startSeq,
378
- version: pairVersionEffect(operation.sessionId, {
379
- operation: "reroll",
380
- cascade: "truncate",
381
- targetTurn: turn.turn,
382
- targetEventSeq: target.seq,
383
- }),
384
- queuedUsers: [cloneUser(turn.user.data)],
385
- };
386
- }
387
- throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
388
- }
389
-
390
103
  function appendLogSeedEvent(
391
104
  events: SessionEvent[],
392
105
  type: string,
@@ -429,10 +142,9 @@ function appendManualTurn(
429
142
  appendSurfaceSeedEvent(
430
143
  events,
431
144
  "assistant/message",
432
- { turn, step: 1, message: assistant },
145
+ { turn, step: 1, message: assistant, stream: [] },
433
146
  {
434
147
  surfaceOp: "append",
435
- sourceEventSeqs: [],
436
148
  },
437
149
  );
438
150
  appendLogSeedEvent(events, "step/end", { turn, step: 1 });
@@ -442,7 +154,11 @@ function appendManualTurn(
442
154
  });
443
155
  }
444
156
 
445
- function appendSeedSuffixLive(session: Session, seedSuffix: readonly SessionEvent[]): void {
157
+ async function appendSeedSuffixLive(
158
+ session: Session,
159
+ seedSuffix: readonly SessionEvent[],
160
+ appendDirect: (events: readonly SessionEvent[]) => Promise<void>,
161
+ ): Promise<void> {
446
162
  for (const event of seedSuffix) {
447
163
  // 版本效果事件携带 ignorable 标记(上游类型无此字段,duck-type 读取)。
448
164
  const ignorable = (event as { ignorable?: boolean }).ignorable === true;
@@ -451,10 +167,13 @@ function appendSeedSuffixLive(session: Session, seedSuffix: readonly SessionEven
451
167
  log: SessionEvent[];
452
168
  eventsSnapshot?: unknown;
453
169
  };
454
- // ignorable 语义:live log 保留、不落 canonical log。session.append 不
455
- // 保留 ignorable 标记,因此直接 push 内存 log(不发布);seq 按 log
456
- // 续接重编号(seedSuffix 内部编号从 0 起)。
457
- s.log.push({ ...event, seq: s.log.length } as SessionEvent);
170
+ // 原样存储语义:版本效果事件经 RDB write handle 直接落库(带
171
+ // ignorable 信封,与 JSONL 一致)。session.append 不保留 ignorable
172
+ // 标记且类型系统禁止 append 未知类型,因此不发布、直接 push 内存
173
+ // log(seq log 续接重编号,与 handle cursor 同空间)。
174
+ const seq = s.log.length;
175
+ await appendDirect([{ ...event, seq } as SessionEvent]);
176
+ s.log.push({ ...event, seq } as SessionEvent);
458
177
  s.eventsSnapshot = undefined;
459
178
  continue;
460
179
  }
@@ -574,8 +293,16 @@ export class SessionEditor extends Service {
574
293
  appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
575
294
  if (plan.manualTurn !== undefined) appendManualTurn(seedSuffix, plan.manualTurn);
576
295
 
577
- // 就地编辑:不创建新会话、不改变 id。先 rewind 截断到目标轮之前的闭合
578
- // 边界,再 append 版本效果与重放输入,最后(可选)驱动 agent 重放。
296
+ // 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
297
+ // 前确保 agent 就绪(live agent 等其停;cold 先 resume)——rewind 截断后
298
+ // agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
299
+ const replay = await this.prepareReplay(
300
+ operation.sessionId,
301
+ plan.queuedUsers,
302
+ signal,
303
+ headerConfig,
304
+ );
305
+
579
306
  const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
580
307
  const boundary =
581
308
  plan.rewindBoundary !== undefined
@@ -587,10 +314,25 @@ export class SessionEditor extends Service {
587
314
  await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
588
315
  if (seedSuffix.length > 0) {
589
316
  if (live !== undefined) {
590
- // live:版本效果 push 内存 log(ignorable 保留)、manualTurn append,
591
- // 同步 cursor 后显式 flush(push 不发布、不进缓冲,cursor 会落后)。
592
- appendSeedSuffixLive(live, seedSuffix);
593
- this.ctx.sessionBranch.syncLiveCursor(operation.sessionId);
317
+ // live:版本效果经 RDB write handle 直接落库(带 ignorable 信封,
318
+ // JSONL 一致)、manualTurn 走 append;同步 cursor 后显式 flush
319
+ // (直接落库不发布、不进缓冲,cursor 会落后)。
320
+ const persistence = this.ctx.sessionPersistence as unknown as {
321
+ tracker: {
322
+ writerOf(id: SessionId):
323
+ | {
324
+ append(events: readonly SessionEvent[]): Promise<void>;
325
+ }
326
+ | undefined;
327
+ };
328
+ };
329
+ const handle = persistence.tracker.writerOf(operation.sessionId);
330
+ if (handle === undefined) {
331
+ throw new Error(
332
+ `session "${operation.sessionId}" has no live write handle for the version effect`,
333
+ );
334
+ }
335
+ await appendSeedSuffixLive(live, seedSuffix, (events) => handle.append(events));
594
336
  await this.ctx.sessions.flush(live);
595
337
  } else {
596
338
  // cold:续写 seq 从平衡后的保留前缀接续(exclusive 截断可能残留
@@ -604,22 +346,28 @@ export class SessionEditor extends Service {
604
346
  seq: keepLength + index,
605
347
  }) as SessionEvent,
606
348
  );
607
- await this.ctx.sessionPersistence.append(operation.sessionId, renumbered);
349
+ const handle = await this.ctx.sessionPersistence.open(operation.sessionId, "write");
350
+ try {
351
+ await handle.append(renumbered);
352
+ } finally {
353
+ await handle.close();
354
+ }
608
355
  }
609
356
  }
610
357
 
611
- // 可选增强:agent 驱动(重放排队用户输入)。缺失 agents 服务时退化为
612
- // durable 的就地版本。
613
- const queuedTurns = await this.driveAgent(
614
- operation.sessionId,
615
- plan.queuedUsers,
616
- signal,
617
- headerConfig,
618
- );
358
+ // 发起新的 user prompt:把排队输入交给就绪的 agent(rewind 后其 session
359
+ // 已被截断,followup 基于截断后历史开新轮重放)。无 agent(agents 服务
360
+ // 缺失)时退化为已 durable 的就地版本。
361
+ let queuedTurns = 0;
362
+ if (replay.agent !== undefined && plan.queuedUsers.length > 0) {
363
+ for (const message of plan.queuedUsers) replay.agent.followup(message);
364
+ await this.ctx.sessions.flush(replay.agent.session);
365
+ queuedTurns = plan.queuedUsers.length;
366
+ }
619
367
  return {
620
368
  sessionId: operation.sessionId,
621
369
  queuedTurns,
622
- // 操作后是否仍有 live owner(driveAgent 可能 resume 出 agent)。
370
+ // 操作后是否仍有 live owner(prepareReplay 可能 resume 出 agent)。
623
371
  live: this.ctx.sessions.get(operation.sessionId) !== undefined,
624
372
  };
625
373
  }
@@ -641,16 +389,34 @@ export class SessionEditor extends Service {
641
389
  return (await branch.readRawEvents(sessionId, signal)).events;
642
390
  }
643
391
 
644
- private async driveAgent(
392
+ /**
393
+ * rewind 前确保 agent 可驱动重放:live agent 先等待其停下(rewind 会截断其
394
+ * session 内存 log,须在 quiescence 后执行);cold 会话先 resume 出驻留
395
+ * agent(此时会话完整,resume 的 prepare 不与截断冲突)。agents 服务缺失或
396
+ * 无需重放时返回空——调用方退化为就地截断版本。
397
+ */
398
+ private async prepareReplay(
645
399
  sessionId: SessionId,
646
400
  queuedUsers: readonly UserMessage[],
647
- signal?: AbortSignal,
401
+ signal: AbortSignal | undefined,
648
402
  headerConfig?: { provider?: string; model?: string; maxTokens?: number },
649
- ): Promise<number> {
650
- if (queuedUsers.length === 0) return 0;
403
+ ): Promise<{ agent: EditorAgent | undefined }> {
404
+ if (queuedUsers.length === 0) return { agent: undefined };
651
405
  signal?.throwIfAborted();
652
406
  const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
653
- if (agents === undefined) return 0;
407
+ if (agents === undefined) return { agent: undefined };
408
+ const existing = agents.get(sessionId);
409
+ if (existing !== undefined) {
410
+ await existing.whenIdle();
411
+ signal?.throwIfAborted();
412
+ // 清空 inbox 残留(rewind 将删除这些输入所属轮次的事件;残留消息若
413
+ // 不清空,agent 后续 splice 落库后无法从日志重放——inbox 增量投影
414
+ // 假设 log 只 append)。
415
+ if (existing.inboxPending) existing.clearInbox();
416
+ return { agent: existing };
417
+ }
418
+ // cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
419
+ // resume 失败是硬错误:rewind 尚未发生,编辑保持原子(不截断不丢数据)。
654
420
  const provider = headerConfig?.provider ?? "";
655
421
  const model = headerConfig?.model ?? "";
656
422
  if (provider.length === 0 || model.length === 0) {
@@ -660,51 +426,22 @@ export class SessionEditor extends Service {
660
426
  .config;
661
427
  const fallbackProvider = config?.provider ?? "";
662
428
  const fallbackModel = config?.model ?? "";
663
- if (fallbackProvider.length === 0 || fallbackModel.length === 0) return 0;
664
- return this.queueThroughAgent(
665
- sessionId,
666
- queuedUsers,
667
- signal,
668
- fallbackProvider,
669
- fallbackModel,
670
- );
671
- }
672
- return this.queueThroughAgent(sessionId, queuedUsers, signal, provider, model);
673
- }
674
-
675
- private async queueThroughAgent(
676
- sessionId: SessionId,
677
- queuedUsers: readonly UserMessage[],
678
- signal: AbortSignal | undefined,
679
- provider: string,
680
- model: string,
681
- ): Promise<number> {
682
- const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
683
- if (agents === undefined) return 0;
684
- // 现有 live agent:直接排队输入(其 session 内存已被 rewind 截断,
685
- // followup 基于截断后历史重放,不改 id、不重建)。
686
- const existing = agents.get(sessionId);
687
- if (existing !== undefined) {
688
- for (const message of queuedUsers) existing.followup(message);
689
- await this.ctx.sessions.flush(existing.session);
690
- return queuedUsers.length;
691
- }
692
- // cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
693
- // resume 后 agent 驻留,不 dispose(dispose 会破坏客户端打开的窗口)。
694
- const handle = await agents
695
- .resume({ resumeSessionId: sessionId, agentOptions: { provider, model } })
696
- .catch((error: unknown) => {
697
- // agent 组合失败不应使已 durable 的版本失效:退化为持久化版本。
698
- this.ctx.logger.warn(
699
- "session-editor: agent resume failed (%s); version remains durable",
700
- String(error),
701
- );
702
- return undefined;
429
+ if (fallbackProvider.length === 0 || fallbackModel.length === 0) {
430
+ throw new SessionBranchError("无法重放:会话没有可解析的模型配置。", "INVALID_BOUNDARY");
431
+ }
432
+ const handle = await agents.resume({
433
+ resumeSessionId: sessionId,
434
+ agentOptions: { provider: fallbackProvider, model: fallbackModel },
703
435
  });
704
- if (handle === undefined) return 0;
705
- for (const message of queuedUsers) handle.agent.followup(message);
706
- await this.ctx.sessions.flush(handle.agent.session);
707
- return queuedUsers.length;
436
+ signal?.throwIfAborted();
437
+ return { agent: handle.agent };
438
+ }
439
+ const handle = await agents.resume({
440
+ resumeSessionId: sessionId,
441
+ agentOptions: { provider, model },
442
+ });
443
+ signal?.throwIfAborted();
444
+ return { agent: handle.agent };
708
445
  }
709
446
  }
710
447