Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions config/agents.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"model": "claude-opus-4-6",
"replyMode": "direct",
"noResume": true,
"toolPolicy": {
"profile": "readonly",
"allow": ["Skill", "Bash"]
Expand Down
53 changes: 31 additions & 22 deletions src/__tests__/history-truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'),
Expand All @@ -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
});
});

Expand All @@ -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', () => {
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/structured-chat-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 = [
Expand Down
54 changes: 54 additions & 0 deletions src/agent/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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();
Expand Down
1 change: 1 addition & 0 deletions src/agent/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/agent/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 重新读取) */
Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (阿里云百炼) 通用配置
Expand Down
45 changes: 45 additions & 0 deletions src/feishu/__tests__/event-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading