diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 1305e9d..5a7de48 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -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 @@ -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 }; + // 关键: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(); + }); + }); }); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 38ec1b4..d466494 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -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 执行器 @@ -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(); @@ -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 { + 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 = 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; + 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).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, + }; + } + /** * 中断某个会话的执行 */ diff --git a/src/claude/types.ts b/src/claude/types.ts index 69c4e67..7539df2 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -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) */ diff --git a/src/feishu/__tests__/compact-reply.test.ts b/src/feishu/__tests__/compact-reply.test.ts new file mode 100644 index 0000000..2c55c1b --- /dev/null +++ b/src/feishu/__tests__/compact-reply.test.ts @@ -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('未知错误'); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 5a93ea7..f8dcce5 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -6,7 +6,7 @@ import { forkSession } from '../session/fork.js'; import { taskQueue } from '../session/queue.js'; import { claudeExecutor } from '../claude/executor.js'; import { DEFAULT_IMAGE_PROMPT, DEFAULT_DOCUMENT_PROMPT } from '../claude/types.js'; -import type { TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, ConversationTurn } from '../claude/types.js'; +import type { TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, ConversationTurn, CompactResult } from '../claude/types.js'; import { buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildCombinedProgressCard, buildAskUserQuestionCard, buildAskUserAnsweredCard } from './message-builder.js'; import type { AskUserQuestionItem } from './message-builder.js'; import { TOTAL_PHASES } from '../pipeline/types.js'; @@ -93,6 +93,28 @@ export function formatConversationTrace(trace?: ConversationTurn[]): string { return joined.length > MAX_TOTAL ? joined.slice(-MAX_TOTAL) : joined; } +/** 把 token 数格式化为紧凑形式:40445 → "40.4k",1351 → "1.4k",800 → "800" */ +export function formatTokenCount(n: number): string { + if (n < 1000) return String(n); + return `${(n / 1000).toFixed(1)}k`; +} + +/** 构造 /compact 结果的飞书回复文本 */ +export function formatCompactReply(result: CompactResult): string { + if (result.success) { + const { preTokens: pre, postTokens: post } = result; + if (pre && post && pre > 0) { + const pct = Math.round((1 - post / pre) * 100); + return `✅ 上下文已压缩:约 ${formatTokenCount(pre)} → ${formatTokenCount(post)} tokens(↓${pct}%)`; + } + return '✅ 上下文已压缩。'; + } + if (result.noop) { + return 'ℹ️ 当前上下文较短,无需压缩。'; + } + return `❌ 压缩失败:${result.error || '未知错误'}`; +} + /** * 工作区切换 restart 时:把已落盘的图片路径拼成文本提示,让 agent 用 Read 工具按需查看。 * 由于 restart query 不重传多模态 images 参数(避免重复消耗 token),需以文本路径作为 fallback。 @@ -1076,8 +1098,10 @@ async function handleSlashCommand( // /auth 已在 @mention 过滤之前处理(无需 @ 即可触发),此处不再重复 - // /reset - 重置会话(清除所有 agent 的 session + thread conversation) - if (trimmed === '/reset') { + // /reset、/clear - 重置会话(清除所有 agent 的 session + thread conversation) + // /clear 是 Claude Code 的清空命令,这里作为 /reset 的别名(本地清掉 conversationId + // 即等效于清空:下次 query 不带 resume,开启全新 session) + if (trimmed === '/reset' || trimmed === '/clear') { claudeExecutor.killSessionsForChat(chatId, userId); // 重置所有 agent 的主 session for (const aid of ['dev', 'pm'] as const) { @@ -1112,6 +1136,70 @@ async function handleSlashCommand( return true; } + // /compact - 压缩当前会话上下文 + // 直接在飞书发 /compact 无效:normal task 链路会层层加前缀(发送者标签/历史/记忆/时间), + // 把 /compact 挤出消息开头,CLI 便不再识别为 local slash command。这里拦截后以**裸** + // /compact + resume 透传给 SDK,触发真实压缩。 + if (trimmed === '/compact') { + const replyFn = async (msg: string) => { + if (threadReplyMsgId) await feishuClient.replyTextInThread(threadReplyMsgId, msg); + else await feishuClient.replyText(messageId, msg); + }; + + const session = sessionManager.getOrCreate(chatId, userId, agentId); + + // resume 目标:话题内用 thread session,否则用主 session + let conversationId: string | undefined; + let convCwd: string | undefined; + let workingDir: string; + if (effectiveThreadId) { + const ts = sessionManager.getThreadSession(effectiveThreadId, agentId); + conversationId = ts?.conversationId; + convCwd = ts?.conversationCwd; + workingDir = ts?.workingDir ?? session.workingDir; + } else { + conversationId = session.conversationId; + convCwd = session.conversationCwd; + workingDir = session.workingDir; + } + + if (!conversationId) { + await replyFn('ℹ️ 当前会话还没有可压缩的上下文(尚未开始对话或已 /reset)。'); + return true; + } + if (session.status === 'busy') { + await replyFn('⚠️ 当前有任务正在执行,请等待完成或先 /stop,再 /compact。'); + return true; + } + + // resume 要求 cwd 与建会话时一致;conversationCwd 缺失则回退到 workingDir + const { existsSync } = await import('node:fs'); + const cwd = convCwd && existsSync(convCwd) ? convCwd : workingDir; + const sessionKey = effectiveThreadId ? `${chatId}:${userId}:${effectiveThreadId}` : `${chatId}:${userId}`; + const model = agentRegistry.get(agentId)?.model; + + await replyFn('🗜️ 正在压缩对话上下文,请稍候(约需 30–60 秒)…'); + + sessionManager.setStatus(chatId, userId, 'busy', agentId); + let result: CompactResult; + try { + result = await claudeExecutor.compact({ sessionKey, workingDir: cwd, resumeSessionId: conversationId, model }); + } finally { + sessionManager.setStatus(chatId, userId, 'idle', agentId); + } + + // 压缩成功后回存 session ID(实测与原 ID 相同,但保持与 normal 流程一致) + if (result.success && result.sessionId) { + if (effectiveThreadId) { + sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); + } + sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); + } + + await replyFn(formatCompactReply(result)); + return true; + } + // /edit [repo] [task] - 原地编辑源仓库(OWNER only) if (trimmed === '/edit' || trimmed.startsWith('/edit ')) { if (!isOwner(userId)) { @@ -1401,7 +1489,8 @@ async function handleSlashCommand( '', '**── 基础命令 ──**', '`/status` — 查看当前会话状态(工作目录、队列)', - '`/reset` — 重置会话,清除对话历史', + '`/compact` — 压缩当前会话上下文,降低后续 token 成本', + '`/reset`(`/clear`)— 重置会话,清除对话历史', '`/stop` — 中断当前正在执行的任务', '`/config` — 查看当前 Agent 配置和人设 🔒', '`/help` — 显示此帮助',