diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index d188c1489..57081cb89 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -35,6 +35,7 @@ import { ensureSessionWhiteboard, getAvailableBots, } from './session-manager.js'; +import { markInitialUserTurnPending } from './initial-user-turn.js'; import { discoverSlashCommandsForAdapter, listMcpServerNames, supportsFilesystemCommandDiscovery } from './command-discovery.js'; import { validateWorkingDir } from './working-dir.js'; import { repinSessionWorkingDir } from './session-cwd.js'; @@ -1467,6 +1468,11 @@ export async function handleCommand( pendingPrompt.trim().length > 0 || (ds!.pendingAttachments?.length ?? 0) > 0 || (ds!.pendingFollowUps?.length ?? 0) > 0; + // Nothing to submit at all (bare `/repo`: the message IS the command). + // The CLI boots idle, so the user's NEXT real message is its first + // turn and must carry the full new-topic opening — see + // markInitialUserTurnPending below. + const emptyStart = !pendingRawInput && !hasBufferedInput; if (hasBufferedInput) ensureSessionWhiteboard(ds!); const wrappedInput = hasBufferedInput ? buildNewTopicCliInput( @@ -1526,6 +1532,11 @@ export async function handleCommand( if (pendingRawInput || hasBufferedInput) { rememberLastCliInput(ds!, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput); } + // Durable, one-shot: the CLI is up but has never received a real user + // turn, so the next business message must be built as a NEW TOPIC + // (routing + built-in skill discovery + identity), not a follow-up. + // Set after the fork so a throwing fork leaves the session untouched. + if (emptyStart) markInitialUserTurnPending(ds!); ds!.pendingPrompt = undefined; ds!.pendingCodexAppText = undefined; ds!.pendingCodexAppApplicationContext = undefined; @@ -1647,6 +1658,10 @@ export async function handleCommand( sessionStore.updateSession(ds!.session); ds!.hasHistory = false; forkWorker(ds!, '', false); + // Brand-new CLI in a brand-new session record: it has never seen the + // botmux opening context either, so the next real business message + // is its new-topic first turn (same invariant as the pending path). + markInitialUserTurnPending(ds!); try { await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc)); } catch (e) { diff --git a/src/core/initial-user-turn.ts b/src/core/initial-user-turn.ts new file mode 100644 index 000000000..1a24ef7d7 --- /dev/null +++ b/src/core/initial-user-turn.ts @@ -0,0 +1,68 @@ +/** + * 「CLI 已空启动,但还没吃过任何真实用户轮」的一次性会话状态。 + * + * 背景:`/repo ` / 选仓卡 / 跳过 / mid-session 切仓这几条路径,在**没有** + * 任何 buffered 用户输入时会 `forkWorker(ds, '', false)` 把 CLI 拉起来待命。这时 + * 进程活着、`ds.worker` 非空,但 CLI 从没见过 `` / + * `` / `` —— 这些只由 `buildNewTopicCliInput` + * 产出。下一条业务消息若只按「worker 活没活」判定,就会被当 follow-up,开场上下文 + * 永久丢失(见 `Session.initialUserTurnPending` 的注释与 PR #477)。 + * + * 状态放在**持久化的 `Session`** 上而不是内存 `DaemonSession`:空启动之后、首条 + * 业务消息之前 daemon 重启,重启后那条消息仍必须是 opening。`ds.session` 在 + * restore 时就是从磁盘读回来的同一个对象,所以恢复是自动的。 + * + * 并发语义:`claimInitialUserTurn` 是**同步**的读-改-写,Node 单线程下天然互斥, + * 因此紧邻/并发到达的两条首消息只有一条能成为 opener,另一条按既有队列顺序退化为 + * 普通 follow-up。投递失败(fork 抛错 / worker 拒收)时用 `releaseInitialUserTurn` + * 把状态放回去,下一条消息重新竞争。 + * + * 边界(刻意不消费的路径):adopt/bridge 会话从不接受 botmux 包装,因此 + * `isInitialUserTurnPending` 直接对它们返回 false;botmux 控制命令、CLI raw + * passthrough、卡片回调等在到达输入构造点之前就 return 了,天然不消费。 + */ +import * as sessionStore from '../services/session-store.js'; +import { logger } from '../utils/logger.js'; +import type { DaemonSession } from './types.js'; + +/** 落盘失败绝不能掀翻消息路由:状态最差退化成「只在本进程生效」。 */ +function persist(ds: DaemonSession): void { + try { + sessionStore.updateSession(ds.session); + } catch (e) { + logger.warn( + `[${ds.session.sessionId.substring(0, 8)}] Failed to persist initialUserTurnPending: ${e instanceof Error ? e.message : e}`, + ); + } +} + +/** CLI 空启动后置位。幂等。 */ +export function markInitialUserTurnPending(ds: DaemonSession): void { + if (ds.session.initialUserTurnPending === true) return; + ds.session.initialUserTurnPending = true; + persist(ds); +} + +/** 只读探测——用于在 await 之前判断「这轮可能要走 opening」,不消费状态。 */ +export function isInitialUserTurnPending(ds: DaemonSession): boolean { + // 外部 CLI 的桥接会话从不接受 botmux 的 XML 包装,opening 对它们无意义。 + if (ds.adoptedFrom) return false; + return ds.session.initialUserTurnPending === true; +} + +/** + * 同步一次性认领。返回 true 表示**本轮**是 opening;返回 false 表示别人已经拿走 + * 了(或本来就没有),按普通 follow-up 处理。调用点与真正的 fork/send 之间可以有 + * await,失败时用 {@link releaseInitialUserTurn} 归还。 + */ +export function claimInitialUserTurn(ds: DaemonSession): boolean { + if (!isInitialUserTurnPending(ds)) return false; + delete ds.session.initialUserTurnPending; + persist(ds); + return true; +} + +/** 认领后投递失败(fork 抛错 / worker 拒收)时归还,下一条消息重新竞争。 */ +export function releaseInitialUserTurn(ds: DaemonSession): void { + markInitialUserTurnPending(ds); +} diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 29d4b4deb..ae58e5aa2 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1412,6 +1412,15 @@ export async function restoreActiveSessions(activeSessions: Map // Mark a new turn so the CLI's response to /model, /clear, /compact, etc. // shows up as a fresh streaming card instead of silently PATCH-ing the // previous turn's card. + // + // Compatibility note (empty-start opening): a raw passthrough is a + // LITERAL CLI command — its contract is that the CLI sees exactly the + // bytes the user typed, never a botmux XML envelope. So it deliberately + // does NOT consume `Session.initialUserTurnPending`: wrapping `/model` + // in `` would break the literal contract, and silently + // clearing the marker would lose the opening for the next real turn. + // `/model` on an empty-started session therefore stays literal and the + // FOLLOWING business message still opens as a new topic. beginNewTurn(ds, commandContent); ds.worker.send({ type: 'raw_input', @@ -16499,19 +16509,53 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise const selfBot = getBot(ds.larkAppId); if (!isBridge) ensureSessionWhiteboard(ds); const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId; + // Empty-started session (repo select/skip/switch booted the CLI with no + // turn): a LIVE worker is not proof the CLI ever saw botmux's opening + // context — only `buildNewTopicCliInput` emits / + // / . Probe (non-consuming) before the + // awaits below, then claim SYNCHRONOUSLY right before building so two + // near-simultaneous first messages can only produce one opener; the loser + // degrades to an ordinary follow-up in queue order. + const wantsOpening = !isBridge && isInitialUserTurnPending(ds); + const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined; + const turnSender = await getThreadSender(); + const openingTurn = wantsOpening && claimInitialUserTurn(ds); const cliInput = isBridge ? { content: buildBridgeInputContent(promptContent, { attachments, mentions: parsed.mentions, selfMention: { name: selfBot.botName, openId: selfBot.botOpenId }, }) } + : openingTurn + ? buildNewTopicCliInput( + promptContent, + ds.session.sessionId, + effectiveCliId, + ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, + attachments, + parsed.mentions, + openingBots, + undefined, + { name: selfBot.botName, openId: selfBot.botOpenId }, + localeForBot(larkAppId), + turnSender, + { + larkAppId, + chatId: ds.session.chatId, + whiteboardId: ds.session.whiteboardId, + substituteTrigger, + codexAppText: parsed.content, + codexAppApplicationContext, + codexAppMessageContext, + }, + ) : buildFollowUpCliInput(promptContent, ds.session.sessionId, { attachments, mentions: parsed.mentions, isAdoptMode: false, cliId: effectiveCliId, cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, - sender: await getThreadSender(), + sender: turnSender, larkAppId, chatId: ds.session.chatId, whiteboardId: ds.session.whiteboardId, @@ -16521,9 +16565,22 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise codexAppMessageContext, }); beginNewTurn(ds, parsed.content); - await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined); - rememberLastCliInput(ds, promptContent, cliInput); - sendWorkerInput(ds, cliInput, parsed.messageId); + await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined); + let accepted = false; + try { + accepted = sendWorkerInput(ds, cliInput, parsed.messageId); + // Record the input as the session's last real CLI turn ONLY after the + // worker accepted it. Recording before delivery (the old order) persisted + // lastCliInput / lastUserPrompt / Codex-App sidecar for a turn that never + // reached the CLI; a rejected send then left that poison behind, and the + // next message's worker-null refork would read it as `hadPriorCliInput` + // and wrongly `--resume` a CLI that never took a real turn. + if (accepted) rememberLastCliInput(ds, promptContent, cliInput); + } finally { + // The opening is one-shot: give it back when the worker died / refused, + // so the next message re-opens instead of silently losing the context. + if (openingTurn && !accepted) releaseInitialUserTurn(ds); + } } else { // Worker not running — re-fork with resume. This is a NEW turn, so drop // any restored streaming-card reference; worker_ready will POST a fresh @@ -16584,19 +16641,36 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise currentText: parsed.content, currentMessageContext: codexAppMessageContext, }); + // Empty-started session that lost its worker (daemon restart, idle sweep, + // CLI exit) — same rule as the live branch: this is still the FIRST real + // user turn, so it must be built as a new topic. A queued(待办池) activation + // is excluded: its queuedPrompt already owns the first turn. + const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds); + const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined; + const reforkSender = await getThreadSender(); + // An empty-started CLI has nothing to resume: `hasHistory` is set + // unconditionally by restoreActiveSessions (and by claude_exit / + // suspendWorker), so it cannot tell "booted idle" from "has real history". + // `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real + // CLI input, and the empty-start fork deliberately writes none. Snapshot it + // BEFORE this turn's own rememberLastCliInput below, so a session some + // non-IM path (scheduler / webhook trigger / doc comment) already fed keeps + // its normal `--resume` instead of being cold-spawned over. + const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput); + const openingTurn = wantsOpening && claimInitialUserTurn(ds); const builtReforkInput = buildReforkCliInput(ds, reforkContent, { attachments, mentions: parsed.mentions, cliId: ds.session.cliId ?? dsBotCfgForFork.cliId, cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, selfMention: { name: selfBot.botName, openId: selfBot.botOpenId }, - sender: await getThreadSender(), + sender: reforkSender, substituteTrigger, codexAppText: reforkCodexApp.text, codexAppApplicationContext, codexAppMessageContext: reforkCodexApp.messageContext, }); - const wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, { + let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, { queued: queuedDashboardTurn, queuedText: queuedCodexAppText, }); @@ -16607,13 +16681,54 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise // contain the reply and would silently discard the original task. logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`); } - await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined); + if (openingTurn) { + // Replace the follow-up envelope built above with the real opening. The + // discarded build is pure string assembly (no side effects) — keeping the + // refork statement unconditional keeps the queued/substitute wiring, and + // its guard test, on a single code path. + wrappedInput = buildNewTopicCliInput( + reforkContent, + ds.session.sessionId, + ds.session.cliId ?? dsBotCfgForFork.cliId, + ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, + attachments, + parsed.mentions, + openingBots, + undefined, + { name: selfBot.botName, openId: selfBot.botOpenId }, + localeForBot(larkAppId), + reforkSender, + { + larkAppId, + chatId: ds.session.chatId, + whiteboardId: ds.session.whiteboardId, + substituteTrigger, + codexAppText: reforkCodexApp.text, + codexAppApplicationContext, + codexAppMessageContext: reforkCodexApp.messageContext, + }, + ); + } + await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined); + try { + forkWorker(ds, wrappedInput, { + // See `hadPriorCliInput` above — an opening on a CLI that never took any + // input cold-spawns rather than `--resume`-ing an empty session. + resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput), + turnId: parsed.messageId, + }); + } catch (e) { + if (openingTurn) releaseInitialUserTurn(ds); + throw e; + } + // Record the input as the session's last real CLI turn ONLY after the fork + // succeeded. Recording before the fork (the old order) persisted lastCliInput + // for a turn that never launched when forkWorker threw; the retry then read + // that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never + // took a real turn — breaking the empty-start invariant. forkWorker itself + // persists the session (clearing queued); this records last* + reply state. rememberLastCliInput(ds, promptContent, wrappedInput); sessionStore.updateSession(ds.session); - forkWorker(ds, wrappedInput, { - resume: ds.hasHistory, - turnId: parsed.messageId, - }); } } diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 536471cdc..82ecf178b 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -71,6 +71,7 @@ import * as sessionStore from '../../services/session-store.js'; import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js'; import { forkWorker, sendWorkerInput, killWorker, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL } from '../../core/worker-pool.js'; import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistStreamCardState, resumeSession, rememberLastCliInput, ensureSessionWhiteboard } from '../../core/session-manager.js'; +import { markInitialUserTurnPending } from '../../core/initial-user-turn.js'; import { publishAttentionPatch, announcePendingRepoSession } from '../../core/session-activity.js'; import { fallbackTurnId } from '../../core/reply-target.js'; import { validateWorkingDir } from '../../core/working-dir.js'; @@ -454,8 +455,14 @@ export async function commitRepoSelection( pendingPrompt.trim().length > 0 || (ds.pendingAttachments?.length ?? 0) > 0 || (ds.pendingFollowUps?.length ?? 0) > 0; + // Nothing to submit at all (session created by a bare `/repo`, i.e. the + // message IS the command). Boot the CLI idle instead of burning an empty + // `` opening on it, and mark the session so the user's NEXT + // real message becomes the new-topic first turn. Mirrors the text + // `/repo` path in command-handler's forkPendingCli. + const emptyStart = !pendingRawInput && !hasBufferedInput; if (!pendingRawInput || hasBufferedInput) ensureSessionWhiteboard(ds); - const wrappedInput = (!pendingRawInput || hasBufferedInput) + const wrappedInput = hasBufferedInput ? buildNewTopicCliInput( pendingPrompt, ds.session.sessionId, @@ -508,8 +515,11 @@ export async function commitRepoSelection( forkWorker(ds, '', false); } else if (pendingTurnId && hasBufferedInput) { forkWorker(ds, prompt, { turnId: pendingTurnId }); - } else { + } else if (hasBufferedInput) { forkWorker(ds, prompt); + } else { + // Empty start — idle boot, no turn submitted. + forkWorker(ds, '', false); } } catch (e) { ds.pendingRepo = true; @@ -517,7 +527,12 @@ export async function commitRepoSelection( publishAttentionPatch(ds); throw e; } - rememberLastCliInput(ds, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput); + if (pendingRawInput || hasBufferedInput) { + rememberLastCliInput(ds, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput); + } + // Durable, one-shot: the CLI is up but has never received a real user turn. + // Set after the fork so a throwing fork leaves the session untouched. + if (emptyStart) markInitialUserTurnPending(ds); ds.pendingPrompt = undefined; ds.pendingCodexAppText = undefined; ds.pendingCodexAppApplicationContext = undefined; @@ -655,6 +670,9 @@ export async function commitRepoSelection( ds.lastScreenContent = undefined; ds.lastScreenStatus = undefined; forkWorker(ds, '', false); + // Brand-new CLI in a brand-new session record: the next real business + // message is its new-topic first turn (same invariant as the pending path). + markInitialUserTurnPending(ds); if (!opts?.suppressConfirmReply) { try { await sessionReply(rootId, t('cmd.repo.switched_to', { name: dirLabel }, locTarget)); diff --git a/src/types.ts b/src/types.ts index e5bb00f37..386d5942d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,26 @@ export interface Session { /** Dashboard-created image files retained while the session is active so * the CLI can read them, then removed by session-store on close. */ dashboardAttachments?: LarkAttachment[]; + /** + * 「CLI 已经空启动,但还没收到过任何真实用户轮」的一次性状态。 + * + * `/repo ` / 选仓卡 / 跳过 / mid-session 切仓这几条路径会在**没有**任何 + * buffered 用户输入(pendingPrompt/attachments/follow-ups/pendingRawInput 全空) + * 的情况下 `forkWorker(ds, '', false)` 把 CLI 拉起来待命。此时 CLI 进程活着, + * 但它从未见过 `` / `` / `` 这套 + * 开场上下文——那套东西只由 `buildNewTopicCliInput` 产出。 + * + * 没有这个标记时,下一条真实业务消息会被 worker 存活性判定成 follow-up + * (`buildFollowUpCliInput` / `buildReforkCliInput`),开场上下文永久丢失。 + * 置位后,下一条**真实业务消息**(不含 botmux 控制命令 / 卡片回调 / raw + * passthrough)走与普通 new topic 完全相同的构造路径,成功投递后一次性清除。 + * + * 持久化(而非只挂内存 DaemonSession)是为了扛 daemon 重启:空启动之后、首条 + * 业务消息之前重启,重启后那条消息仍必须是 opening。它同时也是「CLI 空启动过」 + * 与「已有真实用户历史」的判据——refork 时据此关掉 `--resume`,不去 resume 一个 + * 从未产生过真实轮的 CLI 会话。 + */ + initialUserTurnPending?: boolean; createdAt: string; /** Last user/bot/scheduler input that was routed into this session. */ lastMessageAt?: string; diff --git a/test/card-handler-repo-select.test.ts b/test/card-handler-repo-select.test.ts index 52f14192c..0d88bb99d 100644 --- a/test/card-handler-repo-select.test.ts +++ b/test/card-handler-repo-select.test.ts @@ -298,6 +298,71 @@ describe('repo select card — plain switch', () => { expect(killWorker).not.toHaveBeenCalled(); // First-spawn (pendingRepo) closes nothing, so no "session closed" card. expect(deliverEphemeralOrReply).not.toHaveBeenCalled(); + // The buffered message IS the first real user turn — nothing left pending. + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); + + // ─── empty start (no buffered user input at all) ───────────────────────── + // + // Reached when the session was created by a bare `/repo` (the message IS the + // command, so pendingPrompt is ''). The CLI must boot idle — NOT with an + // empty `` opening — and leave a durable marker so the user's + // next real message gets the full new-topic opening context. + + it('pendingRepo card selection with nothing buffered boots the CLI idle and marks the first turn pending', async () => { + const ds = makeDs({ pendingRepo: true, pendingPrompt: '', worker: null }); + const { deps } = makeDeps(ds); + + await handleCardAction(makeSelectEvent('repo_switch', '/repos/alpha'), deps, APP_ID); + + expect(forkWorker).toHaveBeenCalledTimes(1); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + // No empty/boilerplate `` opening burned on the cold start. + expect(buildNewTopicCliInput).not.toHaveBeenCalled(); + expect(ds.pendingRepo).toBe(false); + expect(ds.session.initialUserTurnPending).toBe(true); + expect(updateSession).toHaveBeenCalledWith(ds.session); + }); + + it('skip_repo with nothing buffered marks the first turn pending too', async () => { + const ds = makeDs({ pendingRepo: true, pendingPrompt: '', worker: null }); + const { deps } = makeDeps(ds); + + await handleCardAction(makeSkipEvent(), deps, APP_ID); + + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(ds.session.initialUserTurnPending).toBe(true); + }); + + it('raw-passthrough cold start does NOT mark the first turn pending', async () => { + // `/goal …` owns the first turn (delivered literally on prompt_ready), so + // this is not an "empty start awaiting its first user turn". + const ds = makeDs({ + pendingRepo: true, + pendingPrompt: '', + pendingRawInput: '/goal 发布 onboarding', + pendingRawTurnId: 'om_goal_first', + worker: null, + }); + const { deps } = makeDeps(ds); + + await handleCardAction(makeSelectEvent('repo_switch', '/repos/alpha'), deps, APP_ID); + + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(ds.pendingRawInput).toBe('/goal 发布 onboarding'); + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); + + it('mid-session card switch empty-starts the replacement CLI and marks its first turn pending', async () => { + const ds = makeDs({ pendingRepo: false }); + const { deps } = makeDeps(ds); + + await handleCardAction(makeSelectEvent('repo_switch', '/repos/beta'), deps, APP_ID); + + expect(killWorker).toHaveBeenCalled(); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(ds.hasHistory).toBe(false); + expect(ds.session.initialUserTurnPending).toBe(true); }); it('pendingRepo selection forwards the complete Codex App sidecar to forkWorker', async () => { diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 4e03bc9a6..c990d4e87 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -1984,6 +1984,24 @@ describe('handleCommand', () => { expect(newSessionUpdate![0].workingDir).toBe('/home/testuser/project-a'); }); + it('mid-session switch empty-starts the replacement CLI and marks its first turn pending', async () => { + // A repo switch closes the old session and boots a BRAND-NEW CLI with an + // empty prompt. That fresh CLI has never seen the botmux opening context, + // so the next real business message must be built as a new topic — same + // invariant as the pending-repo empty start. + const ds = makeDaemonSession({ pendingRepo: false }); + const deps = makeDeps(ds); + deps.lastRepoScan.set(CHAT_ID, [ + { name: 'project-a', path: '/home/testuser/project-a', branch: 'main' }, + ]); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo 1'), deps, LARK_APP_ID); + + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(ds.hasHistory).toBe(false); + expect(ds.session.initialUserTurnPending).toBe(true); + }); + it('should show project list card when called without argument', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(scanMultipleProjects).mockReturnValue([ @@ -2043,6 +2061,28 @@ describe('handleCommand', () => { expect(ds.repoCardMessageId).toBeUndefined(); }); + it('`/repo ` as the first message empty-starts and marks the first turn pending', async () => { + // The literal repro: a brand-new topic whose FIRST message is `/repo + // homelab`. daemon.ts seeds pendingRepo + pendingPrompt:'' (the message IS + // the command), so the CLI boots idle — and the user's next message must + // still arrive as a new-topic opening. + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(scanMultipleProjects).mockReturnValue([ + { name: 'homelab', path: '/home/testuser/homelab', branch: 'main' }, + ]); + const ds = makeDaemonSession({ pendingRepo: true, pendingPrompt: '', worker: null }); + const deps = makeDeps(ds); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo homelab'), deps, LARK_APP_ID); + + expect(ds.workingDir).toBe('/home/testuser/homelab'); + expect(forkWorker).toHaveBeenCalledWith(ds, '', false); + expect(buildNewTopicCliInput).not.toHaveBeenCalled(); + expect(sessionStore.createSession).not.toHaveBeenCalled(); // pending path, not a switch + expect(ds.pendingRepo).toBe(false); + expect(ds.session.initialUserTurnPending).toBe(true); + }); + it('should reply path_not_found when the arg resolves to nothing', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(scanMultipleProjects).mockReturnValue([]); @@ -2303,6 +2343,10 @@ describe('handleCommand', () => { // message becomes the first prompt (not an empty/boilerplate user_message). expect(forkWorker).toHaveBeenCalledWith(ds, '', false); expect(buildNewTopicPrompt).not.toHaveBeenCalled(); + // …and that NEXT message must still get the full new-topic opening, so the + // empty start has to leave a durable, persisted marker behind. + expect(ds.session.initialUserTurnPending).toBe(true); + expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); expect(killWorker).not.toHaveBeenCalled(); expect(sessionStore.createSession).not.toHaveBeenCalled(); // Cleared pending state + withdrew the (already-sent) card. @@ -2406,6 +2450,8 @@ describe('handleCommand', () => { ); expect(ds.pendingRepo).toBe(false); expect(ds.pendingTurnId).toBeUndefined(); + // The buffered message IS the first real user turn — nothing is pending. + expect(ds.session.initialUserTurnPending).toBeUndefined(); }); it('forwards the pending substitute trigger and complete Codex App sidecar', async () => { @@ -2465,6 +2511,9 @@ describe('handleCommand', () => { expect(ds.pendingRawTurnId).toBe('om_goal_first'); expect(ds.pendingFollowUpInput).toBeUndefined(); expect(ds.pendingTurnId).toBeUndefined(); + // Raw passthrough owns the first turn (delivered literally on prompt_ready), + // so this is NOT an "empty start awaiting its first user turn". + expect(ds.session.initialUserTurnPending).toBeUndefined(); }); it('raw-input cold start wraps follow-ups buffered during repo wait into pendingFollowUpInput', async () => { diff --git a/test/initial-user-turn-opening.test.ts b/test/initial-user-turn-opening.test.ts new file mode 100644 index 000000000..86a1f0152 --- /dev/null +++ b/test/initial-user-turn-opening.test.ts @@ -0,0 +1,693 @@ +/** + * Route-level regression guard for the **empty-start opening turn**. + * + * `/repo homelab`(或选仓卡 / 跳过 / mid-session 切仓)会在没有任何 buffered 用户 + * 输入时以 `forkWorker(ds, '', false)` 把 CLI 空启动:进程活着,但从没见过 + * `` / `` / `` 这套开场上下文 + * (只有 `buildNewTopicCliInput` 会产出它们)。 + * + * 修复前,下一条真实业务消息只按「worker 活没活」判定,一律走 + * `buildFollowUpCliInput` / `buildReforkCliInput`,PR #477 的 opening routing + + * built-in skill discovery 永久丢失。这里驱动**真实**路由 handler + * (`handleThreadReply`)钉住: + * + * - live worker 与 worker-null/refork 两条路都用 new-topic 开场构造首条业务输入; + * - 第二条消息回落成普通 follow-up,开场块不重复; + * - 空启动后重启(hasHistory:true + 持久标记)仍然生效,且不会错误 `--resume`; + * - 并发/紧邻两条首消息只有一个 opener; + * - botmux 控制命令 / CLI passthrough 不消费状态; + * - sender / mentions / attachments / quoted 侧车不丢; + * - prompt / off / global / dynamic 四种 skill 注入模式按**能力**断言; + * - 没有该标记的普通会话行为不回归。 + * + * Run: pnpm vitest run test/initial-user-turn-opening.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const mocks = vi.hoisted(() => { + process.env.SESSION_DATA_DIR = `${process.env.TMPDIR ?? '/tmp'}/botmux-initial-turn-${process.pid}`; + delete process.env.BOTMUX_SESSION_ID; + delete process.env.BOTMUX_LARK_APP_ID; + let seq = 0; + return { + replyMessage: vi.fn(async () => 'om_reply'), + sendMessage: vi.fn(async () => 'om_top'), + getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'), + getChatNameAndMode: vi.fn(async () => ({ name: null, mode: 'group' as const })), + resolveSender: vi.fn(async (_appId: string, openId: string | undefined, senderType: string | undefined) => ( + openId + ? { + openId, + type: senderType === 'app' || senderType === 'bot' ? 'bot' as const : 'user' as const, + name: openId === 'ou_owner' ? '凡辞' : undefined, + } + : undefined + )), + forkWorker: vi.fn(), + sendWorkerInput: vi.fn(() => true), + updateSession: vi.fn(), + createSession: vi.fn((chatId: string, rootMessageId: string, title: string, chatType?: 'group' | 'p2p') => ({ + sessionId: `sess-fake-${++seq}`, + chatId, + rootMessageId, + title, + status: 'active' as const, + createdAt: new Date().toISOString(), + chatType, + })), + listChatBotMembers: vi.fn(async () => [] as any[]), + downloadResources: vi.fn(async () => ({ attachments: [] as any[], needLogin: false })), + }; +}); + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record) {} } + return { Client: FakeClient }; +}); + +vi.mock('../src/im/lark/client.js', async () => { + const actual = await vi.importActual('../src/im/lark/client.js'); + return { + ...actual, + replyMessage: mocks.replyMessage, + sendMessage: mocks.sendMessage, + getChatMode: mocks.getChatMode, + getChatNameAndMode: mocks.getChatNameAndMode, + listChatBotMembers: mocks.listChatBotMembers, + }; +}); + +vi.mock('../src/services/session-store.js', async () => { + const actual = await vi.importActual('../src/services/session-store.js'); + return { ...actual, createSession: mocks.createSession, updateSession: mocks.updateSession }; +}); + +vi.mock('../src/im/lark/identity-cache.js', async () => { + const actual = await vi.importActual('../src/im/lark/identity-cache.js'); + return { ...actual, resolveSender: (...args: any[]) => mocks.resolveSender(...args) }; +}); + +// Only the network-facing attachment download is stubbed — every prompt builder +// (buildNewTopicCliInput / buildFollowUpCliInput / …) stays REAL so the +// assertions below observe the actual opening bytes. +vi.mock('../src/core/session-manager.js', async () => { + const actual = await vi.importActual('../src/core/session-manager.js'); + return { ...actual, downloadResources: (...args: any[]) => mocks.downloadResources(...args) }; +}); + +vi.mock('../src/core/worker-pool.js', async () => { + const actual = await vi.importActual('../src/core/worker-pool.js'); + return { + ...actual, + forkWorker: (...args: any[]) => mocks.forkWorker(...args), + sendWorkerInput: (...args: any[]) => mocks.sendWorkerInput(...args), + }; +}); + +import { registerBot } from '../src/bot-registry.js'; +import { sessionKey } from '../src/core/types.js'; +import type { DaemonSession } from '../src/core/types.js'; +import type { CliId } from '../src/adapters/cli/types.js'; +import { createCliAdapterSync } from '../src/adapters/cli/registry.js'; +import { globalConfigPath, invalidateGlobalConfigCache } from '../src/global-config.js'; +import { + __testOnly_activeSessions as activeSessions, + __testOnly_handleThreadReply as handleThreadReply, +} from '../src/daemon.js'; + +const APP = 'initial_turn_app'; +const CHAT = 'oc_initial_turn_chat'; +const OWNER = 'ou_owner'; +const NOW = new Date().toISOString(); + +let home: string; + +function writeGlobalConfig(obj: unknown): void { + mkdirSync(dirname(globalConfigPath()), { recursive: true }); + writeFileSync(globalConfigPath(), JSON.stringify(obj)); + invalidateGlobalConfigCache(); +} + +function writeBots(entries: unknown[]): void { + const p = join(home, 'bots.json'); + writeFileSync(p, JSON.stringify(entries)); + vi.stubEnv('BOTS_CONFIG', p); +} + +function makeEventData(messageId: string, text: string, rootId?: string, extra?: { + senderOpenId?: string; + mentions?: any[]; + parentId?: string; +}): any { + return { + sender: { sender_id: { open_id: extra?.senderOpenId ?? OWNER }, sender_type: 'user' }, + message: { + message_id: messageId, + root_id: rootId, + parent_id: extra?.parentId, + chat_id: CHAT, + message_type: 'text', + content: JSON.stringify({ text }), + create_time: String(Date.now()), + ...(extra?.mentions ? { mentions: extra.mentions } : {}), + }, + }; +} + +function makeCtx(anchor: string, messageId: string, replyRootId?: string): any { + return { + chatId: CHAT, + messageId, + chatType: 'group' as const, + scope: 'thread' as const, + anchor, + replyRootId: replyRootId ?? anchor, + larkAppId: APP, + }; +} + +/** + * A session whose CLI was empty-started by the repo flow: the durable + * `initialUserTurnPending` marker is set, everything else looks ordinary. + */ +function seedEmptyStarted(anchor: string, opts?: { + live?: boolean; + hasHistory?: boolean; + cliId?: CliId; + pending?: boolean; + adoptedFrom?: string; +}): DaemonSession & { worker: any } { + const send = vi.fn(); + const ds = { + scope: 'thread', + chatId: CHAT, + chatType: 'group', + larkAppId: APP, + worker: opts?.live === false ? null : { killed: false, send }, + workerPort: null, + workerToken: null, + spawnedAt: Date.now(), + cliVersion: '1.0.0', + lastMessageAt: Date.now(), + hasHistory: opts?.hasHistory ?? false, + ownerOpenId: OWNER, + ...(opts?.adoptedFrom ? { adoptedFrom: opts.adoptedFrom } : {}), + workingDir: '/tmp', + session: { + sessionId: 'sess-empty-' + Math.random().toString(36).slice(2), + chatId: CHAT, + rootMessageId: anchor, + title: 'empty started', + status: 'active', + createdAt: NOW, + larkAppId: APP, + scope: 'thread', + workingDir: '/tmp', + ...(opts?.cliId ? { cliId: opts.cliId } : {}), + ...(opts?.pending === false ? {} : { initialUserTurnPending: true }), + }, + } as unknown as DaemonSession & { worker: any }; + activeSessions.set(sessionKey(anchor, APP), ds); + return ds; +} + +/** Payload the daemon handed to the live worker (via sendWorkerInput). */ +function liveInputs(): Array<{ content: string; codexAppInput?: any }> { + return mocks.sendWorkerInput.mock.calls.map(call => (call as any[])[1]); +} + +/** Payload the daemon handed to a cold fork (via forkWorker). */ +function forkInputs(): Array<{ content: string; codexAppInput?: any }> { + return mocks.forkWorker.mock.calls.map(call => (call as any[])[1]); +} + +/** + * Capability-derived expectation for one CLI + resolved built-in skill + * injection mode. Deliberately NOT a single literal-tag assertion: Claude-family + * (`injectsSessionContext`) gets routing/identity via its own system prompt and + * must NOT carry those blocks inline, while `skillsDir` CLIs branch per mode. + */ +function openingExpectations(cliId: CliId, mode: 'prompt' | 'off' | 'global') { + const adapter = createCliAdapterSync(cliId); + const inlineRouting = !adapter.injectsSessionContext; + const builtinBlock = inlineRouting && !!adapter.skillsDir + ? (mode === 'prompt' ? 'catalog' : mode === 'off' ? 'pointer' : 'none') + : 'none'; + return { inlineRouting, builtinBlock }; +} + +describe('empty-started session — first real business turn must use the new-topic opening', () => { + beforeEach(() => { + vi.clearAllMocks(); + home = mkdtempSync(join(tmpdir(), 'botmux-initial-turn-')); + vi.stubEnv('HOME', home); + vi.stubEnv('CODEX_HOME', ''); + invalidateGlobalConfigCache(); + mocks.replyMessage.mockResolvedValue('om_reply'); + mocks.sendMessage.mockResolvedValue('om_top'); + mocks.getChatMode.mockResolvedValue('group'); + mocks.getChatNameAndMode.mockResolvedValue({ name: null, mode: 'group' }); + mocks.sendWorkerInput.mockReturnValue(true); + mocks.listChatBotMembers.mockResolvedValue([]); + mocks.downloadResources.mockResolvedValue({ attachments: [], needLogin: false }); + activeSessions.clear(); + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + oncallChats: [{ chatId: CHAT, workingDir: '/tmp' }], + }); + bot.resolvedAllowedUsers = [OWNER]; + bot.botName = 'TestBot'; + bot.botOpenId = 'ou_selfbot'; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + invalidateGlobalConfigCache(); + rmSync(home, { recursive: true, force: true }); + }); + + // ─── live worker ──────────────────────────────────────────────────────────── + + it('live worker: first business message opens with new-topic context, second is a plain follow-up', async () => { + const anchor = 'om_live_root'; + const ds = seedEmptyStarted(anchor); + + await handleThreadReply( + makeEventData('om_first', '帮我看看这个 bug', anchor), + makeCtx(anchor, 'om_first'), + ); + + expect(mocks.sendWorkerInput).toHaveBeenCalledTimes(1); + const opening = liveInputs()[0]!.content; + expect(opening).toContain(''); + expect(opening).toContain(''); + expect(opening).toContain(`${ds.session.sessionId}`); + expect(opening).toContain('\n帮我看看这个 bug\n'); + // New-topic openings never carry the follow-up reminder envelope. + expect(opening).not.toContain(''); + + // One-shot: consumed + persisted. + expect(ds.session.initialUserTurnPending).toBeUndefined(); + expect(mocks.updateSession).toHaveBeenCalledWith(ds.session); + + await handleThreadReply( + makeEventData('om_second', '继续', anchor), + makeCtx(anchor, 'om_second'), + ); + + expect(mocks.sendWorkerInput).toHaveBeenCalledTimes(2); + const followUp = liveInputs()[1]!.content; + expect(followUp).toContain(''); + expect(followUp).not.toContain(''); + expect(followUp).not.toContain(''); + expect(followUp).not.toContain(''); + }); + + it('live worker: opening carries sender, mentions, available bots and attachments', async () => { + const anchor = 'om_live_meta_root'; + seedEmptyStarted(anchor); + mocks.listChatBotMembers.mockResolvedValue([ + { larkAppId: 'peer_app', name: 'peer', displayName: 'Peer Bot', openId: 'ou_peer_bot', mentionable: true }, + ]); + mocks.downloadResources.mockResolvedValue({ + attachments: [{ type: 'image', path: '/tmp/shot.png', name: 'shot.png' }], + needLogin: false, + }); + + const data = makeEventData('om_meta', '@_user_1 看下这个', anchor, { + mentions: [{ key: '@_user_1', name: 'Peer', id: { open_id: 'ou_peer' } }], + }); + await handleThreadReply(data, makeCtx(anchor, 'om_meta')); + + const opening = liveInputs()[0]!.content; + // Opening shape (the regression) … + expect(opening).toContain(''); + expect(opening).not.toContain(''); + // … with every per-turn datum still threaded through. + expect(opening).toContain(''); + expect(opening).toContain('ou_peer'); + expect(opening).toContain(' { + const anchor = 'om_live_quote_root'; + seedEmptyStarted(anchor); + + // parent_id != root_id ⇒ the router prepends the `botmux quoted` hint. + const data = makeEventData('om_quoted', '按引用里的说明改', anchor, { parentId: 'om_quoted_target' }); + await handleThreadReply(data, makeCtx(anchor, 'om_quoted')); + + const opening = liveInputs()[0]!.content; + expect(opening).toContain(''); + expect(opening).toContain('om_quoted_target'); + expect(opening).toContain('按引用里的说明改'); + }); + + it('live worker: Codex App keeps its clean-input sidecar on the opening turn', async () => { + const anchor = 'om_codex_app_root'; + registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex-app', + codexAppCleanInput: true, + allowedUsers: [OWNER], + oncallChats: [{ chatId: CHAT, workingDir: '/tmp' }], + }).resolvedAllowedUsers = [OWNER]; + seedEmptyStarted(anchor, { cliId: 'codex-app' }); + + const data = makeEventData('om_codex_app_msg', '接着上一条改', anchor, { parentId: 'om_codex_quote_target' }); + await handleThreadReply(data, makeCtx(anchor, 'om_codex_app_msg')); + + const payload = liveInputs()[0]!; + // Visible user text stays exactly the Lark bytes; the quote hint and sender + // ride the structured sidecar instead of polluting the message. + expect(payload.codexAppInput?.text).toBe('接着上一条改'); + expect(payload.codexAppInput?.additionalContext?.botmux_message_context?.value) + .toContain('om_codex_quote_target'); + expect(payload.codexAppInput?.additionalContext?.botmux_sender?.value) + .toContain('ou_owner'); + }); + + // ─── worker-null / refork ─────────────────────────────────────────────────── + + it('worker-null refork: opens with new-topic context and does NOT resume a never-used CLI', async () => { + const anchor = 'om_refork_root'; + // hasHistory:true is exactly what restoreActiveSessions sets after a daemon + // restart — the empty-start marker must still win over it. + const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true }); + + await handleThreadReply( + makeEventData('om_cold_first', '重启之后的第一条真实消息', anchor), + makeCtx(anchor, 'om_cold_first'), + ); + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + const opening = forkInputs()[0]!.content; + expect(opening).toContain(''); + expect(opening).toContain('\n重启之后的第一条真实消息\n'); + expect(opening).not.toContain(''); + expect(mocks.forkWorker.mock.calls[0]?.[2]).toEqual({ resume: false, turnId: 'om_cold_first' }); + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); + + it('worker-null refork keeps --resume when a non-IM path already fed the CLI', async () => { + // Scheduler / webhook trigger / doc comment can inject a turn into an + // empty-started session without being a *user* turn, so the marker survives. + // That CLI now has real history: the opening must not cold-spawn over it. + const anchor = 'om_prior_input_root'; + const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true }); + // Both mirrors, exactly as restoreActiveSessions / rememberLastCliInput leave them. + ds.lastCliInput = '\n定时任务的一轮\n'; + ds.session.lastCliInput = ds.lastCliInput; + + await handleThreadReply( + makeEventData('om_after_schedule', '人类的第一条消息', anchor), + makeCtx(anchor, 'om_after_schedule'), + ); + + const opening = forkInputs()[0]!.content; + expect(opening).toContain(''); + expect(mocks.forkWorker.mock.calls[0]?.[2]).toEqual({ resume: true, turnId: 'om_after_schedule' }); + }); + + it('worker-null refork without the marker keeps the ordinary resume follow-up path', async () => { + const anchor = 'om_plain_refork_root'; + seedEmptyStarted(anchor, { live: false, hasHistory: true, pending: false }); + + await handleThreadReply( + makeEventData('om_plain_cold', '普通冷启回话', anchor), + makeCtx(anchor, 'om_plain_cold'), + ); + + expect(mocks.forkWorker).toHaveBeenCalledTimes(1); + const content = forkInputs()[0]!.content; + expect(content).toContain(''); + expect(content).not.toContain(''); + expect(mocks.forkWorker.mock.calls[0]?.[2]).toEqual({ resume: true, turnId: 'om_plain_cold' }); + }); + + // ─── restart durability ───────────────────────────────────────────────────── + + it('survives a daemon restart: the marker lives on the persisted Session record', async () => { + const anchor = 'om_restart_root'; + const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true }); + // Simulate restore: a brand-new DaemonSession wrapper around the SAME + // persisted Session object that was read back from disk. + const persisted = JSON.parse(JSON.stringify(ds.session)); + expect(persisted.initialUserTurnPending).toBe(true); + activeSessions.clear(); + + const restored = { + ...ds, + worker: null, + hasHistory: true, + session: persisted, + } as unknown as DaemonSession; + activeSessions.set(sessionKey(anchor, APP), restored); + + await handleThreadReply( + makeEventData('om_after_restart', '重启后第一条', anchor), + makeCtx(anchor, 'om_after_restart'), + ); + + expect(forkInputs()[0]!.content).toContain(''); + expect(restored.session.initialUserTurnPending).toBeUndefined(); + }); + + // ─── concurrency ──────────────────────────────────────────────────────────── + + it('two back-to-back first messages produce exactly one opener', async () => { + const anchor = 'om_race_root'; + seedEmptyStarted(anchor); + + await Promise.all([ + handleThreadReply(makeEventData('om_race_a', '第一条', anchor), makeCtx(anchor, 'om_race_a')), + handleThreadReply(makeEventData('om_race_b', '第二条', anchor), makeCtx(anchor, 'om_race_b')), + ]); + + const openings = liveInputs().filter(p => p.content.includes('')); + expect(mocks.sendWorkerInput).toHaveBeenCalledTimes(2); + expect(openings).toHaveLength(1); + }); + + // ─── control traffic must not consume the state ───────────────────────────── + + it('a botmux daemon command does not consume the pending opening', async () => { + const anchor = 'om_cmd_root'; + const ds = seedEmptyStarted(anchor); + + await handleThreadReply( + makeEventData('om_status', '/status', anchor), + makeCtx(anchor, 'om_status'), + ); + + expect(mocks.sendWorkerInput).not.toHaveBeenCalled(); + expect(ds.session.initialUserTurnPending).toBe(true); + + await handleThreadReply( + makeEventData('om_after_cmd', '现在才是真正的任务', anchor), + makeCtx(anchor, 'om_after_cmd'), + ); + expect(liveInputs()[0]!.content).toContain(''); + }); + + it('a raw CLI passthrough command keeps its literal contract and does not consume the state', async () => { + const anchor = 'om_passthrough_root'; + const ds = seedEmptyStarted(anchor); + + await handleThreadReply( + makeEventData('om_model', '/model opus', anchor), + makeCtx(anchor, 'om_model'), + ); + + // Literal command straight to the CLI — never wrapped in botmux XML. + expect(ds.worker.send).toHaveBeenCalledWith({ + type: 'raw_input', + content: '/model opus', + turnId: 'om_model', + }); + expect(mocks.sendWorkerInput).not.toHaveBeenCalled(); + expect(ds.session.initialUserTurnPending).toBe(true); + }); + + // ─── skill-injection modes, asserted by capability ────────────────────────── + + for (const mode of ['prompt', 'off', 'global'] as const) { + for (const cliId of ['codex', 'claude-code'] as CliId[]) { + it(`opening honours skillInjection=${mode} for ${cliId} (capability-derived)`, async () => { + writeGlobalConfig({ skills: { builtinInjection: mode } }); + writeBots([{ larkAppId: APP, larkAppSecret: 's', cliId }]); + const anchor = `om_mode_${mode}_${cliId}`; + seedEmptyStarted(anchor, { cliId }); + + await handleThreadReply( + makeEventData(`${anchor}_msg`, '开工', anchor), + makeCtx(anchor, `${anchor}_msg`), + ); + + const opening = liveInputs()[0]!.content; + const { inlineRouting, builtinBlock } = openingExpectations(cliId, mode); + + if (inlineRouting) { + expect(opening).toContain(''); + expect(opening).toContain('botmux send'); + } else { + // dynamic / session-context CLI: routing + skills ride the CLI's own + // native injection channel, so the turn must not duplicate them. + expect(opening).not.toContain(''); + expect(opening).not.toContain(''); + } + + if (builtinBlock === 'catalog') { + expect(opening).toContain(''); + expect(opening).toContain('botmux-send'); + } else if (builtinBlock === 'pointer') { + expect(opening).toContain(''); + expect(opening).toContain('botmux --help'); + expect(opening).not.toContain('- botmux-send:'); + } else { + expect(opening).not.toContain(''); + } + // Always a real user turn, never an empty boilerplate opening — and + // never the follow-up envelope (the single mode-independent tell that + // this went through buildNewTopicCliInput, incl. for claude-family). + expect(opening).toContain('\n开工\n'); + expect(opening).not.toContain(''); + }); + } + } + + // ─── adopt / bridge must never be touched ─────────────────────────────────── + + it('adopt-bridge sessions never receive a botmux opening envelope', async () => { + const anchor = 'om_adopt_root'; + const ds = seedEmptyStarted(anchor, { adoptedFrom: 'tmux:user-session' }); + + await handleThreadReply( + makeEventData('om_adopt_msg', '桥接会话的一条消息', anchor), + makeCtx(anchor, 'om_adopt_msg'), + ); + + const content = liveInputs()[0]!.content; + expect(content).not.toContain(''); + expect(content).not.toContain(''); + expect(content).toContain('桥接会话的一条消息'); + // Bridge sessions must not consume (or even see) the marker. + expect(ds.session.initialUserTurnPending).toBe(true); + }); + + // ─── failure handling ─────────────────────────────────────────────────────── + + it('a rejected live send restores the pending opening for the next message', async () => { + const anchor = 'om_reject_root'; + const ds = seedEmptyStarted(anchor); + mocks.sendWorkerInput.mockReturnValueOnce(false); + + await handleThreadReply( + makeEventData('om_rejected', '这条会被 worker 拒绝', anchor), + makeCtx(anchor, 'om_rejected'), + ); + + // The opening WAS built and offered … + expect(liveInputs()[0]!.content).toContain(''); + // … but the worker refused it, so the one-shot state goes back. + expect(ds.session.initialUserTurnPending).toBe(true); + + await handleThreadReply( + makeEventData('om_retry', '再试一次', anchor), + makeCtx(anchor, 'om_retry'), + ); + expect(liveInputs()[1]!.content).toContain(''); + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); + + it('a throwing cold fork restores the pending opening', async () => { + const anchor = 'om_fork_boom_root'; + const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true }); + mocks.forkWorker.mockImplementationOnce(() => { throw new Error('fork boom'); }); + + await expect(handleThreadReply( + makeEventData('om_fork_boom', '冷启失败', anchor), + makeCtx(anchor, 'om_fork_boom'), + )).rejects.toThrow('fork boom'); + + expect(forkInputs()[0]!.content).toContain(''); + expect(ds.session.initialUserTurnPending).toBe(true); + }); + + // ─── regression: a FAILED delivery must not poison last* / --resume ────────── + // + // The refork branch decides `resume` from `hadPriorCliInput`, i.e. whether the + // session already has a real `lastCliInput`. An empty-started CLI never took a + // real turn, so lastCliInput is unset and the opening must COLD-SPAWN + // (resume:false). The bug: rememberLastCliInput ran BEFORE forkWorker, so a + // throwing fork left lastCliInput populated with an input that never launched; + // the retry then read it as prior history and wrongly resumed. The fix records + // last* only AFTER delivery is confirmed. + + it('after a throwing cold fork, the RETRY still cold-spawns (resume:false), not resume:true', async () => { + const anchor = 'om_fork_boom_retry_root'; + const ds = seedEmptyStarted(anchor, { live: false, hasHistory: true }); + mocks.forkWorker.mockImplementationOnce(() => { throw new Error('fork boom'); }); + + await expect(handleThreadReply( + makeEventData('om_boom_first', '冷启失败的第一条', anchor), + makeCtx(anchor, 'om_boom_first'), + )).rejects.toThrow('fork boom'); + + // The failed attempt must NOT have recorded a phantom prior input … + expect(ds.session.initialUserTurnPending).toBe(true); + expect(ds.lastCliInput ?? ds.session.lastCliInput).toBeFalsy(); + + // … so the retry re-opens AND cold-spawns (never --resume a never-run CLI). + await handleThreadReply( + makeEventData('om_boom_retry', '重试', anchor), + makeCtx(anchor, 'om_boom_retry'), + ); + const retryInput = forkInputs()[forkInputs().length - 1]!; + expect(retryInput.content).toContain(''); + expect(mocks.forkWorker.mock.calls[mocks.forkWorker.mock.calls.length - 1]?.[2]) + .toEqual({ resume: false, turnId: 'om_boom_retry' }); + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); + + it('a rejected live send that loses its worker still cold-spawns on the refork retry (resume:false)', async () => { + // live worker rejects the opening (returns false) → marker restored. The + // worker then dies before the next message, so the retry hits the + // worker-null refork branch — which must still see no prior CLI input and + // cold-spawn, not resume the empty CLI. + const anchor = 'om_reject_then_dead_root'; + const ds = seedEmptyStarted(anchor, { hasHistory: true }); + mocks.sendWorkerInput.mockReturnValueOnce(false); + + await handleThreadReply( + makeEventData('om_live_reject', '被拒的第一条', anchor), + makeCtx(anchor, 'om_live_reject'), + ); + // Offered as an opening, refused, marker returned, no phantom prior input. + expect(liveInputs()[0]!.content).toContain(''); + expect(ds.session.initialUserTurnPending).toBe(true); + expect(ds.lastCliInput ?? ds.session.lastCliInput).toBeFalsy(); + + // Worker dies; next message re-forks cold. + ds.worker = null; + await handleThreadReply( + makeEventData('om_after_death', '重试', anchor), + makeCtx(anchor, 'om_after_death'), + ); + const retryInput = forkInputs()[forkInputs().length - 1]!; + expect(retryInput.content).toContain(''); + expect(mocks.forkWorker.mock.calls[mocks.forkWorker.mock.calls.length - 1]?.[2]) + .toEqual({ resume: false, turnId: 'om_after_death' }); + expect(ds.session.initialUserTurnPending).toBeUndefined(); + }); +});