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;
}