mocode-ai 1.7.5 → 1.7.7

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.
@@ -11,7 +11,7 @@ import { defaultAgentRuntimeContext } from './runtime-context.js';
11
11
  import { runModelTurn } from './model-turn.js';
12
12
  import { runToolTurn } from './tool-turn.js';
13
13
  import { createTurnLifecycle } from './turn-lifecycle.js';
14
- import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
14
+ import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, unregisteredToolOutcome, } from './tool-helpers.js';
15
15
  import { contextState, summarizeToolArguments } from '../session/index.js';
16
16
  import { createReadDedup } from '../tools/read-dedup.js';
17
17
  import { createBudgetScheduler } from '../session/scheduler.js';
@@ -424,10 +424,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
424
424
  const tc = calls[index];
425
425
  const outcome = {
426
426
  status: 'denied',
427
- code: 'TOOL_DISABLED',
427
+ code: isToolDeniedForStep(tc.name) && !ctx.toolRuntime.findTool(tc.name)
428
+ ? 'UNKNOWN_TOOL'
429
+ : 'TOOL_DISABLED',
428
430
  retryable: false,
429
431
  output: isToolDeniedForStep(tc.name)
430
- ? `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
432
+ ? !ctx.toolRuntime.findTool(tc.name)
433
+ ? unregisteredToolOutcome(tc.name, ctx.toolRuntime).output
434
+ : `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
431
435
  : `错误:add_tool_groups 不能与写/执行工具 ${tc.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${tc.name}。`,
432
436
  changedFiles: [],
433
437
  durationMs: 0,
@@ -526,10 +530,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
526
530
  const currentCall = calls[i];
527
531
  if (isToolDeniedForStep(currentCall.name)) {
528
532
  hooks.onToolHeader?.(currentCall);
529
- const error = t('task.disabled');
533
+ // registry 中根本不存在的名字(模型幻觉/拼写错误)给专门纠错提示,
534
+ // 不再复用 task.disabled 的 /subagent 误导文案;已注册但本步不允许才用 gate 文案。
535
+ const error = !ctx.toolRuntime.findTool(currentCall.name)
536
+ ? unregisteredToolOutcome(currentCall.name, ctx.toolRuntime).output
537
+ : t('task.disabled');
530
538
  const outcome = {
531
539
  status: 'denied',
532
- code: 'TOOL_DISABLED',
540
+ code: !ctx.toolRuntime.findTool(currentCall.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
533
541
  retryable: false,
534
542
  output: error,
535
543
  changedFiles: [],
@@ -5,11 +5,19 @@ function replaceMessages(backing, messages) {
5
5
  backing.push(...replacement);
6
6
  }
7
7
  function assistantMessage(turn) {
8
+ const hasToolCalls = turn.toolCalls.length > 0;
8
9
  const message = {
9
10
  role: 'assistant',
10
- content: turn.content,
11
+ // 无 tool_calls 时 content 不能是 null:那是 OpenAI 自己吐 tool_call 时的规范形状,
12
+ // 纯文本轮次写 null 会被严格 OpenAI 兼容网关整请求 400(实测 speedbear:
13
+ // "messages[N].content 不能为 null … 无文本请传空字符串"),且消息一旦落进 history
14
+ // 就每轮重发 → 整个会话永久中毒,换个会话同模型却正常。来源是 SSE 流中途结束
15
+ // (网关掐断)时 SDK 不抛错、content 停在 null 而 tool_calls 为空,框架仍当
16
+ // 「agent 完成了这一轮」写盘。构造点在此收敛,transport 边界另有兜底
17
+ // (src/llm/message-shape.ts)负责让存量中毒会话自愈。
18
+ content: !hasToolCalls && turn.content === null ? '' : turn.content,
11
19
  };
12
- if (turn.toolCalls.length > 0) {
20
+ if (hasToolCalls) {
13
21
  // thoughtSignature 不是 OpenAI 字段,是 mocode 给 Gemini/Vertex 透传用的;
14
22
  // 挂在 tool_call 上随 history 落盘/回灌,Vertex encoder 会读回它。
15
23
  message.tool_calls = turn.toolCalls.map((call) => ({
@@ -7,7 +7,7 @@ import { summarizeToolArguments } from '../../session/index.js';
7
7
  import { getPlanDisabledTools } from '../../tools/constants.js';
8
8
  import { defaultToolRuntime } from '../../tools/registry.js';
9
9
  import { validateToolArguments } from '../../tools/validation.js';
10
- import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, } from '../tool-helpers.js';
10
+ import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, unregisteredToolOutcome, } from '../tool-helpers.js';
11
11
  const DEFAULT_DEPENDENCIES = {
12
12
  toolRuntime: defaultToolRuntime,
13
13
  checkPermission: defaultCheckPermission,
@@ -119,10 +119,12 @@ class LegacyCompatibleToolDispatcher {
119
119
  const call = calls[index];
120
120
  const outcome = {
121
121
  status: 'denied',
122
- code: 'TOOL_DISABLED',
122
+ code: request.isDenied(call.name) && !toolRuntime.findTool(call.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
123
123
  retryable: false,
124
124
  output: request.isDenied(call.name)
125
- ? `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
125
+ ? !toolRuntime.findTool(call.name)
126
+ ? unregisteredToolOutcome(call.name, toolRuntime).output
127
+ : `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
126
128
  : `错误:add_tool_groups 不能与写/执行工具 ${call.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${call.name}。`,
127
129
  changedFiles: [],
128
130
  durationMs: 0,
@@ -213,10 +215,12 @@ class LegacyCompatibleToolDispatcher {
213
215
  const current = calls[index];
214
216
  if (request.isDenied(current.name)) {
215
217
  request.onEvent({ type: 'header', call: current });
216
- const output = t('task.disabled');
218
+ // registry 中不存在的名字(幻觉/拼写错误)给纠错提示,不误导用户去开 /subagent。
219
+ const unregistered = !toolRuntime.findTool(current.name);
220
+ const output = unregistered ? unregisteredToolOutcome(current.name, toolRuntime).output : t('task.disabled');
217
221
  const outcome = {
218
222
  status: 'denied',
219
- code: 'TOOL_DISABLED',
223
+ code: unregistered ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
220
224
  retryable: false,
221
225
  output,
222
226
  changedFiles: [],
@@ -16,6 +16,7 @@ import { capToolResultForHistory } from '../session/compact.js';
16
16
  import { recordArtifact, knownEditTargets } from '../context/index.js';
17
17
  import { isToolResultSuccess } from '../context/utils.js';
18
18
  import { appendGuiAction } from '../session/gui-actions.js';
19
+ import { t } from '../i18n/index.js';
19
20
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
20
21
  export function parseArgs(raw) {
21
22
  try {
@@ -76,6 +77,52 @@ export function deniedOutcome(name) {
76
77
  output: `错误:用户拒绝了工具 ${name} 的执行。`,
77
78
  };
78
79
  }
80
+ /** 编辑距离(Levenshtein);名字很短时用上限截断避免 O(n²) 浪费。 */
81
+ function editDistance(a, b, max = 4) {
82
+ if (Math.abs(a.length - b.length) > max)
83
+ return max + 1;
84
+ const prev = new Array(b.length + 1);
85
+ const curr = new Array(b.length + 1);
86
+ for (let j = 0; j <= b.length; j++)
87
+ prev[j] = j;
88
+ for (let i = 1; i <= a.length; i++) {
89
+ curr[0] = i;
90
+ let rowMin = curr[0];
91
+ for (let j = 1; j <= b.length; j++) {
92
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
93
+ if (curr[j] < rowMin)
94
+ rowMin = curr[j];
95
+ }
96
+ if (rowMin > max)
97
+ return max + 1;
98
+ for (let j = 0; j <= b.length; j++)
99
+ prev[j] = curr[j];
100
+ }
101
+ return prev[b.length];
102
+ }
103
+ /**
104
+ * 未注册工具名(模型幻觉/拼写错误)的专门 denied 结果。
105
+ * isToolDeniedForStep 对不在 allowedToolNames 里的一切名字(含 registry 中不存在的)都返回
106
+ * true,旧路径一律复用 task.disabled 文案,误导用户去开 /subagent。此 helper 先查 registry:
107
+ * 名字根本不存在时给出「未注册 + 相近候选」的纠错提示;存在但本步不允许时才回落 gate 文案。
108
+ */
109
+ export function unregisteredToolOutcome(name, toolRuntime) {
110
+ const registered = toolRuntime.tools.map((tool) => tool.name);
111
+ const ranked = registered
112
+ .map((candidate) => ({
113
+ candidate,
114
+ distance: editDistance(name.toLowerCase(), candidate.toLowerCase()),
115
+ }))
116
+ .sort((x, y) => x.distance - y.distance);
117
+ const close = ranked.filter((entry) => entry.distance <= 3).slice(0, 3);
118
+ const suggestions = close.length > 0 ? close.map((entry) => entry.candidate).join(', ') : t('toolError.noCloseMatch');
119
+ return {
120
+ status: 'denied',
121
+ code: 'UNKNOWN_TOOL',
122
+ retryable: false,
123
+ output: t('toolError.unregistered', { name, suggestions }),
124
+ };
125
+ }
79
126
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
80
127
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
81
128
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
@@ -22,6 +22,13 @@ const ATTACHMENT_TAIL = '. Analyze the attached image content directly.';
22
22
  const UNKNOWN_FRAME_NAME = 'frame';
23
23
  /** 恢复提示只写进**第一条**被剪占位,避免 N 条占位重复同一句话侵蚀 token。 */
24
24
  const RECOVERY_HINT = ' Call computer{screenshot} if you need the current screen.';
25
+ /**
26
+ * 帧被剪枝后占位行的固定前缀(见 pruneFrameMessage,完整行为 `[screenshot omitted: <帧名> · step N]`)。
27
+ * 剪枝会把合成附件消息整个 content 换成占位(前言 ATTACHMENT_PREAMBLE 随之消失),
28
+ * rollback 对齐真实用户轮时必须认出这个占位、不把它算成一轮——那里因循环依赖无法
29
+ * import 本模块,故在 rollback/store.ts 内联了同一行格式(按整行正则判定),修改此串时两处必须同步。
30
+ */
31
+ const PRUNED_FRAME_PREFIX = '[screenshot omitted: ';
25
32
  /**
26
33
  * 白名单:参与滑动窗口的屏幕帧名前缀。
27
34
  *
@@ -139,11 +146,11 @@ function pruneFrameMessage(message, names, step, withHint) {
139
146
  for (const part of parts) {
140
147
  if (part.type !== 'image_url')
141
148
  continue;
142
- lines.push(`[screenshot omitted: ${names[index] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
149
+ lines.push(`${PRUNED_FRAME_PREFIX}${names[index] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
143
150
  index++;
144
151
  }
145
152
  if (lines.length === 0)
146
- lines.push(`[screenshot omitted: ${names[0] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
153
+ lines.push(`${PRUNED_FRAME_PREFIX}${names[0] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
147
154
  if (withHint)
148
155
  lines[0] = `${lines[0]}${RECOVERY_HINT}`;
149
156
  const content = [{ type: 'text', text: lines.join('\n') }];
@@ -241,6 +241,8 @@ const zhCN = {
241
241
  'toolSummary.noOutput': '(无输出)',
242
242
  'toolSummary.imageAttached': '图片已作为视觉输入附加',
243
243
  'toolError.unknown': '错误:未知工具 "{name}"',
244
+ 'toolError.unregistered': '错误:工具 "{name}" 未注册(不在本会话可用工具列表中,可能是拼写错误或模型幻觉出的名称)。相近的已注册工具:{suggestions}。请只调用 tools 列表中存在的名称,不要自造工具名。',
245
+ 'toolError.noCloseMatch': '无相近的已注册工具名',
244
246
  'toolError.invalidJson': '错误:工具 {name} 的 arguments 不是合法 JSON: {arguments}',
245
247
  'toolError.execution': '错误:工具 {name} 执行失败: {message}',
246
248
  'command.outputTruncated': '...(输出已截断 {count} 字符,保留开头与结尾)...',
@@ -624,6 +626,8 @@ const en = {
624
626
  'toolSummary.noOutput': '(no output)',
625
627
  'toolSummary.imageAttached': 'Image attached as visual input',
626
628
  'toolError.unknown': 'Error: unknown tool "{name}"',
629
+ 'toolError.unregistered': 'Error: tool "{name}" is not registered (it is not in this session\'s available tool list — likely a misspelling or a model-hallucinated name). Closest registered tools: {suggestions}. Only call names that appear in the tools list; never invent tool names.',
630
+ 'toolError.noCloseMatch': 'no close match among registered tools',
627
631
  'toolError.invalidJson': 'Error: arguments for tool {name} are not valid JSON: {arguments}',
628
632
  'toolError.execution': 'Error: tool {name} failed: {message}',
629
633
  'command.outputTruncated': '...(output truncated by {count} characters; keeping the beginning and end)...',
package/dist/llm/index.js CHANGED
@@ -5,6 +5,7 @@ import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constant
5
5
  import { imageSizeFromDataUrl } from '../attachments/image.js';
6
6
  import { ThinkTagFilter } from './think-filter.js';
7
7
  import { sanitizeToolSchemas } from './tool-schema.js';
8
+ import { sanitizeAssistantContent } from './message-shape.js';
8
9
  import { isMarkedStreamInterrupted } from './stream-interrupt.js';
9
10
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
10
11
  import { vertexChatOnce } from './providers/vertex.js';
@@ -676,7 +677,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
676
677
  });
677
678
  const stream = await create({
678
679
  model: runtime.getModel(),
679
- messages: normalizeImageDetail(messages),
680
+ messages: sanitizeAssistantContent(normalizeImageDetail(messages)),
680
681
  stream: true,
681
682
  // 空工具表(如摘要请求)不发 tools 字段——部分网关拒绝空数组。
682
683
  ...(activeTools.length > 0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 把「无文本、无 tool_calls」的 assistant 消息的 `content: null` 改写成空字符串。
3
+ *
4
+ * 背景:`content: null` 在 assistant 消息上**只在携带 tool_calls 时合法**(那是 OpenAI 自己
5
+ * 吐 tool_call 时的规范形状)。纯文本轮次写成 null 会被严格 OpenAI 兼容网关整请求拒绝。
6
+ * speedbear(https://speedbear.redbearai.com/api/v1)实测:
7
+ * `{"error_code":"REQUEST_VALIDATION_FAILED",
8
+ * "detail":"messages[12].content 不能为 null(assistant 消息仅在携带 tool_calls/
9
+ * function_call 时允许省略 content;无文本请传空字符串 \"\")"}`
10
+ * 且形状探针逐条确认:`null`+tool_calls → 200;`content:[]` → 200;缺 content 字段 → 200;
11
+ * `content:""` → 200;连续 user 消息 → 200。**只有「null 且无 tool_calls」这一种被拒。**
12
+ *
13
+ * 在 agent 侧的表现极具迷惑性——「某个会话从某一轮起每一步都 400、换个会话同模型却正常」:
14
+ * 毒消息一旦写进 history 就会每轮重发,而它是**合法 JSON、合法 OpenAI 类型**,肉眼 review
15
+ * 抓不到;`/resume` 也救不回来(毒在落盘文件里)。更隐蔽的是它的来源:SSE 流中途结束
16
+ * (网关超时/上游掐断)时 SDK 不抛错,`content` 停在 null、tool_calls 为空,
17
+ * 框架把它当「agent 完成了这一轮」照写 history——于是一步正常的历史被永久污染。
18
+ *
19
+ * 与 `sanitizeToolSchemas`(剔 uniqueItems)同源同策:**放 transport 边界而非只修构造点**——
20
+ * 已经被旧版本写进 session.json 的中毒会话、外部注入的历史、resume 续接的会话都能自愈,
21
+ * 只修构造点救不了存量。构造点(`agent/stages/history-manager.ts`)同步收敛,保证新历史
22
+ * 不再生产这种形状,两层各司其职。
23
+ *
24
+ * 无需改写时返回**原数组引用**——history 参与前缀缓存,逐字节稳定才能命中;
25
+ * 每次新建对象会击穿缓存(同 `sanitizeToolSchemas` 的返回值约定)。
26
+ */
27
+ export function sanitizeAssistantContent(messages) {
28
+ let changed = false;
29
+ const next = messages.map((message) => {
30
+ if (message.role !== 'assistant')
31
+ return message;
32
+ const raw = message;
33
+ // 带 tool_calls 时 null 是规范形状(OpenAI 自己就这么吐),原样保留。
34
+ if (Array.isArray(raw.tool_calls) && raw.tool_calls.length > 0)
35
+ return message;
36
+ if (raw.content !== null)
37
+ return message;
38
+ changed = true;
39
+ return { ...message, content: '' };
40
+ });
41
+ return changed ? next : messages;
42
+ }
@@ -352,12 +352,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
352
352
  layout.contentWrite(`${ui.dim}(没有可回滚的轮次)${ui.reset}\n`);
353
353
  return;
354
354
  }
355
- // 各轮 user 全文(预填用;按 user 消息顺序与 turnList 对齐)
356
- const userTexts = [];
357
- for (const m of history) {
358
- if (m.role === 'user')
359
- userTexts.push(textOf(m.content));
360
- }
361
355
  const items = turnList.map((t) => ({ firstLine: t.firstLine }));
362
356
  let picked;
363
357
  try {
@@ -368,8 +362,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
368
362
  }
369
363
  if (picked === null)
370
364
  return; // Esc 取消
371
- // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保 1..picked),预填该轮 user 输入
372
- const prefillText = userTexts[picked] ?? '';
365
+ // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保前 picked 轮),
366
+ // 预填该轮 user 输入。必须经 store 按"尾部对齐的真实用户轮消息"定位——直接按 history 里
367
+ // role==='user' 数下标会把工具附件的合成 user 消息/被剪枝老轮算进去,预填错轮。
368
+ const pickedMessage = runtime.rollback.turnUserMessage(picked, history);
369
+ const prefillText = pickedMessage ? textOf(pickedMessage.content) : '';
373
370
  const plan = runtime.rollback.plan(picked, history);
374
371
  // 清屏(擦选轮菜单 + /rollback 回显)+ 复位 lastView(dim 空),给文件询问一个干净、resize 安全的画面
375
372
  layout.clearContent();
@@ -47,6 +47,61 @@ const EXCLUDED_WORKSPACE_DIRS = Object.freeze([
47
47
  '.ruff_cache',
48
48
  '.gradle',
49
49
  ]);
50
+ /**
51
+ * 工具经 modelAttachments 注入的合成 user 消息前言(见 agent/tool-turn.ts 的
52
+ * ATTACHMENT_PREAMBLE)。这类消息 role 也是 'user',但不对应 beginTurn 的真实用户轮,
53
+ * 回滚按 role==='user' 数轮次会与 turns 数组错位,导致"回退一条却连带删掉好几轮"。
54
+ * 两处常量必须保持一致;不 import tool-turn 是为避免 rollback→agent 的层级反向依赖。
55
+ */
56
+ const TOOL_ATTACHMENT_PREAMBLE = 'The read_file tool loaded the following visual input: ';
57
+ /**
58
+ * 视觉滑动窗口剪枝占位的机器固定格式(必须与 context/vision-window.ts 的
59
+ * pruneFrameMessage 输出逐字一致):一行或多行 `[screenshot omitted: <帧名> · step <数字>]`,
60
+ * 首行可能再跟固定恢复提示后缀。用整串正则而不是前缀匹配,避免把恰好以
61
+ * "[screenshot omitted: " 开头的真实用户输入误判成占位。
62
+ *
63
+ * 这里内联而不是 import vision-window:vision-window → llm → … → rollback/index 会形成
64
+ * 循环依赖,在共享进程的测试里触发 "Cannot access 'defaultRollbackStore' before initialization"。
65
+ */
66
+ const PRUNED_FRAME_LINE_RE = /^\[screenshot omitted: .+? · step \d+\]$/;
67
+ const PRUNED_FRAME_HINT_SUFFIX = ' Call computer{screenshot} if you need the current screen.';
68
+ function isPrunedFrameText(text) {
69
+ if (typeof text !== 'string')
70
+ return false;
71
+ const lines = text.split('\n');
72
+ return lines.every((line, i) => {
73
+ const core = i === 0 && line.endsWith(PRUNED_FRAME_HINT_SUFFIX) ? line.slice(0, -PRUNED_FRAME_HINT_SUFFIX.length) : line;
74
+ return PRUNED_FRAME_LINE_RE.test(core);
75
+ });
76
+ }
77
+ /**
78
+ * 判断一条 user 消息是否对应一个真实用户轮(= beginTurn 记进 turns 的那一条)。
79
+ *
80
+ * 排除两种合成 user 消息(都是 role:'user' 但不计轮次):
81
+ * 1. 工具批次提交时由 modelAttachments 注入的附件消息:ContentPart[] 的 text part
82
+ * 以工具附件前言开头;
83
+ * 2. 上述附件消息经视觉滑动窗口剪枝后的占位:整个 content 被替换成
84
+ * "[screenshot omitted: …]" 文本,前言已不存在(见 vision-window.pruneFrameMessage),
85
+ * 形态可能是数组 text part,也可能是纯字符串。
86
+ *
87
+ * 用户自己发的多模态消息没有这两个标记,不会被误判。
88
+ */
89
+ function isRealTurnUserMessage(message) {
90
+ if (message.role !== 'user')
91
+ return false;
92
+ const content = message.content;
93
+ if (typeof content === 'string')
94
+ return !isPrunedFrameText(content);
95
+ if (!Array.isArray(content))
96
+ return false;
97
+ for (const part of content) {
98
+ if (part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
99
+ if (part.text.startsWith(TOOL_ATTACHMENT_PREAMBLE) || isPrunedFrameText(part.text))
100
+ return false;
101
+ }
102
+ }
103
+ return true;
104
+ }
50
105
  const CAPTURE_FILE_LIMIT = 1024 * 1024;
51
106
  const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
52
107
  const CAPTURE_ENTRY_LIMIT = 20000;
@@ -440,6 +495,32 @@ export class RollbackStore {
440
495
  listTurns() {
441
496
  return this.turns.slice();
442
497
  }
498
+ /**
499
+ * 取菜单第 pickedIndex(0-based)轮对应的真实用户消息(用于 /rollback 后预填)。
500
+ * 与 findCutoffIndex 同一套尾对齐规则。无对应消息(续接历史缺轮)时返回 undefined。
501
+ */
502
+ turnUserMessage(pickedIndex, history) {
503
+ const index = this.findTurnUserIndex(pickedIndex, history);
504
+ return index < 0 ? undefined : history[index];
505
+ }
506
+ /**
507
+ * 菜单第 pickedIndex(0-based)轮对应的真实用户消息在 history 中的下标。
508
+ * turns 是真实轮次的尾部连续子集(滚动剪枝/压缩只丢旧轮),故菜单第 k 轮 =
509
+ * history 中倒数第 (turns.length-k) 条真实用户轮消息。找不到返回 -1。
510
+ */
511
+ findTurnUserIndex(pickedIndex, history) {
512
+ let wantReverseRank = this.turns.length - pickedIndex;
513
+ if (wantReverseRank <= 0)
514
+ return -1;
515
+ for (let i = history.length - 1; i >= 0; i--) {
516
+ if (!isRealTurnUserMessage(history[i]))
517
+ continue;
518
+ wantReverseRank -= 1;
519
+ if (wantReverseRank === 0)
520
+ return i;
521
+ }
522
+ return -1;
523
+ }
443
524
  /**
444
525
  * 只读侧门(供 /diff 渲染):返回 turnId > sinceTurnId 的全部快照,浅拷贝数组。
445
526
  * 快照内容含 before 原文,调用方不得 mutate 返回值;用于展示,不影响回滚语义。
@@ -447,19 +528,21 @@ export class RollbackStore {
447
528
  snapshotsSince(sinceTurnId) {
448
529
  return this.snapshots.filter((snapshot) => snapshot.turnId > sinceTurnId);
449
530
  }
531
+ /**
532
+ * 回滚切点:保留菜单前 n 轮,从第 n+1 轮(菜单下标 n,0-based)的真实 user 消息处切断。
533
+ *
534
+ * turns(菜单)= 当前仍可回滚的轮次,是真实轮次的**尾部连续子集**(滚动剪枝/压缩只丢旧轮),
535
+ * 故从尾部倒数定位,而不是正数第 n 条——压缩的「user 逐字通道」会把更老的 user 原文
536
+ * 捞回保留区,正数下标与 turns 不再连续对齐。找不到(无第 n+1 轮)时返回 history.length。
537
+ */
450
538
  findCutoffIndex(n, history) {
451
- let seen = 0;
452
- for (let i = 0; i < history.length; i++) {
453
- if (history[i].role === 'user') {
454
- seen += 1;
455
- if (seen === n + 1)
456
- return i;
457
- }
458
- }
459
- return history.length;
539
+ const index = this.findTurnUserIndex(n, history);
540
+ return index < 0 ? history.length : index;
460
541
  }
461
542
  planRollback(n, history) {
462
543
  const cutoffTurnId = this.turns[n - 1]?.turnId ?? 0;
544
+ // turns 与 history 真实 user 消息尾对齐后再取切点:滚动剪枝(>50 轮)/压缩/续接后
545
+ // turns 只是尾部子集,不能再把 n 当成全局第 n 条 user 消息,否则会多删 offset 轮。
463
546
  const cutoffIndex = this.findCutoffIndex(n, history);
464
547
  const order = [];
465
548
  const map = new Map();
@@ -587,7 +670,9 @@ export class RollbackStore {
587
670
  return { deletedMsgs, revertedFiles, conflictedFiles };
588
671
  }
589
672
  pruneAfterCompaction(history) {
590
- const count = history.filter((message) => message.role === 'user').length;
673
+ // 只数真实用户轮(排除工具附件合成 user 消息);压缩会整体丢弃旧轮,
674
+ // 保留下来的总是最近的轮次,故 turns 同样从尾部对齐截取。
675
+ const count = history.filter((message) => isRealTurnUserMessage(message)).length;
591
676
  this.turns = count >= this.turns.length ? this.turns : this.turns.slice(-count);
592
677
  const alive = new Set(this.turns.map((turn) => turn.turnId));
593
678
  this.snapshots = this.snapshots.filter((snapshot) => alive.has(snapshot.turnId));
@@ -603,7 +688,9 @@ export class RollbackStore {
603
688
  rebuildFromHistory(history) {
604
689
  const rebuilt = [];
605
690
  for (const message of history) {
606
- if (message.role !== 'user')
691
+ // 跳过工具附件注入的合成 user 消息,否则 turns 比真实轮次多,
692
+ // /rollback 菜单与 history 切点(planRollback 按 turns[n-1])整体错位。
693
+ if (!isRealTurnUserMessage(message))
607
694
  continue;
608
695
  const first = toText(message.content).split('\n')[0] ?? '';
609
696
  rebuilt.push({ turnId: rebuilt.length + 1, firstLine: truncateDisplay(first, 40) });
@@ -90,6 +90,10 @@ export class Runtime {
90
90
  this.assertOpen();
91
91
  return this.context.rollbackStore.listTurns();
92
92
  },
93
+ turnUserMessage: (pickedIndex, history) => {
94
+ this.assertOpen();
95
+ return this.context.rollbackStore.turnUserMessage(pickedIndex, history);
96
+ },
93
97
  plan: (n, history) => {
94
98
  this.assertIdle('plan rollback');
95
99
  const plan = this.context.rollbackStore.planRollback(n, history);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {