@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/dist/client.js +31 -31
- package/dist/index.d.mts +21 -69
- package/dist/index.mjs +2 -606
- package/dist/invariant.d.mts +1 -2
- package/dist/invariant.mjs +0 -2
- package/dist/plan-Bw4zoI4N.d.mts +86 -0
- package/dist/plan.d.mts +2 -0
- package/dist/plan.mjs +225 -0
- package/dist/src-Da2ZVKlq.mjs +417 -0
- package/dist/testing.d.mts +709 -0
- package/dist/testing.mjs +28317 -0
- package/package.json +48 -28
- package/src/client/chat-node/MessageItem.tsx +4 -5
- package/src/index.ts +115 -378
- package/src/plan.ts +325 -0
- package/src/testing.ts +151 -0
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/invariant.d.mts.map +0 -1
- package/dist/invariant.mjs.map +0 -1
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service, type Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import type { AssistantMessage,
|
|
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(
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
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
|
|
578
|
-
//
|
|
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
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
this.ctx.
|
|
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.
|
|
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
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
401
|
+
signal: AbortSignal | undefined,
|
|
648
402
|
headerConfig?: { provider?: string; model?: string; maxTokens?: number },
|
|
649
|
-
): Promise<
|
|
650
|
-
if (queuedUsers.length === 0) return
|
|
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
|
|
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)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
|