From 8f08977892463278814253ecf6c6f03a7d4e62a5 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 20 Jul 2026 03:48:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=8A=A0=20per-agent=20noResume=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AgentConfig.noResume:开启后该 agent 每条消息都用全新 SDK 会话 (不 resume 上一轮),配合注入的最近 N 条聊天历史提供上下文,避免高频 闲聊型 agent(如 pm)长会话逐轮累积把上下文推到数十万 token、成本失控。 - types.ts: AgentConfig 加 noResume?: boolean - config-schema.ts: input + defaults schema 加 noResume - config-loader.ts: mergeAgentConfig 透传(agent 级覆盖 defaults) - agents.example.json: pm 示例开启 noResume=true Co-Authored-By: Claude Opus 4.8 (1M context) --- config/agents.example.json | 1 + src/agent/config-loader.ts | 1 + src/agent/config-schema.ts | 3 +++ src/agent/types.ts | 6 ++++++ 4 files changed, 11 insertions(+) diff --git a/config/agents.example.json b/config/agents.example.json index f002524f..983cd10f 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -21,6 +21,7 @@ }, "model": "claude-opus-4-6", "replyMode": "direct", + "noResume": true, "toolPolicy": { "profile": "readonly", "allow": ["Skill", "Bash"] diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 7bc9d281..dd956cb0 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -106,6 +106,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age maxTurns: input.maxTurns ?? defaults.maxTurns ?? config.claude.maxTurns, requiresApproval: input.requiresApproval ?? defaults.requiresApproval ?? BUILTIN_DEFAULTS.requiresApproval!, replyMode: input.replyMode ?? defaults.replyMode ?? BUILTIN_DEFAULTS.replyMode! as 'direct' | 'thread', + noResume: input.noResume ?? defaults.noResume, persona: input.persona ?? defaults.persona, knowledge: input.knowledge ?? defaults.knowledge, toolAllow, diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 31c04e93..9744b3c5 100644 --- a/src/agent/config-schema.ts +++ b/src/agent/config-schema.ts @@ -76,6 +76,8 @@ export const AgentConfigInputSchema = z.object({ requiresApproval: z.boolean().optional(), /** 默认回复模式 */ replyMode: z.enum(['direct', 'thread']).optional(), + /** 关闭 SDK resume:每条消息全新会话(配合最近 N 条历史注入),用于降低长会话累积成本 */ + noResume: z.boolean().optional(), /** 人格提示词文件路径(每次 query 重新读取)。有 persona → replace 模式;无 → append 模式 */ persona: z.string().optional(), /** 知识文件列表(相对于 knowledgeDir 的文件名)。agent 级完整覆盖 defaults */ @@ -98,6 +100,7 @@ export const AgentDefaultsSchema = z.object({ maxTurns: z.number().int().positive().max(10000).optional(), requiresApproval: z.boolean().optional(), replyMode: z.enum(['direct', 'thread']).optional(), + noResume: z.boolean().optional(), persona: z.string().optional(), knowledge: z.array(z.string()).optional(), editablePathPatterns: z.array(z.string()).optional(), diff --git a/src/agent/types.ts b/src/agent/types.ts index cf195958..6c75d544 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -55,6 +55,12 @@ export interface AgentConfig { requiresApproval: boolean; /** 默认回复模式 */ replyMode: ReplyMode; + /** + * 关闭 SDK session resume:每条消息都用全新会话(不 resume 上一轮)。 + * 配合注入的"最近 N 条聊天历史"提供上下文,避免长会话逐轮累积把上下文 + * 推到数十万 token、成本失控。适合高频闲聊型 agent(如 pm)。 + */ + noResume?: boolean; /** 人格提示词文件路径(每次 query 重新读取,支持热更新)。有 persona → replace 模式;无 → append 模式 */ persona?: string; /** 知识文件列表(相对于 knowledgeDir 的文件名,每次 query 重新读取) */ From 2686498ce5417a93d42f8d8c9a2e276a440f69f0 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 20 Jul 2026 03:49:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=96=B0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=8D=20resume=20=E6=97=B6=E6=B3=A8=E5=85=A5=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=9C=80=E8=BF=91=E5=8E=86=E5=8F=B2=20+=20=E4=B8=8A?= =?UTF-8?q?=E8=B0=83=E5=8E=86=E5=8F=B2=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出纯函数 canResumeSession 统一 resume 判定(无会话 / cwd 变更 / agent noResume → 全新会话),并在 executeClaudeTask、executeDirectTask 两条路径接入。 关键修正:历史增量去重(afterMsgId)与历史文件附件跳过原本以 activeConversationId 为准,导致不 resume(noResume 或 cwd 变更)时 只注入增量历史、SDK 端却无对话记忆 → 失忆。改为以 canResume 为准: 只有真正 resume 时才做增量,否则注入完整最近 N 条。 配套上调默认预算,让 fresh 会话装得下足够上下文: - CHAT_HISTORY_MAX_COUNT 10 → 20 - CHAT_HISTORY_MAX_CHARS 8000 → 16000 - SELF_BOT_MSG_MAX 150 → 1500 (noResume 下历史是唯一自我记忆来源, 需保留较完整的自身回复;字符硬顶仍防爆) Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 8 ++--- src/config.ts | 4 +-- src/feishu/event-handler.ts | 70 +++++++++++++++++++++++++++++-------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 899eb5b6..d08ea74b 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,10 @@ FEISHU_TOOLS_BITABLE=true FEISHU_TOOLS_CALENDAR=true # === 聊天上下文配置 === -# 注入初始上下文时最多拉取的历史消息条数 (默认 10) -# CHAT_HISTORY_MAX_COUNT=10 -# 历史上下文总字符上限,超出时从最旧的消息开始丢弃 (默认 4000) -# CHAT_HISTORY_MAX_CHARS=4000 +# 注入初始上下文时最多拉取的历史消息条数 (默认 20) +# CHAT_HISTORY_MAX_COUNT=20 +# 历史上下文总字符上限,超出时从最旧的消息开始丢弃 (默认 16000) +# CHAT_HISTORY_MAX_CHARS=16000 # === 工作区配置 === # 自动 clone 的工作区根目录 (默认 DEFAULT_WORK_DIR/.workspaces) diff --git a/src/config.ts b/src/config.ts index cb422f96..313b2c6a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -164,9 +164,9 @@ export const config = { // 聊天上下文配置 chat: { /** 注入初始上下文时最多拉取的历史消息条数 */ - historyMaxCount: parseInt(process.env.CHAT_HISTORY_MAX_COUNT || '10', 10), + historyMaxCount: parseInt(process.env.CHAT_HISTORY_MAX_COUNT || '20', 10), /** 历史上下文总字符上限(超出时从最旧的消息开始丢弃) */ - historyMaxChars: parseInt(process.env.CHAT_HISTORY_MAX_CHARS || '8000', 10), + historyMaxChars: parseInt(process.env.CHAT_HISTORY_MAX_CHARS || '16000', 10), }, // DashScope (阿里云百炼) 通用配置 diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 409227d8..a52e9755 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -2291,7 +2291,9 @@ async function formatHistoryMessages( } const USER_MSG_MAX = 500; - const SELF_BOT_MSG_MAX = 150; // resume 上下文里有完整版,这里只需定位 + // noResume agent 不 resume,历史注入是唯一的自我记忆来源,需保留较完整的自身回复; + // resume 场景下 SDK 已有完整版,这里偏大也只是少量冗余(受 historyMaxChars 硬顶约束) + const SELF_BOT_MSG_MAX = 1500; const OTHER_BOT_MSG_MAX = 4000; // 其他 bot 的回复需要较完整保留 const parentMsgCount = options?.parentMsgCount ?? 0; const hasStructuredSections = parentMsgCount > 0; @@ -2569,6 +2571,31 @@ export function buildBotIdentityContext(chatId: string, agentId?: string): strin return lines.join('\n'); } +/** + * 判断是否可以 resume 上一轮 SDK session。 + * + * 三种情况不能 resume(改为全新会话 + 注入最近 N 条历史): + * - 无 activeConversationId:本会话还没有可续的 SDK session + * - cwd 变更:workspace 切换后 Agent SDK 不支持跨 cwd resume(会 exit 1) + * - agent 配置 noResume:强制每条消息全新会话,避免长会话逐轮累积把上下文 + * 推到数十万 token、成本失控 + * + * 抽成纯函数便于单测。afterMsgId 增量去重也以此为准:只有真正 resume 时 + * SDK 端才存有前序 turn,此时才做"只注入新消息"的增量;否则注入完整最近 N 条。 + */ +export function canResumeSession(params: { + activeConversationId?: string; + activeConversationCwd?: string; + workingDir: string; + noResume?: boolean; +}): boolean { + const { activeConversationId, activeConversationCwd, workingDir, noResume } = params; + if (!activeConversationId) return false; + if (activeConversationCwd && activeConversationCwd !== workingDir) return false; + if (noResume) return false; + return true; +} + /** * 执行 Claude Agent SDK 任务 * 支持 workspace 变更后自动 restart:第一次 query 触发 setup_workspace 后, @@ -2647,6 +2674,14 @@ export async function executeClaudeTask( const activeConversationCwd = threadId ? threadSession?.conversationCwd : session.conversationCwd; + const agentCfg = agentRegistry.get(agentId); + // resume 开关:无会话 / cwd 变更 / agent noResume → 全新会话(注入最近 N 条历史) + const canResume = canResumeSession({ + activeConversationId, + activeConversationCwd, + workingDir, + noResume: agentCfg?.noResume, + }); // 构建历史上下文 // Pipeline context → system prompt (historySummaries),聊天历史 → user prompt 前缀 @@ -2687,7 +2722,8 @@ export async function executeClaudeTask( const selfBotOpenIds = accountManager.getAllBotOpenIds(); if (feishuClient.botOpenId) selfBotOpenIds.add(feishuClient.botOpenId); - const afterMsgId = activeConversationId ? _historyDedup.get(sessionKey) : undefined; + // 仅在真正 resume 时做增量去重;不 resume(含 noResume/cwd 变更)时注入完整最近 N 条 + const afterMsgId = canResume ? _historyDedup.get(sessionKey) : undefined; const history = await buildChatHistoryContext(chatId, threadId, messageId, afterMsgId, selfBotOpenIds); if (history.historyImagePaths?.length) { restartImagePaths.push(...history.historyImagePaths); @@ -2699,8 +2735,9 @@ export async function executeClaudeTask( _historyDedup.set(sessionKey, history.newestMsgId); } // Resume 时跳过历史文件附件:SDK 会重放所有前序 turn,文件已在对话中, - // 重复附加会导致 payload 累积膨胀(N turns × PDF size → 超 30MB 限制) - if (activeConversationId) { + // 重复附加会导致 payload 累积膨胀(N turns × PDF size → 超 30MB 限制)。 + // 不 resume(含 noResume/cwd 变更)时 SDK 端无对话记忆,须正常合并历史文件。 + if (canResume) { if (history.topicRootImages?.length || history.documents?.length) { logger.info( { topicRootImages: history.topicRootImages?.length ?? 0, historyDocs: history.documents?.length ?? 0 }, @@ -2820,21 +2857,18 @@ export async function executeClaudeTask( }; try { - // Resume 策略:activeConversationId/activeConversationCwd 已在上方提前计算 + // Resume 策略:canResume/activeConversationId/activeConversationCwd 已在上方提前计算 const activePromptHash = threadId ? threadSession?.systemPromptHash : session.systemPromptHash; - const canResume = activeConversationId - && (!activeConversationCwd || activeConversationCwd === workingDir); if (activeConversationId && !canResume) { logger.info( - { sessionKey, threadId, sessionId: activeConversationId, sessionCwd: activeConversationCwd, currentCwd: workingDir }, - 'Skipping resume: cwd mismatch (workspace switched), starting fresh session', + { sessionKey, threadId, sessionId: activeConversationId, sessionCwd: activeConversationCwd, currentCwd: workingDir, noResume: agentCfg?.noResume }, + 'Skipping resume: cwd mismatch (workspace switched) or agent noResume, starting fresh session', ); } // NOTE: 图片消息(AsyncIterable prompt)也支持 resume,SDK 的 resume 是 CLI 参数与 prompt 投递方式正交 // readOnly: agent 配置优先,如果 agent 是 readonly 则强制只读; - // 否则回退到 owner 检查(dev agent 中非 owner 也是只读) - const agentCfg = agentRegistry.get(agentId); + // 否则回退到 owner 检查(dev agent 中非 owner 也是只读);agentCfg 已在上方提前取得 const readOnly = agentCfg?.readOnly ?? !isOwner(userId); // /fable 强制模型:thread 级优先,其次 chat 级 session,都没有则用 agent 配置模型 const forcedModel = resolveForcedModel(threadSession?.forcedModel, session.forcedModel); @@ -3306,8 +3340,13 @@ export async function executeDirectTask( ? threadSession?.conversationCwd : session.conversationCwd; const activePromptHash = eventThreadId ? threadSession?.systemPromptHash : session.systemPromptHash; - const canResume = activeConversationId - && (!activeConversationCwd || activeConversationCwd === workingDir); + // resume 开关:无会话 / cwd 变更 / agent noResume → 全新会话(注入最近 N 条历史) + const canResume = canResumeSession({ + activeConversationId, + activeConversationCwd, + workingDir, + noResume: agentCfg.noResume, + }); const resumeSessionId = canResume ? activeConversationId : undefined; // 群聊/话题中标注发送者身份,避免多用户共享 session 时模型混淆对话对象 @@ -3324,9 +3363,10 @@ export async function executeDirectTask( if (feishuClient.botOpenId) selfBotOpenIds.add(feishuClient.botOpenId); let effectivePrompt = promptWithTime; - const afterMsgId = activeConversationId ? _historyDedup.get(sessionKey) : undefined; + // 仅在真正 resume 时做增量去重;不 resume(含 noResume/cwd 变更)时注入完整最近 N 条 + const afterMsgId = canResume ? _historyDedup.get(sessionKey) : undefined; logger.info( - { sessionKey, afterMsgId, hasConversationId: !!activeConversationId, currentMessageId: messageId, rootId }, + { sessionKey, afterMsgId, hasConversationId: !!activeConversationId, canResume, currentMessageId: messageId, rootId }, 'History dedup state before buildDirectTaskHistory', ); const history = await buildDirectTaskHistory(chatId, eventThreadId, messageId, afterMsgId, selfBotOpenIds); From a459089e4680700fe556ca06ca46c50360bc6f9d Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 20 Jul 2026 03:49:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test:=20canResumeSession/noResume=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=20+=20=E5=8E=86=E5=8F=B2=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=80=82=E9=85=8D=E6=96=B0=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - event-handler.test.ts: 新增 canResumeSession describe(无会话/cwd 变更/ noResume/正常 resume 各分支) - config-loader.test.ts: 新增 noResume 透传/继承/覆盖/缺省 4 例 - history-truncation.test.ts: 常量与断言同步到 SELF_BOT_MSG_MAX=1500、 TOTAL_BUDGET=16000,新增'<1500 不截断'回归例 - structured-chat-context.test.ts: 放大输入以在 16000 预算下重新触发 父群消息裁剪/省略提示 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/history-truncation.test.ts | 53 ++++++++++-------- src/__tests__/structured-chat-context.test.ts | 8 +-- src/agent/__tests__/config-loader.test.ts | 54 +++++++++++++++++++ src/feishu/__tests__/event-handler.test.ts | 45 ++++++++++++++++ 4 files changed, 134 insertions(+), 26 deletions(-) diff --git a/src/__tests__/history-truncation.test.ts b/src/__tests__/history-truncation.test.ts index 3930a402..ba63bf9b 100644 --- a/src/__tests__/history-truncation.test.ts +++ b/src/__tests__/history-truncation.test.ts @@ -4,10 +4,11 @@ * Tests for the formatHistoryMessages logic that applies different truncation * limits based on message sender type: * - User messages: 500 chars - * - Self bot messages: 150 chars (resume context has full version) + * - Self bot messages: 1500 chars (noResume agents rely on history as their only + * self-memory; resume context still has the full version) * - Other bot messages: 4000 chars (need fuller context) * - * Total budget: 8000 chars (CHAT_HISTORY_MAX_CHARS default) + * Total budget: 16000 chars (CHAT_HISTORY_MAX_CHARS default) */ // @ts-nocheck — test file import { describe, it, expect } from 'vitest'; @@ -24,9 +25,9 @@ type SimpleMessage = { msgType: string; }; -const TOTAL_BUDGET = 8000; +const TOTAL_BUDGET = 16000; const USER_MSG_MAX = 500; -const SELF_BOT_MSG_MAX = 150; +const SELF_BOT_MSG_MAX = 1500; const OTHER_BOT_MSG_MAX = 4000; function formatHistory( @@ -110,13 +111,22 @@ describe('formatHistoryMessages differentiated truncation', () => { expect(result).not.toContain('u'.repeat(501)); }); - it('truncates self bot messages at 150 chars', () => { - const msg = makeBotMsg('m1', 's'.repeat(300), SELF_BOT_ID); + it('truncates self bot messages at 1500 chars', () => { + const msg = makeBotMsg('m1', 's'.repeat(2000), SELF_BOT_ID); const result = formatHistory([msg], selfBotIds); expect(result).toContain('[Bot(self)]'); - expect(result).toContain('s'.repeat(150) + '...'); - expect(result).not.toContain('s'.repeat(151)); + expect(result).toContain('s'.repeat(1500) + '...'); + expect(result).not.toContain('s'.repeat(1501)); + }); + + it('preserves self bot messages under 1500 chars (noResume 场景需较完整自身回复)', () => { + const msg = makeBotMsg('m1', 's'.repeat(800), SELF_BOT_ID); + const result = formatHistory([msg], selfBotIds); + + // 800 < 1500 → 不截断(旧的 150 上限会把它砍掉,回归点) + expect(result).toContain('s'.repeat(800)); + expect(result).not.toContain('...'); }); it('truncates other bot messages at 4000 chars', () => { @@ -178,7 +188,7 @@ describe('formatHistoryMessages differentiated truncation', () => { }); }); - describe('total budget guard (8000 chars)', () => { + describe('total budget guard (16000 chars)', () => { it('keeps all messages when within budget', () => { const msgs = [ makeUserMsg('m1', 'hello'), @@ -193,34 +203,33 @@ describe('formatHistoryMessages differentiated truncation', () => { expect(result).not.toContain('已省略'); }); - it('drops oldest messages when total exceeds 8000 chars', () => { - // 20 user messages with ~500 chars each (after truncation) → ~10000+ chars, should drop some - const msgs = Array.from({ length: 20 }, (_, i) => + it('drops oldest messages when total exceeds 16000 chars', () => { + // 40 user messages ~500 chars each (after truncation) → ~20000+ chars, should drop some + const msgs = Array.from({ length: 40 }, (_, i) => makeUserMsg(`m${i}`, `msg-${i}-${'x'.repeat(490)}`), ); const result = formatHistory(msgs, selfBotIds); expect(result).toBeDefined(); - expect(result).toContain('msg-19-'); // most recent kept + expect(result).toContain('msg-39-'); // most recent kept expect(result).toContain('已省略'); // drop indicator }); it('self bot messages use less budget than other bot messages', () => { - // Self bot with 1000 chars → truncated to 150, leaves room for others - // Other bot with 1000 chars → kept at 1000, uses more budget - const selfBotLong = makeBotMsg('m1', 'S'.repeat(1000), SELF_BOT_ID); - const otherBotLong = makeBotMsg('m2', 'O'.repeat(1000), OTHER_BOT_ID); + // 内容同时超过两者上限:self 截到 1500,other 截到 4000 → self 行更短 + const selfBotLong = makeBotMsg('m1', 'S'.repeat(5000), SELF_BOT_ID); + const otherBotLong = makeBotMsg('m2', 'O'.repeat(5000), OTHER_BOT_ID); const userMsg = makeUserMsg('m3', 'user question'); const resultSelf = formatHistory([selfBotLong, userMsg], selfBotIds); const resultOther = formatHistory([otherBotLong, userMsg], selfBotIds); - // Self bot line should be much shorter + // Self bot line should be much shorter (1500 vs 4000) const selfLine = resultSelf!.split('\n').find(l => l.includes('[Bot(self)]'))!; const otherLine = resultOther!.split('\n').find(l => l.includes('[Bot]'))!; expect(selfLine.length).toBeLessThan(otherLine.length); - expect(selfLine.length).toBeLessThan(200); // ~150 + role prefix - expect(otherLine.length).toBeGreaterThan(900); // ~1000 + role prefix + expect(selfLine.length).toBeLessThan(1520); // ~1500 + role prefix + "..." + expect(otherLine.length).toBeGreaterThan(3900); // ~4000 + role prefix }); }); @@ -242,9 +251,9 @@ describe('formatHistoryMessages differentiated truncation', () => { expect(result).toContain('[Bot]'); expect(result).toContain('好的,那就按这个方案来'); - // Self bot should be truncated more aggressively + // Self bot should be truncated more aggressively than other bot (1500 vs 4000) const selfLine = result!.split('\n').find(l => l.includes('[Bot(self)]'))!; - expect(selfLine.length).toBeLessThanOrEqual(150 + '[Bot(self)]: '.length + 3); // +3 for "..." + expect(selfLine.length).toBeLessThanOrEqual(SELF_BOT_MSG_MAX + '[Bot(self)]: '.length + 3); // +3 for "..." }); it('returns undefined for empty messages', () => { diff --git a/src/__tests__/structured-chat-context.test.ts b/src/__tests__/structured-chat-context.test.ts index 9a470440..ed0fa1ba 100644 --- a/src/__tests__/structured-chat-context.test.ts +++ b/src/__tests__/structured-chat-context.test.ts @@ -75,12 +75,12 @@ describe('formatHistoryMessages with parentMsgCount (structured sections)', () = }); it('omits parent section when all parent messages are truncated by budget', async () => { - // 用大量话题消息填满 8000 字符预算(每条被截断至 500 字符 ≈ 530 字符/行), + // 用大量话题消息填满 16000 字符预算(每条被截断至 500 字符 ≈ 530 字符/行), // 使所有父群消息都被挤出预算 const parentMsgs = Array.from({ length: 5 }, (_, i) => makeMsg({ content: `parent msg ${i}`, createTime: '1711900000000' }), ); - const threadMsgs = Array.from({ length: 20 }, (_, i) => + const threadMsgs = Array.from({ length: 40 }, (_, i) => makeMsg({ content: `thread msg ${i} ${'z'.repeat(500)}`, createTime: '1711910000000' }), ); const messages = [...parentMsgs, ...threadMsgs]; @@ -140,8 +140,8 @@ describe('formatHistoryMessages with parentMsgCount (structured sections)', () = }); it('shows truncation notice in parent section when some parent messages dropped', async () => { - // 生成足够多的消息让部分父群消息被截断(每条约 220 字符,50 条 ≈ 11000 > 8000 预算) - const parentMsgs = Array.from({ length: 50 }, (_, i) => + // 生成足够多的消息让部分父群消息被截断(每条约 220 字符,90 条 ≈ 20000 > 16000 预算) + const parentMsgs = Array.from({ length: 90 }, (_, i) => makeMsg({ content: `parent ${i} ${'y'.repeat(200)}`, createTime: '1711900000000' }), ); const threadMsgs = [ diff --git a/src/agent/__tests__/config-loader.test.ts b/src/agent/__tests__/config-loader.test.ts index 74902c98..2acc8ec0 100644 --- a/src/agent/__tests__/config-loader.test.ts +++ b/src/agent/__tests__/config-loader.test.ts @@ -341,6 +341,60 @@ describe('editablePathPatterns', () => { }); }); +describe('noResume', () => { + beforeEach(() => { + vi.resetModules(); + mkdirSync(KNOWLEDGE_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + async function setup(configObj: Record) { + writeFileSync(CONFIG_FILE, JSON.stringify(configObj)); + vi.stubEnv('AGENT_CONFIG_PATH', CONFIG_FILE); + vi.doMock('../../config.js', () => ({ + config: { + agent: { configPath: CONFIG_FILE }, + claude: { model: 'claude-sonnet-4-6', maxBudgetUsd: 5, maxTurns: 100 }, + }, + })); + const loader = await import('../config-loader.js'); + const result = loader.loadAgentConfig(); + expect(result.loaded).toBe(true); + return loader; + } + + it('should pass agent-level noResume through to registry', async () => { + await setup({ agents: [{ id: 'pm', noResume: true }] }); + + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('pm')!.noResume).toBe(true); + }); + + it('should inherit noResume from defaults', async () => { + await setup({ defaults: { noResume: true }, agents: [{ id: 'pm' }] }); + + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('pm')!.noResume).toBe(true); + }); + + it('should let agent-level noResume override defaults', async () => { + await setup({ defaults: { noResume: true }, agents: [{ id: 'dev', noResume: false }] }); + + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('dev')!.noResume).toBe(false); + }); + + it('should be undefined when not configured (resume 默认开启)', async () => { + await setup({ agents: [{ id: 'dev' }] }); + + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('dev')!.noResume).toBeUndefined(); + }); +}); + describe('maxBudgetUsd / maxTurns fallback', () => { beforeEach(() => { vi.resetModules(); diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index aa482e8f..1473643d 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -1300,3 +1300,48 @@ describe('queue key construction for parallel execution', () => { expect(key1).toBe(key2); }); }); + +// ============================================================ +// canResumeSession — resume 开关判定(含 per-agent noResume) +// ============================================================ + +const { canResumeSession } = await import('../event-handler.js'); + +describe('canResumeSession', () => { + const WORK = '/tmp/work'; + + it('无 activeConversationId 时不能 resume(全新会话)', () => { + expect(canResumeSession({ workingDir: WORK })).toBe(false); + expect(canResumeSession({ activeConversationId: undefined, workingDir: WORK })).toBe(false); + expect(canResumeSession({ activeConversationId: '', workingDir: WORK })).toBe(false); + }); + + it('有会话且 cwd 一致时可以 resume', () => { + expect(canResumeSession({ activeConversationId: 'sess-1', workingDir: WORK })).toBe(true); + expect(canResumeSession({ + activeConversationId: 'sess-1', activeConversationCwd: WORK, workingDir: WORK, + })).toBe(true); + }); + + it('cwd 变更(workspace 切换)时不能 resume', () => { + expect(canResumeSession({ + activeConversationId: 'sess-1', activeConversationCwd: '/tmp/other', workingDir: WORK, + })).toBe(false); + }); + + it('agent noResume=true 时强制不 resume,即使会话与 cwd 都满足', () => { + expect(canResumeSession({ + activeConversationId: 'sess-1', activeConversationCwd: WORK, workingDir: WORK, noResume: true, + })).toBe(false); + }); + + it('noResume=false / 省略 时不影响正常 resume', () => { + expect(canResumeSession({ activeConversationId: 'sess-1', workingDir: WORK, noResume: false })).toBe(true); + expect(canResumeSession({ activeConversationId: 'sess-1', workingDir: WORK })).toBe(true); + }); + + it('noResume 优先级:即使无会话也返回 false(不改变最终结果)', () => { + // 无会话本就 false,noResume 叠加仍 false —— 确认不会因 noResume 逻辑抛错 + expect(canResumeSession({ workingDir: WORK, noResume: true })).toBe(false); + }); +}); From a832bb6bed8c349f8fe966947e1b0b5787c58f45 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 20 Jul 2026 04:16:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20formatHistoryM?= =?UTF-8?q?essages=20=E6=B3=A8=E9=87=8A=E9=87=8C=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 反馈:注释还写 CHAT_HISTORY_MAX_CHARS 默认 8000 / 自身 bot 150 字符, 本 PR 已改成 16000 / 1500,同步注释消除 doc-drift。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/feishu/event-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index a52e9755..93082117 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -2268,8 +2268,8 @@ export const _testClearTopicRootCache = () => topicRootImagesCache.clear(); * 格式化历史消息为上下文文本(共享逻辑)。 * * 保护策略: - * 1. 单条消息截断:用户消息 500 字符,自己的 bot 150 字符(resume 里有完整版),其他 bot 4000 字符 - * 2. 总字符数超 CHAT_HISTORY_MAX_CHARS(默认 8000)时,从最旧的消息开始丢弃 + * 1. 单条消息截断:用户消息 500 字符,自己的 bot 1500 字符(noResume 下历史是唯一自我记忆),其他 bot 4000 字符 + * 2. 总字符数超 CHAT_HISTORY_MAX_CHARS(默认 16000)时,从最旧的消息开始丢弃 * * 当前 @bot 的消息不在 history 中(调用前已过滤),rawPrompt 始终完整保留。 *