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
108 changes: 107 additions & 1 deletion src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ vi.mock('../../cron/init.js', () => ({
getCronScheduler: vi.fn(() => null),
}));

import { ClaudeExecutor, buildWorkspaceSystemPrompt } from '../executor.js';
import { ClaudeExecutor, buildWorkspaceSystemPrompt, classifyCompactOutcome } from '../executor.js';

// ============================================================
// Helpers
Expand Down Expand Up @@ -599,4 +599,110 @@ describe('ClaudeExecutor', () => {
expect(prompt).toContain('建议用户稍后手动追问检查结果');
});
});

describe('compact()', () => {
it('sends a BARE "/compact" prompt with resume (no prefixes)', async () => {
setupMessages([
{ type: 'system', subtype: 'status', status: 'compacting' },
{ type: 'system', subtype: 'compact_boundary', compact_metadata: { trigger: 'manual', pre_tokens: 40445, post_tokens: 1351 } },
{ type: 'system', subtype: 'status', status: null, compact_result: 'success' },
{ type: 'system', subtype: 'init', session_id: 'sess-c' },
{ type: 'result', subtype: 'success', session_id: 'sess-c' },
]);

await executor.compact({ sessionKey: 'k', workingDir: '/tmp/w', resumeSessionId: 'sess-c' });

const arg = mockQuery.mock.calls[0]![0] as { prompt: string; options: Record<string, unknown> };
// 关键:prompt 必须是裸 /compact,否则 CLI 不识别为 local slash command
expect(arg.prompt).toBe('/compact');
expect(arg.options.resume).toBe('sess-c');
});

it('returns success with pre/post tokens on a real compaction', async () => {
setupMessages([
{ type: 'system', subtype: 'status', status: 'compacting' },
{ type: 'system', subtype: 'compact_boundary', compact_metadata: { trigger: 'manual', pre_tokens: 40445, post_tokens: 1351 } },
{ type: 'system', subtype: 'status', status: null, compact_result: 'success' },
{ type: 'system', subtype: 'init', session_id: 'sess-c' },
{ type: 'result', subtype: 'success', session_id: 'sess-c' },
]);

const r = await executor.compact({ sessionKey: 'k', workingDir: '/tmp/w', resumeSessionId: 'sess-c' });

expect(r.success).toBe(true);
expect(r.noop).toBe(false);
expect(r.preTokens).toBe(40445);
expect(r.postTokens).toBe(1351);
expect(r.sessionId).toBe('sess-c');
expect(r.error).toBeUndefined();
});

it('flags noop when context is too short to compact', async () => {
setupMessages([
{ type: 'system', subtype: 'status', status: 'compacting' },
{ type: 'system', subtype: 'status', status: null, compact_result: 'failed', compact_error: 'Not enough messages to compact.' },
{ type: 'system', subtype: 'init', session_id: 'sess-c' },
{ type: 'result', subtype: 'success', session_id: 'sess-c' },
]);

const r = await executor.compact({ sessionKey: 'k', workingDir: '/tmp/w', resumeSessionId: 'sess-c' });

expect(r.success).toBe(false);
expect(r.noop).toBe(true);
expect(r.error).toBeUndefined();
});

it('returns failure with error on a real compaction error', async () => {
setupMessages([
{ type: 'system', subtype: 'status', status: 'compacting' },
{ type: 'system', subtype: 'status', status: null, compact_result: 'failed', compact_error: 'API overloaded' },
{ type: 'result', subtype: 'success', session_id: 'sess-c' },
]);

const r = await executor.compact({ sessionKey: 'k', workingDir: '/tmp/w', resumeSessionId: 'sess-c' });

expect(r.success).toBe(false);
expect(r.noop).toBe(false);
expect(r.error).toBe('API overloaded');
});

it('treats a missing compact signal as failure (not silent success)', async () => {
// 没有任何 compact_boundary / compact_result —— 说明 /compact 未被识别为命令
setupMessages([
{ type: 'system', subtype: 'init', session_id: 'sess-c' },
{ type: 'result', subtype: 'success', session_id: 'sess-c', result: '/compact' },
]);

const r = await executor.compact({ sessionKey: 'k', workingDir: '/tmp/w', resumeSessionId: 'sess-c' });

expect(r.success).toBe(false);
expect(r.noop).toBe(false);
expect(r.error).toBeTruthy();
});
});

describe('classifyCompactOutcome()', () => {
it('success when compact_result is success', () => {
expect(classifyCompactOutcome({ compactResult: 'success' })).toEqual({ success: true, noop: false });
});

it('noop when failed with "not enough messages"', () => {
expect(classifyCompactOutcome({ compactResult: 'failed', compactError: 'Not enough messages to compact.' }))
.toEqual({ success: false, noop: true });
});

it('failure with error for other failed reasons', () => {
const r = classifyCompactOutcome({ compactResult: 'failed', compactError: 'boom' });
expect(r.success).toBe(false);
expect(r.noop).toBe(false);
expect(r.error).toBe('boom');
});

it('failure when no signal at all', () => {
const r = classifyCompactOutcome({});
expect(r.success).toBe(false);
expect(r.noop).toBe(false);
expect(r.error).toBeTruthy();
});
});
});
146 changes: 145 additions & 1 deletion src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { createWebSearchMcpServer } from '../websearch/tool.js';
import { feishuClientContext } from '../feishu/client.js';
import { isAutoWorkspacePath, isServiceOwnRepo, isInsideSourceRepo } from '../workspace/isolation.js';
import { detectRuntime } from '../utils/runtime.js';
import type { ClaudeResult, ExecuteOptions, ProgressCallback, TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, MultimodalContentBlock, ConversationTurn, ToolCallTrace } from './types.js';
import type { ClaudeResult, CompactResult, ExecuteOptions, ProgressCallback, TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, MultimodalContentBlock, ConversationTurn, ToolCallTrace } from './types.js';

// ============================================================
// Claude Agent SDK 执行器
Expand Down Expand Up @@ -581,6 +581,33 @@ async function* buildMultimodalPrompt(
/** 仅测试用:导出 buildMultimodalPrompt */
export const _testBuildMultimodalPrompt = buildMultimodalPrompt;

/**
* 根据 /compact 流式消息中捕获的信号判定压缩结果。
*
* - compact_result === 'success' → 真正完成压缩
* - compact_result === 'failed' + "Not enough messages to compact" → noop(上下文过短,非错误)
* - 其余 failed / 无任何信号 → 视为失败
*
* 拆成纯函数便于单元测试(compact() 本身依赖 SDK query())。
*/
export function classifyCompactOutcome(signals: {
compactResult?: 'success' | 'failed';
compactError?: string;
}): { success: boolean; noop: boolean; error?: string } {
if (signals.compactResult === 'success') {
return { success: true, noop: false };
}
const isNotEnough = /not enough messages/i.test(signals.compactError ?? '');
if (signals.compactResult === 'failed' && isNotEnough) {
return { success: false, noop: true };
}
return {
success: false,
noop: false,
error: signals.compactError || '压缩未生效(未收到 compact 完成信号)',
};
}

export class ClaudeExecutor {
/** 运行中的 query 实例 (用于 abort) */
private runningQueries = new Map<string, Query>();
Expand Down Expand Up @@ -1302,6 +1329,123 @@ export class ClaudeExecutor {
};
}

/**
* 对指定会话执行 /compact 上下文压缩。
*
* 关键:prompt 必须是**裸** "/compact"(不拼接任何 memory/历史/发送者/时间前缀),
* 否则 Claude Code CLI 不会把它识别为 local slash command —— 命令必须位于消息开头。
* 这正是飞书侧直接发 /compact 无效的根因:normal task 链路会层层加前缀。
*
* 已实测(SDK 0.3.156):bare "/compact" + resume 触发真实压缩,
* session_id 保持不变,compact_boundary 给出 pre/post tokens。
* 消息序列:status(compacting) → status(compact_result[+error]) → init → assistant → result。
*/
async compact(input: {
sessionKey: string;
workingDir: string;
resumeSessionId: string;
model?: string;
timeoutSeconds?: number;
}): Promise<CompactResult> {
const { sessionKey, workingDir, resumeSessionId, model } = input;
const startTime = Date.now();
const abortController = new AbortController();
const idleTimeoutMs = (input.timeoutSeconds ?? config.claude.timeoutSeconds) * 1000;
let timedOut = false;

// 滑动窗口 idle 超时:每收到一条消息就重置。压缩本身是一次 API 调用,
// status(compacting) 与 status(success) 之间可能间隔数十秒(实测 40K tokens 约 35s),
// 默认 idle 超时(300s)足够。
let idleTimer: ReturnType<typeof setTimeout> = undefined!;
const resetIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
timedOut = true;
abortController.abort();
logger.warn({ sessionKey, idleTimeoutMs }, 'Compact query idle timeout — aborting');
}, idleTimeoutMs);
};
resetIdleTimer();

logger.info({ sessionKey, workingDir, resumeSessionId }, 'Executing /compact');

const q = query({
prompt: '/compact',
options: {
cwd: workingDir,
abortController,
stderr: (data: string) => logger.warn({ stderr: data.trim() }, 'Claude Code stderr (compact)'),
// 同 execute():SDK 0.3.x 传入 env 会完全替换子进程环境,需展开 process.env
...(config.claude.apiBaseUrl
? { env: { ...process.env, ANTHROPIC_BASE_URL: config.claude.apiBaseUrl } }
: {}),
permissionMode: 'acceptEdits' as const,
model: model ?? config.claude.model,
resume: resumeSessionId,
},
});

this.runningQueries.set(sessionKey, q);

let sessionId: string | undefined;
let preTokens: number | undefined;
let postTokens: number | undefined;
let compactResult: 'success' | 'failed' | undefined;
let compactError: string | undefined;

try {
for await (const message of q) {
resetIdleTimer();
if (message.type === 'system') {
const m = message as Record<string, unknown>;
if (m.subtype === 'init') {
sessionId = (m.session_id as string) ?? sessionId;
} else if (m.subtype === 'compact_boundary') {
const meta = m.compact_metadata as { pre_tokens?: number; post_tokens?: number } | undefined;
preTokens = meta?.pre_tokens;
postTokens = meta?.post_tokens;
} else if (m.subtype === 'status') {
if (m.compact_result) compactResult = m.compact_result as 'success' | 'failed';
if (m.compact_error) compactError = m.compact_error as string;
}
} else if (message.type === 'result') {
sessionId = ((message as Record<string, unknown>).session_id as string) ?? sessionId;
break;
}
}
q.close();
} catch (err) {
clearTimeout(idleTimer);
this.runningQueries.delete(sessionKey);
const durationMs = Date.now() - startTime;
const errorMsg = timedOut
? `压缩超时(${idleTimeoutMs / 1000}s 无响应)`
: (err instanceof Error ? err.message : String(err));
logger.error({ sessionKey, err: errorMsg, timedOut }, 'Compact query error');
return { success: false, error: errorMsg, sessionId, durationMs };
}

clearTimeout(idleTimer);
this.runningQueries.delete(sessionKey);
const durationMs = Date.now() - startTime;

const outcome = classifyCompactOutcome({ compactResult, compactError });
logger.info(
{ sessionKey, ...outcome, preTokens, postTokens, durationMs },
'Compact query result',
);

return {
success: outcome.success,
noop: outcome.noop,
preTokens,
postTokens,
sessionId,
error: outcome.error,
durationMs,
};
}

/**
* 中断某个会话的执行
*/
Expand Down
23 changes: 23 additions & 0 deletions src/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ export interface ClaudeResult {
conversationTrace?: ConversationTurn[];
}

/**
* /compact 上下文压缩结果。
*
* 由 ClaudeExecutor.compact() 返回:以裸 "/compact" 透传给 SDK 触发 local slash
* command,捕获 compact_boundary(pre/post tokens)与 status(compact_result)。
*/
export interface CompactResult {
/** 是否真正完成压缩(compact_result === 'success') */
success: boolean;
/** 上下文消息过少、无需压缩("Not enough messages to compact",非真实错误) */
noop?: boolean;
/** 压缩前 token 数(来自 compact_boundary.pre_tokens) */
preTokens?: number;
/** 压缩后 token 数(来自 compact_boundary.post_tokens) */
postTokens?: number;
/** 压缩后用于下次 resume 的会话 ID(实测与传入的相同,仍回存以保持一致) */
sessionId?: string;
/** 失败原因(compact_error 或异常/超时信息) */
error?: string;
/** 执行耗时 (ms) */
durationMs: number;
}

/** executor.execute() 的可选参数 */
export interface ExecuteOptions {
/** 覆盖默认 maxTurns (默认 50) */
Expand Down
60 changes: 60 additions & 0 deletions src/feishu/__tests__/compact-reply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* /compact reply formatting tests
*
* formatCompactReply / formatTokenCount turn a CompactResult into the Feishu
* text reply. Pure functions — imported directly with no mocks (same pattern
* as document-dedup.test.ts).
*/
import { describe, it, expect } from 'vitest';
import { formatCompactReply, formatTokenCount } from '../event-handler.js';
import type { CompactResult } from '../../claude/types.js';

describe('formatTokenCount', () => {
it('keeps small numbers as-is', () => {
expect(formatTokenCount(0)).toBe('0');
expect(formatTokenCount(800)).toBe('800');
expect(formatTokenCount(999)).toBe('999');
});

it('renders thousands compactly', () => {
expect(formatTokenCount(1351)).toBe('1.4k');
expect(formatTokenCount(40445)).toBe('40.4k');
expect(formatTokenCount(776142)).toBe('776.1k');
});
});

describe('formatCompactReply', () => {
const base: CompactResult = { success: false, durationMs: 0 };

it('shows token reduction on success', () => {
const msg = formatCompactReply({ ...base, success: true, preTokens: 40445, postTokens: 1351 });
expect(msg).toContain('✅');
expect(msg).toContain('40.4k');
expect(msg).toContain('1.4k');
expect(msg).toContain('↓97%'); // 1 - 1351/40445 ≈ 0.967
});

it('falls back to a plain success message when tokens are missing', () => {
const msg = formatCompactReply({ ...base, success: true });
expect(msg).toBe('✅ 上下文已压缩。');
});

it('reports noop gently (not an error)', () => {
const msg = formatCompactReply({ ...base, noop: true });
expect(msg).toContain('ℹ️');
expect(msg).toContain('无需压缩');
expect(msg).not.toContain('❌');
});

it('reports real failure with the error text', () => {
const msg = formatCompactReply({ ...base, error: 'API overloaded' });
expect(msg).toContain('❌');
expect(msg).toContain('API overloaded');
});

it('handles failure with no error string', () => {
const msg = formatCompactReply({ ...base });
expect(msg).toContain('❌');
expect(msg).toContain('未知错误');
});
});
Loading
Loading