@kangyishuai/dsh-session-rewind 0.1.1 → 0.2.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/README.md +10 -9
- package/README.zh-CN.md +10 -9
- package/lib/agent-settle.js +108 -0
- package/lib/client.js +110 -10
- package/lib/operations.js +19 -7
- package/lib/service.js +38 -8
- package/locale/en.json +8 -2
- package/locale/zh.json +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,10 +15,11 @@ Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/de
|
|
|
15
15
|
Long conversations drift. A few turns later you wish you had answered a prompt differently, but starting a new session loses everything before it, and continuing keeps the unwanted turns in the context window forever. This plugin rewinds **in place**:
|
|
16
16
|
|
|
17
17
|
- **Two entries.** Press `Esc` `Esc` to open the rewind dialog, or click the ↶ button in any user message's action row (next to Copy).
|
|
18
|
+
- **Running turns are not a dead end.** Confirming a rewind while a turn is running cancels that turn (keeping the queue), waits for it to go idle, then re-plans against the settled history.
|
|
18
19
|
- **Two scopes, chosen per rewind.** The confirmation dialog offers **Rewind conversation only** (default) and **Rewind conversation and files**. The dialog first shows a dry-run preview: how many messages and roughly how many tokens will be withdrawn, which files will be restored or moved to the trash, conflicts, and skipped files.
|
|
19
20
|
- **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
|
|
20
21
|
- **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
|
|
21
|
-
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten.
|
|
22
|
+
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
|
|
22
23
|
- **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (audit of each rewind: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
|
|
23
24
|
|
|
24
25
|
## Install
|
|
@@ -49,14 +50,14 @@ Re-run `pnpm pack` + `add` after changing the code. The plugin has a host half,
|
|
|
49
50
|
|
|
50
51
|
**What is refused**
|
|
51
52
|
|
|
52
|
-
| Situation | What happens
|
|
53
|
-
| ------------------------------------------------------------------------ |
|
|
54
|
-
| The session has a running turn |
|
|
55
|
-
| The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there.
|
|
56
|
-
| The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context.
|
|
57
|
-
| A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched.
|
|
58
|
-
| A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed.
|
|
59
|
-
| A rewind is already running on this session | Refused; one at a time.
|
|
53
|
+
| Situation | What happens |
|
|
54
|
+
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| The session has a running turn | It is cancelled first (queued steering messages are dropped), then the rewind runs. Set `cancelRunning: false` to refuse instead (`session-rewind/busy`). |
|
|
56
|
+
| The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there. |
|
|
57
|
+
| The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context. |
|
|
58
|
+
| A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched. |
|
|
59
|
+
| A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed. |
|
|
60
|
+
| A rewind is already running on this session | Refused; one at a time. |
|
|
60
61
|
|
|
61
62
|
## File restore scope & safety
|
|
62
63
|
|
package/README.zh-CN.md
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
- **每次回溯二选一**:确认框里提供「**仅回退对话**」(默认)与「**回退对话和文件**」,并先展示 dry-run 预览:将撤回多少条消息、约多少 token、哪些文件会还原/移入回收站、冲突与被跳过的文件。
|
|
19
19
|
- **对话**:目标消息及其后的内容从模型上下文和渲染对话中撤回;会话 id、标题和目标之前的历史不变;日志只追加一条 marker,不删历史。
|
|
20
20
|
- **文件(可选)**:被撤回轮次里由 `write` / `edit` / `apply_patch` 改过的文件还原到改动前内容;目标之后新建的文件移入插件回收站而不是删除。写入前先建救援点,失败从救援点回滚。
|
|
21
|
-
-
|
|
21
|
+
- **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
|
|
22
|
+
- **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
|
|
22
23
|
- **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
|
|
23
24
|
|
|
24
25
|
## 安装
|
|
@@ -49,14 +50,14 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
49
50
|
|
|
50
51
|
**会被拒绝的情况**
|
|
51
52
|
|
|
52
|
-
| 情况 | 表现
|
|
53
|
-
| ----------------------------------------------------------- |
|
|
54
|
-
| 会话有正在运行的回合 |
|
|
55
|
-
| 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。
|
|
56
|
-
| 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。
|
|
57
|
-
| 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。
|
|
58
|
-
| 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。
|
|
59
|
-
| 同一会话已有回溯在执行 | 拒绝;一次只允许一个。
|
|
53
|
+
| 情况 | 表现 |
|
|
54
|
+
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| 会话有正在运行的回合 | 先取消该回合并等到 idle(队列里未投递的插话会被丢弃),再回溯;设 `cancelRunning: false` 则仍返回 `session-rewind/busy`。 |
|
|
56
|
+
| 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。 |
|
|
57
|
+
| 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。 |
|
|
58
|
+
| 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。 |
|
|
59
|
+
| 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。 |
|
|
60
|
+
| 同一会话已有回溯在执行 | 拒绝;一次只允许一个。 |
|
|
60
61
|
|
|
61
62
|
## 文件还原的范围与安全
|
|
62
63
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 回溯前把运行中的回合停下来:取消(保留 inbox)→ 等 idle → 丢弃尚未投递的 steering。
|
|
3
|
+
// 纯逻辑 + 注入 wait,便于用假 agent 单测;不 import 任何宿主包。
|
|
4
|
+
//
|
|
5
|
+
// DSH 0.2.2-rc.1 的 Agent 形状:
|
|
6
|
+
// - agent.status: "idle" | "running"
|
|
7
|
+
// - agent.cancel(cause, { keepInbox }):keepInbox 时不清空队列
|
|
8
|
+
// - agent.whenIdle(): Promise<void>(回合结束后 resolve)
|
|
9
|
+
// - agent.inbox.nextStep: 待投递的插话消息;agent.inbox.remove(id)
|
|
10
|
+
|
|
11
|
+
/** 默认等待 idle 的超时(毫秒)。 */
|
|
12
|
+
export const DEFAULT_CANCEL_TIMEOUT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} SettleResult
|
|
16
|
+
* @property {boolean} cancelled 是否发起过取消
|
|
17
|
+
* @property {boolean} idle 是否最终空闲
|
|
18
|
+
* @property {string[]} droppedSteering 丢弃的插话消息 id
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 等待 agent 空闲;优先用宿主提供的 whenIdle,否则轮询 status。
|
|
23
|
+
* @param {any} agent
|
|
24
|
+
* @param {number} timeoutMs
|
|
25
|
+
* @param {(ms: number) => Promise<void>} sleep
|
|
26
|
+
* @returns {Promise<boolean>}
|
|
27
|
+
*/
|
|
28
|
+
async function waitForIdle(agent, timeoutMs, sleep) {
|
|
29
|
+
const timeout = new Promise((resolve) => {
|
|
30
|
+
void sleep(timeoutMs).then(() => resolve(false));
|
|
31
|
+
});
|
|
32
|
+
if (typeof agent?.whenIdle === "function") {
|
|
33
|
+
try {
|
|
34
|
+
const idle = Promise.resolve(agent.whenIdle()).then(
|
|
35
|
+
() => true,
|
|
36
|
+
() => false,
|
|
37
|
+
);
|
|
38
|
+
return await Promise.race([idle, timeout]);
|
|
39
|
+
} catch (_error) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
if (agent?.status === "idle") return true;
|
|
46
|
+
await sleep(100);
|
|
47
|
+
}
|
|
48
|
+
return agent?.status === "idle";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 丢弃 next-step(尚未投递的插话)消息。
|
|
53
|
+
* @param {any} agent
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function dropPendingSteering(agent) {
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const dropped = [];
|
|
59
|
+
try {
|
|
60
|
+
const pending = agent?.inbox?.nextStep;
|
|
61
|
+
if (!pending || typeof agent?.inbox?.remove !== "function") return dropped;
|
|
62
|
+
for (const message of [...pending]) {
|
|
63
|
+
if (message && typeof message.id === "string" && message.id !== "") {
|
|
64
|
+
try {
|
|
65
|
+
agent.inbox.remove(message.id);
|
|
66
|
+
dropped.push(message.id);
|
|
67
|
+
} catch (_error) {
|
|
68
|
+
/* 单条失败不影响其余 */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (_error) {
|
|
73
|
+
/* inbox 形状不符时放弃丢弃 */
|
|
74
|
+
}
|
|
75
|
+
return dropped;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 停下运行中的回合并等待空闲。已经空闲时是 no-op。
|
|
80
|
+
* @param {any} agent
|
|
81
|
+
* @param {{timeoutMs?: number, sleep?: (ms: number) => Promise<void>}} [options]
|
|
82
|
+
* @returns {Promise<SettleResult>}
|
|
83
|
+
*/
|
|
84
|
+
export async function settleAgent(agent, options = {}) {
|
|
85
|
+
if (!agent || agent.status === "idle")
|
|
86
|
+
return { cancelled: false, idle: true, droppedSteering: [] };
|
|
87
|
+
const timeoutMs =
|
|
88
|
+
typeof options.timeoutMs === "number" &&
|
|
89
|
+
Number.isFinite(options.timeoutMs) &&
|
|
90
|
+
options.timeoutMs > 0
|
|
91
|
+
? options.timeoutMs
|
|
92
|
+
: DEFAULT_CANCEL_TIMEOUT_MS;
|
|
93
|
+
const sleep =
|
|
94
|
+
typeof options.sleep === "function"
|
|
95
|
+
? options.sleep
|
|
96
|
+
: (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
try {
|
|
98
|
+
if (typeof agent.cancel === "function") agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
99
|
+
} catch (_error) {
|
|
100
|
+
/* 取消失败时仍尝试等待 idle;超时会交给调用方报错 */
|
|
101
|
+
}
|
|
102
|
+
const idle = await waitForIdle(agent, timeoutMs, sleep);
|
|
103
|
+
return {
|
|
104
|
+
cancelled: true,
|
|
105
|
+
idle,
|
|
106
|
+
droppedSteering: idle ? dropPendingSteering(agent) : [],
|
|
107
|
+
};
|
|
108
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -45,6 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
"dialog.cancel": "取消",
|
|
46
46
|
"dialog.confirm": "开始回退",
|
|
47
47
|
"dialog.executing": "正在回退…",
|
|
48
|
+
"dialog.settling": "正在停止当前回合并回退…",
|
|
48
49
|
"dialog.loading": "正在读取…",
|
|
49
50
|
"dialog.empty": "没有可回溯的消息。",
|
|
50
51
|
"dialog.previewLoading": "正在计算影响面…",
|
|
@@ -65,7 +66,11 @@ window.__ModuleLoader__.load({
|
|
|
65
66
|
"confirm.pathsLess": "收起路径",
|
|
66
67
|
"confirm.skipped": "{n} 个文件被跳过",
|
|
67
68
|
"confirm.conflictsTitle": "冲突文件(前 5 个)",
|
|
68
|
-
"confirm.busy": "
|
|
69
|
+
"confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
|
|
70
|
+
"confirm.settling": "正在停止当前回合…",
|
|
71
|
+
"confirm.resend": "回退后立即重发这条消息",
|
|
72
|
+
"confirm.resendHint":
|
|
73
|
+
"适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
69
74
|
"confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
|
|
70
75
|
"toast.done": "已回退到此处,原消息已放回输入框",
|
|
71
76
|
"toast.draft": "已回退;输入框已有内容,未覆盖",
|
|
@@ -73,6 +78,8 @@ window.__ModuleLoader__.load({
|
|
|
73
78
|
"toast.rewound": "已回退 {n} 条消息(约 {tokens} token)",
|
|
74
79
|
"toast.rewoundFiles": ",还原 {restored} 个文件、移入回收站 {trashed} 个",
|
|
75
80
|
"toast.rewoundFailed": ",{n} 个文件处理失败",
|
|
81
|
+
"toast.resent": ",已重发这条消息",
|
|
82
|
+
"toast.stoppedTurn": ",已停止运行中的回合",
|
|
76
83
|
"toast.draftKept": ";输入框已有内容,未覆盖",
|
|
77
84
|
"toast.noComposerSuffix": ";未找到输入框",
|
|
78
85
|
"toast.view": "查看",
|
|
@@ -166,6 +173,7 @@ window.__ModuleLoader__.load({
|
|
|
166
173
|
"dialog.cancel": "Cancel",
|
|
167
174
|
"dialog.confirm": "Start rewind",
|
|
168
175
|
"dialog.executing": "Rewinding…",
|
|
176
|
+
"dialog.settling": "Stopping the running turn, then rewinding…",
|
|
169
177
|
"dialog.loading": "Loading…",
|
|
170
178
|
"dialog.empty": "No messages to rewind to.",
|
|
171
179
|
"dialog.previewLoading": "Computing the impact…",
|
|
@@ -187,8 +195,15 @@ window.__ModuleLoader__.load({
|
|
|
187
195
|
"confirm.pathsLess": "Hide paths",
|
|
188
196
|
"confirm.skipped": "{n} file(s) skipped",
|
|
189
197
|
"confirm.conflictsTitle": "Conflicting files (first 5)",
|
|
190
|
-
"confirm.busy":
|
|
198
|
+
"confirm.busy":
|
|
199
|
+
"The turn is still running: rewinding stops it first and drops any queued steering messages.",
|
|
200
|
+
"confirm.settling": "Stopping the running turn…",
|
|
201
|
+
"confirm.resend": "Send this message again right after rewinding",
|
|
202
|
+
"confirm.resendHint":
|
|
203
|
+
"Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
191
204
|
"confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
|
|
205
|
+
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
206
|
+
"toast.resent": ", and the message was sent again",
|
|
192
207
|
"toast.done": "Rewound. The original message is back in the composer.",
|
|
193
208
|
"toast.draft": "Rewound. The composer already had content, so it was kept.",
|
|
194
209
|
"toast.noComposer": "Rewound, but no composer was found.",
|
|
@@ -1139,6 +1154,11 @@ window.__ModuleLoader__.load({
|
|
|
1139
1154
|
text: text,
|
|
1140
1155
|
images: imageRefsOf(source),
|
|
1141
1156
|
files: filesReceiptOf(source.files),
|
|
1157
|
+
cancelledTurn: source.cancelledTurn === true,
|
|
1158
|
+
droppedSteering:
|
|
1159
|
+
typeof source.droppedSteering === "number" && source.droppedSteering > 0
|
|
1160
|
+
? source.droppedSteering
|
|
1161
|
+
: 0,
|
|
1142
1162
|
};
|
|
1143
1163
|
}
|
|
1144
1164
|
|
|
@@ -1960,8 +1980,37 @@ window.__ModuleLoader__.load({
|
|
|
1960
1980
|
}
|
|
1961
1981
|
}
|
|
1962
1982
|
|
|
1983
|
+
// 「回退后立即重发」:仅对话模式提供(文件模式先还原文件,语义不同)。
|
|
1984
|
+
if (state.rewindMode !== "both" && state.previewLoading !== true) {
|
|
1985
|
+
blocks.push(
|
|
1986
|
+
h(
|
|
1987
|
+
"div",
|
|
1988
|
+
{ key: "resend", className: "dsh-srw-setting" },
|
|
1989
|
+
h(
|
|
1990
|
+
"label",
|
|
1991
|
+
{ className: "dsh-srw-check" },
|
|
1992
|
+
h("input", {
|
|
1993
|
+
type: "checkbox",
|
|
1994
|
+
"data-rewind-resend": "true",
|
|
1995
|
+
checked: state.resend === true,
|
|
1996
|
+
onChange: (/** @type {any} */ event) => {
|
|
1997
|
+
if (event?.target) api.setResend(event.target.checked === true);
|
|
1998
|
+
},
|
|
1999
|
+
}),
|
|
2000
|
+
h("span", null, t("confirm.resend")),
|
|
2001
|
+
),
|
|
2002
|
+
h("div", { className: "dsh-srw-option-hint" }, t("confirm.resendHint")),
|
|
2003
|
+
),
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
1963
2006
|
if (state.busy) {
|
|
1964
|
-
blocks.push(
|
|
2007
|
+
blocks.push(
|
|
2008
|
+
h(
|
|
2009
|
+
"div",
|
|
2010
|
+
{ key: "busy", className: "dsh-srw-warn" },
|
|
2011
|
+
state.executing === true ? t("confirm.settling") : t("confirm.busy"),
|
|
2012
|
+
),
|
|
2013
|
+
);
|
|
1965
2014
|
}
|
|
1966
2015
|
if (state.errorKey) {
|
|
1967
2016
|
blocks.push(
|
|
@@ -2077,8 +2126,8 @@ window.__ModuleLoader__.load({
|
|
|
2077
2126
|
|
|
2078
2127
|
let footer;
|
|
2079
2128
|
if (confirmMode) {
|
|
2080
|
-
|
|
2081
|
-
|
|
2129
|
+
// v2.1:运行中的回合不再阻止执行——宿主会先取消并等到 idle 再回溯。
|
|
2130
|
+
let disabled = state.executing === true || state.previewLoading === true;
|
|
2082
2131
|
let executing = state.executing === true || state.loading === true;
|
|
2083
2132
|
footer = h(
|
|
2084
2133
|
Fragment,
|
|
@@ -2091,7 +2140,11 @@ window.__ModuleLoader__.load({
|
|
|
2091
2140
|
disabled: disabled,
|
|
2092
2141
|
onClick: () => api.doRewind(),
|
|
2093
2142
|
},
|
|
2094
|
-
executing
|
|
2143
|
+
executing
|
|
2144
|
+
? state.busy === true
|
|
2145
|
+
? t("dialog.settling")
|
|
2146
|
+
: t("dialog.executing")
|
|
2147
|
+
: t("dialog.confirm"),
|
|
2095
2148
|
),
|
|
2096
2149
|
);
|
|
2097
2150
|
} else {
|
|
@@ -2720,7 +2773,7 @@ window.__ModuleLoader__.load({
|
|
|
2720
2773
|
} else if (state.mode === "confirm" && event.key === "Enter") {
|
|
2721
2774
|
event.preventDefault();
|
|
2722
2775
|
event.stopPropagation();
|
|
2723
|
-
if (!state.
|
|
2776
|
+
if (!state.executing && !state.previewLoading) api.doRewind();
|
|
2724
2777
|
}
|
|
2725
2778
|
return;
|
|
2726
2779
|
}
|
|
@@ -3059,6 +3112,7 @@ window.__ModuleLoader__.load({
|
|
|
3059
3112
|
previewErrorParams: null,
|
|
3060
3113
|
rewindMode: "chat",
|
|
3061
3114
|
showAllPaths: false,
|
|
3115
|
+
resend: false,
|
|
3062
3116
|
busy: false,
|
|
3063
3117
|
executing: false,
|
|
3064
3118
|
loading: false,
|
|
@@ -3182,6 +3236,32 @@ window.__ModuleLoader__.load({
|
|
|
3182
3236
|
}
|
|
3183
3237
|
|
|
3184
3238
|
/** 回填输入框;返回 'ok' | 'draft' | 'none'。 */
|
|
3239
|
+
/** 复用刚回填的 draft 直接提交(「回退后立即重发」)。 @param {any} sessionId */
|
|
3240
|
+
function submitComposer(/** @type {any} */ sessionId) {
|
|
3241
|
+
try {
|
|
3242
|
+
let conversation = safeGet(ctx, "conversation");
|
|
3243
|
+
let scope =
|
|
3244
|
+
ctx.sessions && typeof ctx.sessions.scope === "function"
|
|
3245
|
+
? ctx.sessions.scope(sessionId)
|
|
3246
|
+
: undefined;
|
|
3247
|
+
if (!conversation?.input || typeof conversation.input.for !== "function") return false;
|
|
3248
|
+
if (scope === undefined) return false;
|
|
3249
|
+
let facade = conversation.input.for(scope);
|
|
3250
|
+
if (!facade) return false;
|
|
3251
|
+
if (facade.actions && typeof facade.actions.submit === "function") {
|
|
3252
|
+
facade.actions.submit();
|
|
3253
|
+
return true;
|
|
3254
|
+
}
|
|
3255
|
+
if (typeof facade.submit === "function") {
|
|
3256
|
+
facade.submit("queue");
|
|
3257
|
+
return true;
|
|
3258
|
+
}
|
|
3259
|
+
return false;
|
|
3260
|
+
} catch (_error) {
|
|
3261
|
+
return false;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3185
3265
|
function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
|
|
3186
3266
|
let facade;
|
|
3187
3267
|
try {
|
|
@@ -3298,6 +3378,7 @@ window.__ModuleLoader__.load({
|
|
|
3298
3378
|
previewLoading: false,
|
|
3299
3379
|
previewErrorKey: null,
|
|
3300
3380
|
previewErrorParams: null,
|
|
3381
|
+
resend: false,
|
|
3301
3382
|
busy: false,
|
|
3302
3383
|
executing: false,
|
|
3303
3384
|
loading: false,
|
|
@@ -3393,6 +3474,7 @@ window.__ModuleLoader__.load({
|
|
|
3393
3474
|
previewErrorParams: null,
|
|
3394
3475
|
rewindMode: effectiveMode(prefs.defaultMode, null),
|
|
3395
3476
|
showAllPaths: false,
|
|
3477
|
+
resend: false,
|
|
3396
3478
|
busy: busy,
|
|
3397
3479
|
executing: false,
|
|
3398
3480
|
loading: false,
|
|
@@ -3435,6 +3517,11 @@ window.__ModuleLoader__.load({
|
|
|
3435
3517
|
}
|
|
3436
3518
|
|
|
3437
3519
|
/** 确认框里切换回退范围。 */
|
|
3520
|
+
/** 「回退后立即重发」勾选。 @param {boolean} value */
|
|
3521
|
+
function setResend(/** @type {any} */ value) {
|
|
3522
|
+
patch({ resend: value === true });
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3438
3525
|
function setRewindMode(/** @type {any} */ mode) {
|
|
3439
3526
|
patch({ rewindMode: mode === "both" ? "both" : "chat" });
|
|
3440
3527
|
}
|
|
@@ -3461,6 +3548,7 @@ window.__ModuleLoader__.load({
|
|
|
3461
3548
|
previewLoading: false,
|
|
3462
3549
|
previewErrorKey: null,
|
|
3463
3550
|
previewErrorParams: null,
|
|
3551
|
+
resend: false,
|
|
3464
3552
|
busy: false,
|
|
3465
3553
|
executing: false,
|
|
3466
3554
|
loading: false,
|
|
@@ -3479,6 +3567,7 @@ window.__ModuleLoader__.load({
|
|
|
3479
3567
|
previewLoading: false,
|
|
3480
3568
|
previewErrorKey: null,
|
|
3481
3569
|
previewErrorParams: null,
|
|
3570
|
+
resend: false,
|
|
3482
3571
|
busy: false,
|
|
3483
3572
|
executing: false,
|
|
3484
3573
|
loading: false,
|
|
@@ -3531,6 +3620,8 @@ window.__ModuleLoader__.load({
|
|
|
3531
3620
|
let state = store.getSnapshot();
|
|
3532
3621
|
if (state?.executing) return;
|
|
3533
3622
|
let wasConfirm = state?.mode === "confirm";
|
|
3623
|
+
// 仅对话模式下才有「回退后立即重发」。
|
|
3624
|
+
let resend = wasConfirm && state?.resend === true && mode !== "both";
|
|
3534
3625
|
/** @type {any} */
|
|
3535
3626
|
let busyState = {
|
|
3536
3627
|
origin: origin,
|
|
@@ -3550,7 +3641,7 @@ window.__ModuleLoader__.load({
|
|
|
3550
3641
|
mode: mode === "both" ? "both" : "chat",
|
|
3551
3642
|
})
|
|
3552
3643
|
.then((/** @type {any} */ value) => {
|
|
3553
|
-
handleRewindSuccess(sessionId, target, value);
|
|
3644
|
+
handleRewindSuccess(sessionId, target, value, resend);
|
|
3554
3645
|
})
|
|
3555
3646
|
.catch((/** @type {any} */ error) => {
|
|
3556
3647
|
handleRewindFailure(sessionId, target, origin, wasConfirm, error);
|
|
@@ -3562,6 +3653,7 @@ window.__ModuleLoader__.load({
|
|
|
3562
3653
|
/** @type {any} */ sessionId,
|
|
3563
3654
|
/** @type {any} */ target,
|
|
3564
3655
|
/** @type {any} */ value,
|
|
3656
|
+
/** @type {boolean} */ resend,
|
|
3565
3657
|
) {
|
|
3566
3658
|
let summary = rewindSummaryOf(value);
|
|
3567
3659
|
cutStore.add(sessionId, {
|
|
@@ -3583,6 +3675,12 @@ window.__ModuleLoader__.load({
|
|
|
3583
3675
|
text += t("toast.rewoundFailed", { n: summary.files.failed.length });
|
|
3584
3676
|
}
|
|
3585
3677
|
}
|
|
3678
|
+
if (summary.cancelledTurn) text += t("toast.stoppedTurn");
|
|
3679
|
+
let resent = false;
|
|
3680
|
+
if (resend === true && outcome === "ok") {
|
|
3681
|
+
resent = submitComposer(sessionId);
|
|
3682
|
+
if (resent) text += t("toast.resent");
|
|
3683
|
+
}
|
|
3586
3684
|
if (outcome === "draft") text += t("toast.draftKept");
|
|
3587
3685
|
else if (outcome === "none") text += t("toast.noComposerSuffix");
|
|
3588
3686
|
patch({
|
|
@@ -3593,6 +3691,7 @@ window.__ModuleLoader__.load({
|
|
|
3593
3691
|
previewLoading: false,
|
|
3594
3692
|
previewErrorKey: null,
|
|
3595
3693
|
previewErrorParams: null,
|
|
3694
|
+
resend: false,
|
|
3596
3695
|
busy: false,
|
|
3597
3696
|
executing: false,
|
|
3598
3697
|
loading: false,
|
|
@@ -3655,8 +3754,8 @@ window.__ModuleLoader__.load({
|
|
|
3655
3754
|
function doRewind() {
|
|
3656
3755
|
let state = store.getSnapshot();
|
|
3657
3756
|
if (state?.mode !== "confirm" || !state.target) return;
|
|
3658
|
-
|
|
3659
|
-
|
|
3757
|
+
// 运行中的回合不再由客户端拒绝:宿主会先取消并等到 idle 再回溯(v2.1)。
|
|
3758
|
+
if (state.executing === true || state.previewLoading === true) return;
|
|
3660
3759
|
executeRewind(state.sessionId, state.target, state.rewindMode, state.origin || "row");
|
|
3661
3760
|
}
|
|
3662
3761
|
|
|
@@ -4069,6 +4168,7 @@ window.__ModuleLoader__.load({
|
|
|
4069
4168
|
pick: pick,
|
|
4070
4169
|
doRewind: doRewind,
|
|
4071
4170
|
setRewindMode: setRewindMode,
|
|
4171
|
+
setResend: setResend,
|
|
4072
4172
|
togglePathList: togglePathList,
|
|
4073
4173
|
setListSearch: setListSearch,
|
|
4074
4174
|
loadOlderCandidates: loadOlderCandidates,
|
package/lib/operations.js
CHANGED
|
@@ -124,12 +124,16 @@ function surfaceNodesOf(session) {
|
|
|
124
124
|
*
|
|
125
125
|
* - not-found:没有 live session(agent 也没有);
|
|
126
126
|
* - inert:子代理会话(`header.origin === "subagent"` 或 `delegationDepth > 0`);
|
|
127
|
-
* - busy:agent 存在且 `status !== "idle"
|
|
127
|
+
* - busy:agent 存在且 `status !== "idle"`,且调用方不允许运行中的会话。
|
|
128
|
+
*
|
|
129
|
+
* 只读操作(候选列表、预览、幽灵历史)传 `allowRunning: true`:它们不改日志,
|
|
130
|
+
* 运行中也能安全求值;真正写日志的 rewind 会先停下回合再重新求值。
|
|
128
131
|
* @param {ServiceDeps} deps
|
|
129
132
|
* @param {any} sessionId
|
|
133
|
+
* @param {{allowRunning?: boolean}} [options]
|
|
130
134
|
* @returns {ResolvedSession}
|
|
131
135
|
*/
|
|
132
|
-
export function resolveSession(deps, sessionId) {
|
|
136
|
+
export function resolveSession(deps, sessionId, options = {}) {
|
|
133
137
|
if (deps.available !== true) {
|
|
134
138
|
throw new RewindRequestError(
|
|
135
139
|
ERROR_CODES.failed,
|
|
@@ -158,7 +162,12 @@ export function resolveSession(deps, sessionId) {
|
|
|
158
162
|
`session "${sessionId}" is a subagent session and cannot be rewound`,
|
|
159
163
|
);
|
|
160
164
|
}
|
|
161
|
-
if (
|
|
165
|
+
if (
|
|
166
|
+
options.allowRunning !== true &&
|
|
167
|
+
agent !== undefined &&
|
|
168
|
+
agent !== null &&
|
|
169
|
+
agent.status !== "idle"
|
|
170
|
+
) {
|
|
162
171
|
throw new RewindRequestError(
|
|
163
172
|
ERROR_CODES.busy,
|
|
164
173
|
`session "${sessionId}" is not idle; stop the running turn before rewinding`,
|
|
@@ -218,7 +227,8 @@ function throwPlanError(planError) {
|
|
|
218
227
|
* @returns {{sessionId: string, candidates: any[], nextCursor: number | null}}
|
|
219
228
|
*/
|
|
220
229
|
export function candidatesResponse(deps, request) {
|
|
221
|
-
|
|
230
|
+
// 只读:运行中的会话也能列候选(客户端据此让用户选择「停止并回退」)。
|
|
231
|
+
const { session, sessionId } = resolveSession(deps, request?.sessionId, { allowRunning: true });
|
|
222
232
|
const page = listCandidatesPage(snapshotEventsOf(session), surfaceNodesOf(session), {
|
|
223
233
|
limit: request?.limit,
|
|
224
234
|
cursor: request?.cursor,
|
|
@@ -250,6 +260,8 @@ export function candidatesResponse(deps, request) {
|
|
|
250
260
|
* @returns {{targetSeq: number, markerSeq: number, removedCount: number, text: string, target: {text: string, images: any[]}}}
|
|
251
261
|
*/
|
|
252
262
|
export function rewindResponse(deps, request) {
|
|
263
|
+
// 严格模式:这个 helper 代表「append 时会话必须已 idle」。生产路径
|
|
264
|
+
// (SessionRewindService#executeRewind)会先停下运行中的回合再走到这里。
|
|
253
265
|
const { session, sessionId } = resolveSession(deps, request?.sessionId);
|
|
254
266
|
const targetSeq = request?.targetSeq;
|
|
255
267
|
if (!isEventSeq(targetSeq)) {
|
|
@@ -336,8 +348,8 @@ export function appendRewindMarker(session, plan, deps, extra = {}) {
|
|
|
336
348
|
* @param {any} request `{ sessionId, targetSeq }`
|
|
337
349
|
* @returns {{session: any, sessionId: string, plan: import("./plan.js").RewindPlan, targetEvent: any}}
|
|
338
350
|
*/
|
|
339
|
-
export function resolveRewindTarget(deps, request) {
|
|
340
|
-
const { session, sessionId } = resolveSession(deps, request?.sessionId);
|
|
351
|
+
export function resolveRewindTarget(deps, request, options = {}) {
|
|
352
|
+
const { session, sessionId } = resolveSession(deps, request?.sessionId, options);
|
|
341
353
|
const targetSeq = request?.targetSeq;
|
|
342
354
|
if (!isEventSeq(targetSeq)) {
|
|
343
355
|
throw new RewindRequestError(
|
|
@@ -358,6 +370,6 @@ export function resolveRewindTarget(deps, request) {
|
|
|
358
370
|
* @returns {{session: any, events: any[]}}
|
|
359
371
|
*/
|
|
360
372
|
export function sessionEvents(deps, sessionId) {
|
|
361
|
-
const { session } = resolveSession(deps, sessionId);
|
|
373
|
+
const { session } = resolveSession(deps, sessionId, { allowRunning: true });
|
|
362
374
|
return { session, events: snapshotEventsOf(session) };
|
|
363
375
|
}
|
package/lib/service.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
|
14
14
|
import { deriveEventMessage, isAppendSurfaceEvent } from "@deepseek-ai/dsh-session/surface";
|
|
15
15
|
import { estimateMessage } from "@deepseek-ai/dsh-token-meter/estimate";
|
|
16
16
|
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
17
|
+
import { settleAgent } from "./agent-settle.js";
|
|
17
18
|
import { installFileTracking } from "./file-tracking.js";
|
|
18
19
|
import { createFileStore } from "./files.js";
|
|
19
20
|
import { collectGhost } from "./ghost.js";
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
ERROR_CODES,
|
|
24
25
|
RewindRequestError,
|
|
25
26
|
resolveRewindTarget,
|
|
27
|
+
resolveSession,
|
|
26
28
|
sessionEvents,
|
|
27
29
|
} from "./operations.js";
|
|
28
30
|
import { findOrphans, pruneOrphans } from "./orphans.js";
|
|
@@ -403,7 +405,9 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
403
405
|
async preview(request) {
|
|
404
406
|
try {
|
|
405
407
|
const deps = this.deps();
|
|
406
|
-
const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request
|
|
408
|
+
const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request, {
|
|
409
|
+
allowRunning: true,
|
|
410
|
+
});
|
|
407
411
|
const events = session.snapshotEvents();
|
|
408
412
|
const tokens = this.estimateTokens(events, plan.shadowedSeqs);
|
|
409
413
|
const target = targetContent(targetEvent);
|
|
@@ -504,15 +508,36 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
504
508
|
*/
|
|
505
509
|
async executeRewind(input) {
|
|
506
510
|
const deps = this.deps();
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
511
|
+
// 先按「允许运行中」解析,拿到 agent;运行中的回合必须先停下,否则 surface 会在
|
|
512
|
+
// 我们求值与 append 之间继续增长,marker 的 replace 范围就失效了。
|
|
513
|
+
const pre = resolveSession(deps, input.sessionId, { allowRunning: true });
|
|
514
|
+
const agent = pre.agent;
|
|
515
|
+
/** @type {{cancelledTurn: boolean, droppedSteering: number}} */
|
|
516
|
+
const settle = { cancelledTurn: false, droppedSteering: 0 };
|
|
510
517
|
if (agent !== undefined && agent !== null && agent.status !== "idle") {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
518
|
+
if (this.config.cancelRunning === false) {
|
|
519
|
+
throw new RewindRequestError(
|
|
520
|
+
ERROR_CODES.busy,
|
|
521
|
+
`session "${input.sessionId}" is not idle; stop the running turn before rewinding`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const outcome = await settleAgent(agent, {
|
|
525
|
+
...(typeof this.config.cancelTimeoutMs === "number"
|
|
526
|
+
? { timeoutMs: this.config.cancelTimeoutMs }
|
|
527
|
+
: {}),
|
|
528
|
+
});
|
|
529
|
+
settle.cancelledTurn = outcome.cancelled;
|
|
530
|
+
settle.droppedSteering = outcome.droppedSteering.length;
|
|
531
|
+
if (!outcome.idle) {
|
|
532
|
+
throw new RewindRequestError(
|
|
533
|
+
ERROR_CODES.busy,
|
|
534
|
+
`session "${input.sessionId}" did not become idle after cancelling the running turn`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
515
537
|
}
|
|
538
|
+
// 停稳之后重新求值:取消前的快照可能已经被这一轮追加的事件改变。
|
|
539
|
+
const resolved = resolveRewindTarget(deps, input);
|
|
540
|
+
const { session, sessionId, plan, targetEvent } = resolved;
|
|
516
541
|
if (this.inflight.has(sessionId)) {
|
|
517
542
|
throw new RewindRequestError(
|
|
518
543
|
ERROR_CODES.failed,
|
|
@@ -618,6 +643,9 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
618
643
|
auditId,
|
|
619
644
|
target: targetContent(targetEvent),
|
|
620
645
|
files,
|
|
646
|
+
// v2.1:执行时是否为了回溯而停下了运行中的回合。
|
|
647
|
+
cancelledTurn: settle.cancelledTurn,
|
|
648
|
+
droppedSteering: settle.droppedSteering,
|
|
621
649
|
};
|
|
622
650
|
try {
|
|
623
651
|
await this.store.appendAudit({
|
|
@@ -631,6 +659,8 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
631
659
|
tokens,
|
|
632
660
|
files,
|
|
633
661
|
requestedBy: input.requestedBy,
|
|
662
|
+
cancelledTurn: settle.cancelledTurn,
|
|
663
|
+
droppedSteering: settle.droppedSteering,
|
|
634
664
|
});
|
|
635
665
|
} catch (error) {
|
|
636
666
|
this.ctx?.logger?.warn?.(
|
package/locale/en.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"confirm.pathsLess": "Hide paths",
|
|
29
29
|
"confirm.skipped": "{n} file(s) skipped",
|
|
30
30
|
"confirm.conflictsTitle": "Conflicting files (first 5)",
|
|
31
|
-
"confirm.busy": "The turn is still running
|
|
31
|
+
"confirm.busy": "The turn is still running: rewinding stops it first and drops any queued steering messages.",
|
|
32
32
|
"confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
|
|
33
33
|
"toast.done": "Rewound. The original message is back in the composer.",
|
|
34
34
|
"toast.draft": "Rewound. The composer already had content, so it was kept.",
|
|
@@ -116,6 +116,12 @@
|
|
|
116
116
|
"relative.justNow": "just now",
|
|
117
117
|
"relative.minutes": "{n} min ago",
|
|
118
118
|
"relative.hours": "{n} h ago",
|
|
119
|
-
"relative.days": "{n} d ago"
|
|
119
|
+
"relative.days": "{n} d ago",
|
|
120
|
+
"dialog.settling": "Stopping the running turn, then rewinding…",
|
|
121
|
+
"confirm.settling": "Stopping the running turn…",
|
|
122
|
+
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
123
|
+
"confirm.resend": "Send this message again right after rewinding",
|
|
124
|
+
"confirm.resendHint": "Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
125
|
+
"toast.resent": ", and the message was sent again"
|
|
120
126
|
}
|
|
121
127
|
}
|
package/locale/zh.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"confirm.pathsLess": "收起路径",
|
|
29
29
|
"confirm.skipped": "{n} 个文件被跳过",
|
|
30
30
|
"confirm.conflictsTitle": "冲突文件(前 5 个)",
|
|
31
|
-
"confirm.busy": "
|
|
31
|
+
"confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
|
|
32
32
|
"confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
|
|
33
33
|
"toast.done": "已回退到此处,原消息已放回输入框",
|
|
34
34
|
"toast.draft": "已回退;输入框已有内容,未覆盖",
|
|
@@ -116,6 +116,12 @@
|
|
|
116
116
|
"relative.justNow": "刚刚",
|
|
117
117
|
"relative.minutes": "{n} 分钟前",
|
|
118
118
|
"relative.hours": "{n} 小时前",
|
|
119
|
-
"relative.days": "{n} 天前"
|
|
119
|
+
"relative.days": "{n} 天前",
|
|
120
|
+
"dialog.settling": "正在停止当前回合并回退…",
|
|
121
|
+
"confirm.settling": "正在停止当前回合…",
|
|
122
|
+
"toast.stoppedTurn": ",已停止运行中的回合",
|
|
123
|
+
"confirm.resend": "回退后立即重发这条消息",
|
|
124
|
+
"confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
125
|
+
"toast.resent": ",已重发这条消息"
|
|
120
126
|
}
|
|
121
127
|
}
|
package/package.json
CHANGED