diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index b05545802..af3982348 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1033,7 +1033,29 @@ function buildCodexAppTurnInput(opts: { }; } -export function buildNewTopicPrompt( +/** opening 构建选项。在原有 larkAppId/chatId/whiteboardId 等之外,新增 hook 模式 + * (#794 后续)所需的 turnId 与 sessionBackendType:turnId 是 opening 轮的权威 + * turnId(= 发给 worker 的 turnId,最终成为 managedTurnOrigin.turnId),用于 + * sidecar 绑定;sessionBackendType 取会话冻结的后端类型(远端后端无本地 hook 进程)。 */ +type NewTopicOpts = { + larkAppId?: string; + chatId?: string; + whiteboardId?: string; + substituteTrigger?: SubstituteTrigger; + chatContext?: ChatContext; + turnId?: string; + sessionBackendType?: BackendType; +}; + +type NewTopicBlockKey = 'routing' | 'skill' | 'identity' | 'sessionId' | 'role' + | 'summaryMemory' | 'whiteboard' | 'chatContextPolicy' | 'chatContext' + | 'userMessage' | 'sender' | 'substitute' | 'senderNote' | 'attachments' + | 'mentions' | 'availableBots'; + +/** opening 的 hook 模式(#794 后续):whiteboard/sender/mentions 搬进 hook envelope, + * PTY 文本只剩用户正文(+ role/summaryMemory 等稳定上下文)。与 follow-up 同一套 + * sidecar/claim 机制。inline 模式(hookMode=false)输出与历史完全一致。 */ +function buildNewTopicBlocks( userMessage: string, sessionId: string, cliId: CliId, @@ -1045,8 +1067,9 @@ export function buildNewTopicPrompt( botIdentity?: { name?: string; openId?: string }, locale?: Locale, sender?: ResolvedSender, - opts?: { larkAppId?: string; chatId?: string; whiteboardId?: string; substituteTrigger?: SubstituteTrigger; chatContext?: ChatContext }, -): string { + opts?: NewTopicOpts, + hookMode = false, +): Array<{ key: NewTopicBlockKey; text: string }> { const adapter = createCliAdapterSync(cliId, cliPathOverride); // Non-Claude CLIs receive the botmux routing hints inline via the prompt // (Claude Code builds its own via --append-system-prompt). Source hints @@ -1108,8 +1131,11 @@ export function buildNewTopicPrompt( const mergedMessage = followUps && followUps.length > 0 ? [userMessage, ...followUps].join('\n\n') : userMessage; - const userBlock = `\n${mergedMessage}\n`; - const parts: string[] = []; + // hook 模式(#794 后续):PTY 文本只保留用户正文,不再包 外壳。 + // 理由同 follow-up:会话发现主防线是 collectBotmuxSessionIdentities 按文件名排除, + // 标题提取有 ?? rawContent 兜底。inline 模式保持原样。 + const userBlock = hookMode ? mergedMessage : `\n${mergedMessage}\n`; + const blocks: Array<{ key: NewTopicBlockKey; text: string }> = []; // Put stable, instruction-like context before the user's first turn. This // improves salience without moving per-turn attribution (sender/mentions) @@ -1118,40 +1144,60 @@ export function buildNewTopicPrompt( // message — same position as in follow-ups — not after it, where it could be // misread as part of the user's text. if (!adapter.injectsSessionContext) { - if (routingBlock) parts.push(routingBlock); - if (skillBlock) parts.push(skillBlock); - if (identityBlock) parts.push(identityBlock); - parts.push(`${xmlEscape(sessionId)}`); + if (routingBlock) blocks.push({ key: 'routing', text: routingBlock }); + if (skillBlock) blocks.push({ key: 'skill', text: skillBlock }); + if (identityBlock) blocks.push({ key: 'identity', text: identityBlock }); + blocks.push({ key: 'sessionId', text: `${xmlEscape(sessionId)}` }); } - if (roleBlock) parts.push(roleBlock); - if (summaryMemoryBlock) parts.push(summaryMemoryBlock); - if (whiteboardBlock) parts.push(whiteboardBlock); - if (chatContextPolicyBlock) parts.push(chatContextPolicyBlock); - if (chatContextBlock) parts.push(chatContextBlock); + if (roleBlock) blocks.push({ key: 'role', text: roleBlock }); + if (summaryMemoryBlock) blocks.push({ key: 'summaryMemory', text: summaryMemoryBlock }); + if (whiteboardBlock) blocks.push({ key: 'whiteboard', text: whiteboardBlock }); + if (chatContextPolicyBlock) blocks.push({ key: 'chatContextPolicy', text: chatContextPolicyBlock }); + if (chatContextBlock) blocks.push({ key: 'chatContext', text: chatContextBlock }); - parts.push(userBlock); + blocks.push({ key: 'userMessage', text: userBlock }); const senderBlock = renderSenderTag(sender); - if (senderBlock) parts.push(senderBlock); + if (senderBlock) blocks.push({ key: 'sender', text: senderBlock }); const substituteBlock = renderSubstituteTrigger(opts?.substituteTrigger); - if (substituteBlock) parts.push(substituteBlock); + if (substituteBlock) blocks.push({ key: 'substitute', text: substituteBlock }); const senderNote = renderCursorSenderNote(cliId, !!senderBlock, locale); - if (senderNote) parts.push(senderNote); + if (senderNote) blocks.push({ key: 'senderNote', text: senderNote }); const attachHint = formatAttachmentsHint(attachments, locale); - if (attachHint) parts.push(attachHint); + if (attachHint) blocks.push({ key: 'attachments', text: attachHint }); // CLIs with injectsSessionContext (Claude Code) get Lark routing/identity // and session ID via system prompt, so skip those blocks here. - if (mentionBlock) parts.push(mentionBlock); - if (botBlock) parts.push(botBlock); + if (mentionBlock) blocks.push({ key: 'mentions', text: mentionBlock }); + if (botBlock) blocks.push({ key: 'availableBots', text: botBlock }); // The per-session skill catalog block is appended later in the worker-pool // fork path (prepareSessionSkillPrompt), which also writes the manifest and // resolves delivery — keeping a single injection site avoids double-rendering. - return parts.join('\n\n'); + return blocks; +} + +export function buildNewTopicPrompt( + userMessage: string, + sessionId: string, + cliId: CliId, + cliPathOverride?: string, + attachments?: LarkAttachment[], + mentions?: LarkMention[], + availableBots?: Array<{ name: string; displayName: string; openId: string }>, + followUps?: string[], + botIdentity?: { name?: string; openId?: string }, + locale?: Locale, + sender?: ResolvedSender, + opts?: NewTopicOpts, +): string { + return buildNewTopicBlocks( + userMessage, sessionId, cliId, cliPathOverride, attachments, mentions, + availableBots, followUps, botIdentity, locale, sender, opts, + ).map((b) => b.text).join('\n\n'); } /** Build the legacy opening prompt plus a Codex App structured sidecar. The @@ -1182,8 +1228,43 @@ export function buildNewTopicCliInput( codexAppFollowUps?: string[]; codexAppFollowUpContexts?: string[]; chatContext?: ChatContext; + /** opening 轮的权威 turnId(= 发给 worker 的 turnId,最终成为 + * managedTurnOrigin.turnId)。hook 模式下用于 sidecar 绑定;缺失回退 inline。 */ + turnId?: string; + /** 会话冻结的后端类型,用于 hook 模式判定(远端后端无本地 hook 进程)。 */ + sessionBackendType?: BackendType; }, ): CliTurnPayload { + // hook 注入模式(#794 后续):opening 也走 sidecar——whiteboard/sender/mentions + // 写入 per-turn sidecar,PTY 文本只剩用户正文(+ role/summaryMemory 等稳定上下文)。 + // 与 follow-up 同一套 sidecar/claim 机制;turnId 是 claim 的权威键,缺失或条件 + // 不满足时回退 inline(legacy 路径),行为与历史完全一致。 + const hookTurnId = opts?.turnId; + if (resolveEnvelopeInjectionMode({ + cliId, + cliPathOverride, + sessionBackendType: opts?.sessionBackendType, + larkAppId: opts?.larkAppId, + }) === 'hook' && hookTurnId) { + const blocks = buildNewTopicBlocks( + userMessage, sessionId, cliId, cliPathOverride, attachments, mentions, + availableBots, followUps, botIdentity, locale, sender, opts, true, + ); + const ENVELOPE_KEYS = new Set(['whiteboard', 'sender', 'mentions']); + const ptyText = blocks + .filter((b) => !ENVELOPE_KEYS.has(b.key)) + .map((b) => b.text) + .join('\n\n'); + const hookEnvelope = blocks + .filter((b) => ENVELOPE_KEYS.has(b.key)) + .map((b) => b.text) + .join('\n\n'); + if (hookEnvelope && hookEnvelope.length <= HOOK_ENVELOPE_MAX_CHARS) { + writePromptContext(sessionId, hookTurnId, ptyText, hookEnvelope); + return { content: ptyText }; + } + // 无 envelope(无 whiteboard/sender/mentions)或超限 → 回退 inline。 + } const content = buildNewTopicPrompt( userMessage, sessionId, cliId, cliPathOverride, attachments, mentions, availableBots, followUps, botIdentity, locale, sender, opts, @@ -1300,7 +1381,12 @@ function buildFollowUpBlocks( } if (whiteboardBlock) blocks.push({ key: 'whiteboard', text: whiteboardBlock }); - blocks.push({ key: 'userMessage', text: `\n${content}\n` }); + // hook 模式(#794 后续):PTY 文本只保留用户正文,不再包 外壳。 + // 外壳的唯一作用是给 transcript 消费方(会话发现 / 标题提取)做结构标记, + // 但会话发现的主防线是 collectBotmuxSessionIdentities 的按文件名排除(不依赖 + // transcript 内容),标题提取有 ?? rawContent 兜底,所以 hook 模式下可以去掉。 + // inline 模式保持原样(其它 CLI 与旧会话发现正则仍依赖外壳)。 + blocks.push({ key: 'userMessage', text: hookMode ? content : `\n${content}\n` }); const senderBlock = renderSenderTag(opts?.sender); if (senderBlock) blocks.push({ key: 'sender', text: senderBlock }); @@ -1345,9 +1431,19 @@ const HOOK_ENVELOPE_MAX_CHARS = 8000; * (read-isolation 下是 per-bot BOT_HOME 那份,不是全局); * 4. (在 buildFollowUpCliInput 里)envelope 不超 8k。 * 任一不满足 → inline(现状字节不变)。 + * + * opening(buildNewTopicCliInput)复用同一判定:cfg 字段是 FollowUpOpts / + * NewTopicOpts 的结构化子集,两边都满足。 */ -function resolveEnvelopeInjectionMode(opts?: FollowUpOpts): 'hook' | 'inline' { - if (!opts?.cliId) return 'inline'; +type EnvelopeInjectionCfg = { + cliId?: CliId; + cliPathOverride?: string; + sessionBackendType?: BackendType; + larkAppId?: string; +}; + +function resolveEnvelopeInjectionMode(cfg?: EnvelopeInjectionCfg): 'hook' | 'inline' { + if (!cfg?.cliId) return 'inline'; // 远端后端(riff 等)没有本地 Claude hook 进程,sidecar 写了没人读, // 必须用会话冻结的 backendType(不是当前 bot 配置,那是 next-session 生效)。 // 只有确知在本地跑 CLI 的后端才允许 hook 模式(白名单)。未来新增远端后端 @@ -1356,19 +1452,19 @@ function resolveEnvelopeInjectionMode(opts?: FollowUpOpts): 'hook' | 'inline' { // fail-closed(review B3):sessionBackendType 缺失(null/undefined)时不再短路 // 放过,强制 inline。现网 spawn 的 reconcileRiffBackendType 已把 claude-code 钉在 // 本地后端,此条是防未来远端后端的硬化;所有调用点都传 ds.session.backendType。 - if (!opts.sessionBackendType || !LOCAL_BACKENDS.has(opts.sessionBackendType)) return 'inline'; + if (!cfg.sessionBackendType || !LOCAL_BACKENDS.has(cfg.sessionBackendType)) return 'inline'; let adapter: CliAdapter; try { - adapter = createCliAdapterSync(opts.cliId, opts.cliPathOverride); + adapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride); } catch { return 'inline'; } if (!adapter.supportsInvisiblePromptHook || !adapter.hookInstall?.userPromptSubmitCommand) return 'inline'; - if (!opts.larkAppId) return 'inline'; + if (!cfg.larkAppId) return 'inline'; let botConfig: BotConfig; try { - botConfig = getBot(opts.larkAppId).config; + botConfig = getBot(cfg.larkAppId).config; } catch { return 'inline'; } if (botConfig.envelopeInjection !== 'auto') return 'inline'; - const effectivePath = effectivePromptHookConfigPath(adapter, botConfig, opts.larkAppId, opts.sessionBackendType); + const effectivePath = effectivePromptHookConfigPath(adapter, botConfig, cfg.larkAppId, cfg.sessionBackendType); if (!effectivePath || !hasInstalledPromptHookCached(effectivePath)) return 'inline'; return 'hook'; } @@ -1420,15 +1516,21 @@ export function buildFollowUpCliInput( // 其余块。超限或无条件时回退 inline(legacy 路径),行为与历史完全一致。 // turnId 是 claim 的权威键:缺失时无法做 turn 绑定,回退 inline(避免 reminder 被 // 剥离却无 sidecar 可领)。 + // + // #794 后续:sender/mentions 也搬进 hook envelope,PTY 文本只剩用户正文(+ role/ + // summaryMemory 等稳定上下文)。模型经 system-reminder 仍看得到发送者与提及, + // 输入框不再出现 // 标签。代价:hook 注入内容不落 + // transcript,insight 发送者归因对 hook 模式会话降级为「未知用户」(用户已确认接受)。 const hookTurnId = opts?.turnId; if (resolveEnvelopeInjectionMode(opts) === 'hook' && hookTurnId) { const blocks = buildFollowUpBlocks(content, sessionId, opts, true); + const ENVELOPE_KEYS = new Set(['reminder', 'whiteboard', 'sender', 'mentions']); const ptyText = blocks - .filter((b) => b.key !== 'reminder' && b.key !== 'whiteboard') + .filter((b) => !ENVELOPE_KEYS.has(b.key)) .map((b) => b.text) .join('\n\n'); const hookEnvelope = blocks - .filter((b) => b.key === 'reminder' || b.key === 'whiteboard') + .filter((b) => ENVELOPE_KEYS.has(b.key)) .map((b) => b.text) .join('\n\n'); if (hookEnvelope && hookEnvelope.length <= HOOK_ENVELOPE_MAX_CHARS) { diff --git a/src/daemon.ts b/src/daemon.ts index 00e3a48c9..af2a3a870 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -16707,6 +16707,15 @@ function buildReservedInitialInput( // master: thread the joined-chat context into the opening prompt (group-join // auto-start passes this via pendingChatContext). chatContext: ds.pendingChatContext, + // #794 后续:opening 也走 hook 注入(sender/mentions 进 envelope,PTY 文本 + // 只剩正文)。turnId 必须与 forkReservedInitialSession 传给 forkWorker 的 + // 权威 turnId 一致(= 最终 managedTurnOrigin.turnId),sidecar 才能被 claim。 + // raw 命令冷启动(pendingRawInput)的 buffered follow-up 走 raw_input IPC + // 延迟发送,turnId 权威流不同,暂不启用 hook,保持 inline。 + turnId: ds.pendingRawInput + ? undefined + : (ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId), + sessionBackendType: ds.session.backendType, }, ); // R5-B1-1: COPY the frozen new-topic steer authorization onto the opening @@ -20231,6 +20240,10 @@ async function handleThreadReplyAdmitted( codexAppText: parsed.content, codexAppApplicationContext, codexAppMessageContext, + // #794 后续:empty-start 首轮 opening 也走 hook 注入。turnId 与下方 + // sendWorkerInput 的权威 turnId(parsed.messageId)一致,sidecar 可被 claim。 + turnId: parsed.messageId, + sessionBackendType: ds.session.backendType, }, ) : buildFollowUpCliInput(promptContent, ds.session.sessionId, { @@ -20491,6 +20504,11 @@ async function handleThreadReplyAdmitted( codexAppText: reforkCodexApp.text, codexAppApplicationContext, codexAppMessageContext: reforkCodexApp.messageContext, + // #794 后续:worker-null refork 的 empty-start 首轮 opening 也走 hook 注入。 + // turnId 与下方 forkWorker 的权威 turnId 一致(非 queued 时 = parsed.messageId); + // queued dashboard 场景的 turnId 是合成的,权威流不同,保持 inline。 + turnId: queuedHasDurableTail ? undefined : parsed.messageId, + sessionBackendType: ds.session.backendType, }, ); } else { diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index baea5d8f5..93f6db175 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -561,6 +561,13 @@ export async function commitRepoSelection( codexAppFollowUps: ds.pendingCodexAppFollowUps, codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts, chatContext: ds.pendingChatContext, + // #794 后续:opening 走 hook 注入(sender/mentions 进 envelope,PTY 文本 + // 只剩正文)。turnId 与下方 forkWorker 的权威 turnId 一致;raw 命令冷启动 + // 的 buffered follow-up 走 raw_input IPC 延迟发送,turnId 权威流不同,不启用。 + turnId: pendingRawInput + ? undefined + : (ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId), + sessionBackendType: ds.session.backendType, }, ) : undefined; diff --git a/test/initial-user-turn-opening.test.ts b/test/initial-user-turn-opening.test.ts index 7fabd3f1f..ec5f8b4b5 100644 --- a/test/initial-user-turn-opening.test.ts +++ b/test/initial-user-turn-opening.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { claimPromptContext, fingerprintPromptText, prefixOf } from '../src/services/prompt-context-store.js'; const mocks = vi.hoisted(() => { process.env.SESSION_DATA_DIR = `${process.env.TMPDIR ?? '/tmp'}/botmux-initial-turn-${process.pid}`; @@ -499,11 +500,14 @@ describe('empty-started session — first real business turn must use the new-to expect(ds.session.initialUserTurnPending).toBeUndefined(); }); - it('worker-null refork + auto hook: opening 轮不写 speculative sidecar(review 三审 HIGH-3)', async () => { + it('worker-null refork + auto hook: opening 轮写合法 sidecar(非 speculative follow-up reminder)', async () => { // 三审发现:opening 分支曾无条件先跑 buildReforkCliInput(有写 sidecar 的副作用), // 结果被 buildNewTopicCliInput 覆盖丢弃,但 sidecar 已写入 opening 的 turnId, // opening 的 hook 会领到这份没发出去的 speculative reminder → 双注入。 - // 修复后 opening 分支直接用 buildNewTopicCliInput(不写 sidecar)。 + // 修复后 opening 分支直接用 buildNewTopicCliInput。#794 后续后 opening 也走 hook + // 注入:写的是**合法** opening sidecar(whiteboard/sender/mentions,无 follow-up + // reminder),opening 内容只剩正文。本测试锁住:sidecar 是 opening envelope(claim + // 回的内容不含 ),不是 speculative follow-up reminder。 const anchor = 'om_hook_opening_root'; const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true, cliId: 'claude-code' }); ds.session.backendType = 'pty'; @@ -526,13 +530,17 @@ describe('empty-started session — first real business turn must use the new-to makeCtx(anchor, 'om_hook_first'), ); - // opening 用 new-topic 构造,不应有 sidecar 写入 - const sidecarDir = join(process.env.SESSION_DATA_DIR!, 'prompt-ctx', ds.session.sessionId); - expect(existsSync(sidecarDir)).toBe(false); - // opening 内容应包含 user_message(new-topic 开场);claude 系列不内联 routing 块 + // opening 走 hook 模式:PTY 文本只剩正文,无 外壳 / reminder const opening = forkInputs()[0]!.content; - expect(opening).toContain(''); + expect(opening).toBe('第一条消息'); + expect(opening).not.toContain(''); expect(opening).not.toContain(''); + // sidecar 是合法 opening envelope:claim 回的内容含 sender,不含 follow-up reminder + // (若写的是 speculative buildReforkCliInput sidecar,envelope 会含 ) + const envelope = claimPromptContext(ds.session.sessionId, 'om_hook_first', fingerprintPromptText(opening), prefixOf(opening)); + expect(envelope).toBeDefined(); + expect(envelope).toContain(''); }); it('worker-null refork keeps --resume when a non-IM path already fed the CLI', async () => { diff --git a/test/prompt-hook-injection.test.ts b/test/prompt-hook-injection.test.ts index d8a948c57..2ab818a1c 100644 --- a/test/prompt-hook-injection.test.ts +++ b/test/prompt-hook-injection.test.ts @@ -91,7 +91,7 @@ vi.mock('../src/adapters/hook-installer.js', () => ({ // ─── 被测模块 ────────────────────────────────────────────────────────────── -import { buildFollowUpCliInput } from '../src/core/session-manager.js'; +import { buildFollowUpCliInput, buildNewTopicCliInput } from '../src/core/session-manager.js'; import { claimPromptContext, fingerprintPromptText, prefixOf } from '../src/services/prompt-context-store.js'; const SESSION_ID = 'hook-session-789'; @@ -133,21 +133,24 @@ describe('buildFollowUpCliInput — hook 注入模式', () => { else process.env.SESSION_DATA_DIR = prevDataDir; }); - it('auto + claude-code + preflight 通过:reminder/whiteboard 进 sidecar,PTY 文本只留其余块', () => { + it('auto + claude-code + preflight 通过:reminder/whiteboard/sender/mentions 进 sidecar,PTY 文本只剩正文', () => { const result = buildFollowUpCliInput('帮我修个 bug', SESSION_ID, followUpOpts({ whiteboardId: 'wb_1' })); - // PTY 文本:有 user_message / sender / mentions,无 reminder / whiteboard - expect(result.content).toContain('\n帮我修个 bug\n'); - expect(result.content).toContain(''); + // PTY 文本:只剩用户正文,无 user_message 外壳 / sender / mentions / reminder / whiteboard + expect(result.content).toBe('帮我修个 bug'); + expect(result.content).not.toContain(''); + expect(result.content).not.toContain(''); expect(result.content).not.toContain(''); expect(result.content).not.toContain(''); expect(envelope).toContain(''); // hook 模式用描述式文案(命令式原文只出现在 inline 路径) expect(envelope).toContain('本会话通过 botmux 桥接飞书'); expect(envelope).not.toContain('至少 botmux send 回应一次'); @@ -260,3 +263,133 @@ describe('buildFollowUpCliInput — hook 注入模式', () => { expect(claimByPrompt(SESSION_ID, TURN_ID, result.content)).toBeUndefined(); }); }); + +describe('buildNewTopicCliInput — hook 注入模式(opening)', () => { + let prevDataDir: string | undefined; + beforeEach(() => { + prevDataDir = process.env.SESSION_DATA_DIR; + process.env.SESSION_DATA_DIR = '/tmp/test-sessions'; + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', envelopeInjection: 'auto' as const }, + }); + preflightMock.mockReturnValue(true); + }); + afterEach(() => { + if (prevDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = prevDataDir; + }); + + const openingOpts = (overrides: Record = {}) => ({ + larkAppId: LARK_APP_ID, + whiteboardId: 'wb_1', + turnId: TURN_ID, + sessionBackendType: 'pty' as const, + ...overrides, + }); + + it('auto + claude-code + preflight 通过 + turnId:whiteboard/sender/mentions 进 sidecar,PTY 文本只剩正文', () => { + const result = buildNewTopicCliInput( + '帮我修个 bug', SESSION_ID, 'claude-code', undefined, + undefined, + [{ name: 'Bob', openId: 'ou_bob' }], + undefined, undefined, + { name: 'Bot', openId: 'ou_bot' }, + undefined, + { openId: 'ou_sender', type: 'user' as const, name: 'Sender' }, + openingOpts(), + ); + + // PTY 文本:只剩用户正文,无 user_message 外壳 / sender / mentions / whiteboard + expect(result.content).toBe('帮我修个 bug'); + expect(result.content).not.toContain(''); + expect(result.content).not.toContain(''); + expect(result.content).not.toContain(''); + }); + + it('缺 turnId:回退 inline(有 user_message 外壳,无 sidecar)', () => { + const result = buildNewTopicCliInput( + '帮我修个 bug', SESSION_ID, 'claude-code', undefined, + undefined, undefined, undefined, undefined, + { name: 'Bot', openId: 'ou_bot' }, + undefined, + { openId: 'ou_sender', type: 'user' as const, name: 'Sender' }, + openingOpts({ turnId: undefined }), + ); + expect(result.content).toContain(''); + expect(result.content).toContain(' { + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', envelopeInjection: 'off' as const }, + }); + const result = buildNewTopicCliInput( + '帮我修个 bug', SESSION_ID, 'claude-code', undefined, + undefined, undefined, undefined, undefined, + { name: 'Bot', openId: 'ou_bot' }, + undefined, + { openId: 'ou_sender', type: 'user' as const, name: 'Sender' }, + openingOpts(), + ); + expect(result.content).toContain(''); + expect(claimByPrompt(SESSION_ID, TURN_ID, result.content)).toBeUndefined(); + }); + + it('无 whiteboard/sender/mentions(envelope 为空):回退 inline,保留外壳', () => { + // envelope 为空时不写 sidecar,回退 inline——外壳保留给会话发现/标题提取用。 + const result = buildNewTopicCliInput( + '帮我修个 bug', SESSION_ID, 'claude-code', undefined, + undefined, undefined, undefined, undefined, + undefined, + undefined, + undefined, + openingOpts({ whiteboardId: undefined }), + ); + expect(result.content).toContain(''); + expect(claimByPrompt(SESSION_ID, TURN_ID, result.content)).toBeUndefined(); + }); + + it('skill catalog 追加到 prompt 尾部后:全量指纹 miss,prefix 兜底仍能 claim', () => { + // opening 是 CLI generation 的首轮,prepareSessionSkillPrompt 会把 skill catalog + // 追加到 prompt 尾部(${opts.prompt}\n\n${catalog}),这才是 claude-code 真正 + // typed 进 PTY 的文本。sidecar 按「catalog 追加前」的 ptyText 写指纹,所以 hook + // fire 时全量指纹 exact match 会 miss,只能靠 prefix 兜底(前 30 归一字符,catalog + // 在尾 → 前 30 不变 → turnId 定域 0/1 条 → 救回)。本测试锁住这条 fallback 路径。 + // 注意:消息需长于 PREFIX_FALLBACK_LEN(30 字符),否则前 30 字符本身就被追加改变。 + const longMessage = '帮我修个 bug,这个问题出现在用户登录模块,需要排查一下认证流程的 token 刷新逻辑'; + const result = buildNewTopicCliInput( + longMessage, SESSION_ID, 'claude-code', undefined, + undefined, + [{ name: 'Bob', openId: 'ou_bob' }], + undefined, undefined, + { name: 'Bot', openId: 'ou_bot' }, + undefined, + { openId: 'ou_sender', type: 'user' as const, name: 'Sender' }, + openingOpts(), + ); + expect(result.content).toBe(longMessage); + + // 模拟 prepareSessionSkillPrompt 把 catalog 追加到尾部 + const catalog = '\n- skill-a\n- skill-b\n'; + const typedText = `${result.content}\n\n${catalog}`; + + // 全量指纹 miss(typed 文本 ≠ sidecar 的 ptyText),且 exact miss 不消费 sidecar + const exactMiss = claimPromptContext(SESSION_ID, TURN_ID, fingerprintPromptText(typedText)); + expect(exactMiss).toBeUndefined(); + + // 但 sidecar 还在,用 prefix 兜底能 claim 回 envelope + const rescued = claimPromptContext(SESSION_ID, TURN_ID, fingerprintPromptText(typedText), prefixOf(typedText)); + expect(rescued).toBeDefined(); + expect(rescued).toContain(''); + }); +}); diff --git a/test/scheduler-silent-execute.test.ts b/test/scheduler-silent-execute.test.ts index 68ed8a82f..2e90bc98f 100644 --- a/test/scheduler-silent-execute.test.ts +++ b/test/scheduler-silent-execute.test.ts @@ -560,7 +560,8 @@ describe('executeScheduledTask — live-session injection', () => { const content = typeof injected === 'string' ? injected : injected.content; // 本地后端 + auto → hook 模式:reminder 进 sidecar,PTY 文本里没有 expect(content).not.toContain(''); - expect(content).toContain(''); + // #794 后续:hook 模式连 外壳也剥掉,PTY 文本只剩正文 + expect(content).not.toContain(''); } finally { (BOT.config as Record).envelopeInjection = prev; }