diff --git a/CONTEXT.md b/CONTEXT.md index 8b15cfb1e..c6c9c669c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,17 @@ cache read/create tokens when the CLI reports them; Token Out is the native output-side total. Botmux does not estimate token counts from message text. _Avoid_: token estimate, cost estimate +**Context Usage**: +The latest valid context-window measurement reported by an **Agent CLI** or its +persisted transcript. It may decrease after compaction and is never derived +from cumulative **Token Usage**. The window size and percentage are shown only +when the Agent CLI provides enough native data; missing measurements are +omitted from card footers rather than inferred from the model name. +Each Bot may set `showUsageInCardFooter: false` to hide both Context Usage and +Token Usage from ordinary reply-card footers. This is a display preference +only; Usage Ledger accounting and other usage consumers remain active. +_Avoid_: cumulative context, estimated context window + **Usage Ledger**: Append-only daily JSONL files under `~/.botmux/usage/` recording per-turn **Token Usage** deltas per **Session**. Each record is a self-describing JSON @@ -48,5 +59,5 @@ route that reply back to the same Agent CLI conversation." Dev: "Cursor did not expose Token Usage for this Session." -Domain expert: "Then botmux should say the Token Usage is unavailable, not guess -from the visible text." +Domain expert: "Then botmux should omit Token Usage from the card footer, not +guess from the visible text." diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index a14320b63..7844d1318 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -139,6 +139,7 @@ You can also add it to the corresponding bot entry directly (manual `bots.json` | Field | Description | |------|------| | `brandLabel` | Branding text at the bottom of the card. `undefined` = default `botmux` link; `""` = hidden; any other string = rendered as-is (supports markdown). Purely cosmetic, does not affect routing / permissions | +| `showUsageInCardFooter` | Whether reply-card footers show native Context / Token usage from the Agent CLI. Missing / `true` = show; `false` = hide both metrics. A missing individual metric is still omitted independently. This controls card display only and does not disable the Usage Ledger or other accounting | | `disableStreamingCard` | When `true`, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via `botmux send`, there's just no auto-refreshing status card). For users who find the real-time card noisy | | `silentTurnReactions` | When `true`, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when `disableStreamingCard` or `noCardChats` suppresses live cards; defaults to `false` | | `receivedReactionEmoji` | Feishu emoji_type for the "received" reaction in card-off sessions; `undefined` = default `GoGoGo` (冲!). Free-form string; a bad value just silently fails to attach (best-effort) | diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index c8e44abc2..786628aca 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -139,6 +139,7 @@ | 字段 | 说明 | |------|------| | `brandLabel` | 卡片底部品牌文案。`undefined`=默认 `botmux` 链接;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 | +| `showUsageInCardFooter` | 回复卡片页脚是否展示 Agent CLI 原生提供的 Context / Token 用量。缺省 / `true`=展示,`false`=同时隐藏两项;单项数据缺失时仍只省略缺失项。仅控制卡片展示,不停止 Usage Ledger 或其它统计 | | `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 | | `silentTurnReactions` | `true` 时,无卡片会话不再给触发消息添加 GoGoGo / DONE reaction。只影响 `disableStreamingCard` 或 `noCardChats` 关闭实时卡片后的轻量状态提示;默认 `false` | | `receivedReactionEmoji` | 无卡片会话「已收到」reaction 的飞书 emoji_type;`undefined`=默认 `GoGoGo`(冲!)。自由字符串,填错只是静默不加表情(best-effort) | diff --git a/docs/assets/card-usage-footer.png b/docs/assets/card-usage-footer.png new file mode 100644 index 000000000..0a74f153e Binary files /dev/null and b/docs/assets/card-usage-footer.png differ diff --git a/docs/assets/dashboard-card-usage-toggle.png b/docs/assets/dashboard-card-usage-toggle.png new file mode 100644 index 000000000..c3ca08fec Binary files /dev/null and b/docs/assets/dashboard-card-usage-toggle.png differ diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 9f8c61149..acd3c7789 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -32,6 +32,10 @@ export type { } from './types.js'; export type ChatReplyMode = 'chat' | 'new-topic' | 'shared' | 'chat-topic'; +/** Where a bot shows native Context / Token usage on its Session cards. */ +export type UsageDisplayMode = 'streaming' | 'footer' | 'off'; +/** Default when a bot sets nothing: usage rides the live streaming card. */ +export const DEFAULT_USAGE_DISPLAY: UsageDisplayMode = 'streaming'; export type ContentTriggerScope = 'topic' | 'regularGroup' | 'both'; export type ContentTriggerMatchType = 'keyword' | 'regex'; export type ContentTriggerActionType = 'start-or-wake-session'; @@ -1168,9 +1172,16 @@ export interface BotConfig { */ brandLabel?: string; /** - * botmux slash 可注入的 CLI 原生斜杠命令 allowlist(如 ["/compact","/model"])。 - * 缺省/空 = 通用注入关闭。/cd 永远被拒(见 core/slash-inject.ts)。 + * Where to show native Context / Token usage for this bot's Session cards: + * • `'streaming'` (default / unset) → in the live streaming card body + * • `'footer'` → in the ordinary reply-card footer + * • `'off'` → nowhere + * A missing individual metric is still omitted independently, and this only + * controls DISPLAY — Usage Ledger accounting and other consumers are + * unaffected. Backward compat: a legacy `showUsageInCardFooter: false` with no + * `usageDisplay` set is read as `'off'` (see {@link resolveUsageDisplay}). */ + usageDisplay?: UsageDisplayMode; tuiSlashAllow?: string[]; /** * When true, suppress the live streaming session card entirely. The web @@ -1363,6 +1374,7 @@ export function __testOnly_resetBotRegistry(): void { loadedConfigPath = undefined; oncallChatCache = null; brandLabelCache = null; + usageDisplayCache = null; } // Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without @@ -1635,6 +1647,23 @@ export function isChatOncallBoundForAnyBot(chatId: string): boolean { // Per-bot brand label, mtime-cached for the disk fallback. Keyed by larkAppId → // the configured value (undefined when the bot has no brandLabel key). let brandLabelCache: { mtimeMs: number; map: Map } | null = null; +let usageDisplayCache: { mtimeMs: number; map: Map } | null = null; + +/** Normalize a raw bots.json entry's usage-display intent to the enum, applying + * backward compat: an explicit `usageDisplay` wins; otherwise a legacy + * `showUsageInCardFooter: false` maps to `'off'`; everything else is the + * default (`'streaming'`). Single source of truth for both the in-memory parse + * and the disk-fallback resolver so they cannot drift. */ +export function normalizeUsageDisplay(entry: { + usageDisplay?: unknown; + showUsageInCardFooter?: unknown; +}): UsageDisplayMode { + if (entry.usageDisplay === 'streaming' || entry.usageDisplay === 'footer' || entry.usageDisplay === 'off') { + return entry.usageDisplay; + } + if (entry.showUsageInCardFooter === false) return 'off'; + return DEFAULT_USAGE_DISPLAY; +} /** Resolve the bots.json path the same way loadBotConfigs does, without * requiring the registry to have been loaded (works in one-shot CLI processes @@ -1687,6 +1716,43 @@ export function resolveBrandLabel(larkAppId: string): string | undefined { } } +/** + * Resolve the per-bot usage-display mode (default `'streaming'`). A freshly + * loaded registry wins over the spawn-time env so long-lived panes observe + * `/botconfig` hot updates; sandboxed/env-only processes carry the value in + * their synthetic registered bot and otherwise fall back to the injected env. + */ +export function resolveUsageDisplay(larkAppId: string): UsageDisplayMode { + const inMem = bots.get(larkAppId); + if (inMem) return normalizeUsageDisplay(inMem.config); + if (process.env.BOTMUX_LARK_APP_ID === larkAppId + && 'BOTMUX_USAGE_DISPLAY' in process.env) { + const env = process.env.BOTMUX_USAGE_DISPLAY; + if (env === 'streaming' || env === 'footer' || env === 'off') return env; + return DEFAULT_USAGE_DISPLAY; + } + const path = loadedConfigPath ?? botsConfigDiskPath(); + if (!path) return DEFAULT_USAGE_DISPLAY; + try { + const stat = statSync(path); + if (!usageDisplayCache || usageDisplayCache.mtimeMs !== stat.mtimeMs) { + const raw = JSON.parse(readFileSync(path, 'utf-8')); + const map = new Map(); + if (Array.isArray(raw)) { + for (const entry of raw) { + if (entry && typeof entry.larkAppId === 'string') { + map.set(entry.larkAppId, normalizeUsageDisplay(entry)); + } + } + } + usageDisplayCache = { mtimeMs: stat.mtimeMs, map }; + } + return usageDisplayCache.map.get(larkAppId) ?? DEFAULT_USAGE_DISPLAY; + } catch { + return DEFAULT_USAGE_DISPLAY; + } +} + /** * 只读 accessor:该 bot 配置的 tuiSlashAllow allowlist(TUI 通用 slash 注入用)。 * 仅读内存态注册表,daemon 进程内使用;无需 bots.json 磁盘回退(不同于 @@ -2173,6 +2239,12 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { // Preserve '' distinctly from undefined: '' means "brand off", undefined // means "use default botmux brand". Don't trim-to-undefined here. brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined, + // Persist only a non-default usage-display mode; 'streaming' (default) and + // an absent key both mean streaming. Legacy showUsageInCardFooter:false is + // still honored on read (see normalizeUsageDisplay) but never re-emitted. + usageDisplay: normalizeUsageDisplay(entry) === DEFAULT_USAGE_DISPLAY + ? undefined + : normalizeUsageDisplay(entry), disableStreamingCard: entry.disableStreamingCard === true || undefined, silentTurnReactions: entry.silentTurnReactions === true || undefined, receivedReactionEmoji: typeof entry.receivedReactionEmoji === 'string' && entry.receivedReactionEmoji.trim() diff --git a/src/cli.ts b/src/cli.ts index 2d2fced52..74659f29a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3054,6 +3054,7 @@ interface AdoptedFromData { herdrTarget?: string; herdrPaneId?: string; originalCliPid?: number; + sessionId?: string; cwd?: string; cliId?: string; } @@ -3108,6 +3109,8 @@ interface SessionData { // here, so they're typed loosely. Used by cmdList to avoid reporting an // unconfirmed /adopt scratch as a crashed CLI session. cliId?: string; + /** CLI-native resume id when it differs from botmux's Session id. */ + cliSessionId?: string; lastCliInput?: string; adoptedFrom?: AdoptedFromData; /** Deliberately suspended by the resident-session cap. No process/backing @@ -4290,6 +4293,108 @@ function findDaemon(larkAppId?: string): { return all[0] ?? null; } +function normalizeCardUsageSnapshot(value: unknown): CardUsageSnapshot | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + const rawContext = raw.context; + const rawTokens = raw.tokens; + + let context: CardUsageSnapshot['context'] = null; + if (rawContext && typeof rawContext === 'object' && !Array.isArray(rawContext)) { + const c = rawContext as Record; + if (typeof c.usedTokens === 'number' + && Number.isFinite(c.usedTokens) + && c.usedTokens >= 0) { + context = { + usedTokens: c.usedTokens, + ...(typeof c.windowTokens === 'number' + && Number.isFinite(c.windowTokens) + && c.windowTokens > 0 + ? { windowTokens: c.windowTokens } + : {}), + ...(typeof c.percentUsed === 'number' + && Number.isFinite(c.percentUsed) + && c.percentUsed >= 0 + ? { percentUsed: c.percentUsed } + : {}), + }; + } + } + + let tokens: CardUsageSnapshot['tokens'] = null; + if (rawTokens && typeof rawTokens === 'object' && !Array.isArray(rawTokens)) { + const u = rawTokens as Record; + if (typeof u.in === 'number' + && Number.isFinite(u.in) + && u.in >= 0 + && typeof u.out === 'number' + && Number.isFinite(u.out) + && u.out >= 0) { + tokens = { in: u.in, out: u.out }; + } + } + + return { context, tokens }; +} + +/** Prefer the resident daemon's incremental transcript cache. Older/offline + * daemons and isolated environments fall back to the local reader; either path + * degrades to explicit unavailable facts without blocking the reply. */ +async function readCardUsageSnapshotForSend( + session: SessionData, + larkAppId: string, +): Promise { + let daemonPort: number | undefined; + try { + daemonPort = + findDaemon(larkAppId)?.ipcPort + ?? resolveDaemonIpcPort(undefined, process.env.BOTMUX_DAEMON_IPC_PORT); + } catch { + // A stale/unreadable daemon registry must not prevent the reply. + } + if (daemonPort) { + try { + const path = `/api/sessions/${encodeURIComponent(session.sessionId)}/usage`; + const response = await fetchDaemonIpc(daemonPort, path, { + method: 'GET', + signal: AbortSignal.timeout(1_500), + }); + if (response.ok) { + const body = await response.json() as { usage?: unknown }; + const normalized = normalizeCardUsageSnapshot(body.usage); + if (normalized) return normalized; + } + } catch { + // No host secret, old daemon, timeout, or transient IPC failure: use the + // same bounded local parser below. + } + } + + // Old/offline daemon fallback. Sandboxed panes receive this per-bot value + // explicitly from the worker because bots.json is intentionally unreadable. + // This send path renders into the reply-card FOOTER, so only the 'footer' + // display mode surfaces usage here; 'streaming' shows it on the daemon's live + // card (absent on this offline fallback) and 'off' shows nothing. + if (resolveUsageDisplay(larkAppId) !== 'footer') { + return { context: null, tokens: null }; + } + + try { + return getSessionUsageSnapshot({ + cliId: (session.cliId ?? session.adoptedFrom?.cliId ?? 'unknown') as CliId | 'unknown', + sessionId: session.sessionId, + cliSessionId: session.cliSessionId ?? session.adoptedFrom?.sessionId, + cwd: session.workingDir ?? session.adoptedFrom?.cwd, + // BOT_HOME transcript fallback for CLI-data-redirected / sandboxed bots + // (parity with the daemon reader and the ledger/dashboard consumers). + larkAppId: larkAppId ?? session.larkAppId, + fresh: true, + }); + } catch { + return { context: null, tokens: null }; + } +} + /** * Authenticate the human who opened this exact turn against the target run, * then return the only daemon app that may receive the mutation. Inherited @@ -5888,37 +5993,40 @@ function argValues(args: string[], ...flags: string[]): string[] { function withCustomCardMentionFooter( card: Record, mentionOpenIds: readonly string[], - sentToLabel: string, + locale?: Locale, ): { ok: true; card: Record } | { ok: false; error: string } { if (mentionOpenIds.length === 0) return { ok: true, card }; - const cloned = JSON.parse(JSON.stringify(card)) as Record; - const body = cloned.body as { elements?: unknown } | undefined; - if (!body || !Array.isArray(body.elements)) { + const deduped = [...new Set(mentionOpenIds.filter(Boolean))]; + const cloned = appendReplyCardFooterToV2Card(card, { + brand: '', + recipientOpenIds: deduped, + locale, + }); + if (!cloned) { return { ok: false, - error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0 且包含 body.elements;或改用 --no-mention 并在卡片 JSON 内自行处理展示', + error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0、包含 body.elements,且未占用 botmux_reply_footer 元素 ID;或改用 --no-mention 并在卡片 JSON 内自行处理展示', }; } - const deduped = [...new Set(mentionOpenIds.filter(Boolean))]; - body.elements.push( - { tag: 'hr' }, - { - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${sentToLabel}${deduped.map(id => ``).join(' ')}`, - }, - ); return { ok: true, card: cloned }; } // Card v2 body builder helpers — extracted to im/lark/md-card.ts so the // daemon's bridge fallback path can produce identical cards. cmdSend // keeps using `buildImageCardElements` from there. -import { buildImageCardElements, brandFooterSegment, prepareCardMarkdown, type LocalHomeLinkMode } from './im/lark/md-card.js'; +import { + appendReplyCardFooterToV2Card, + buildImageCardElements, + buildReplyCardFooter, + prepareCardMarkdown, + type CardUsageSnapshot, + type LocalHomeLinkMode, +} from './im/lark/md-card.js'; import { applyInlineMentions } from './im/lark/inline-mentions.js'; import { renderBrandTemplate } from './im/lark/brand-template.js'; -import { resolveBrandLabel } from './bot-registry.js'; +import { resolveBrandLabel, resolveUsageDisplay } from './bot-registry.js'; import { config } from './config.js'; +import { getSessionUsageSnapshot } from './core/cost-calculator.js'; import { resolveQuoteTarget, validateMentionDecision, @@ -6098,6 +6206,12 @@ async function registerSelfFromCredFile(): Promise { larkAppSecret: cred.larkAppSecret, cliId: 'claude-code', brand: cred.brand as 'feishu' | 'lark' | undefined, + usageDisplay: + process.env.BOTMUX_USAGE_DISPLAY === 'streaming' || + process.env.BOTMUX_USAGE_DISPLAY === 'footer' || + process.env.BOTMUX_USAGE_DISPLAY === 'off' + ? (process.env.BOTMUX_USAGE_DISPLAY as import('./bot-registry.js').UsageDisplayMode) + : undefined, } as import('./bot-registry.js').BotConfig); } @@ -6162,6 +6276,12 @@ function riffModeSession(opts: { evenWithLocalSessions?: boolean } = {}): { sess brand, cliId: 'riff', allowedUsers: [], + usageDisplay: + process.env.BOTMUX_USAGE_DISPLAY === 'streaming' || + process.env.BOTMUX_USAGE_DISPLAY === 'footer' || + process.env.BOTMUX_USAGE_DISPLAY === 'off' + ? (process.env.BOTMUX_USAGE_DISPLAY as import('./bot-registry.js').UsageDisplayMode) + : undefined, } as unknown as import('./bot-registry.js').BotConfig; const session: SessionData = { @@ -7223,7 +7343,7 @@ async function cmdSend(rest: string[]): Promise { const withFooter = withCustomCardMentionFooter( customCard, mentionFooter, - t('card.sent_to', undefined, localeForBot(appId)), + localeForBot(appId), ); if (!withFooter.ok) { console.error(`botmux send: ${withFooter.error}`); process.exit(2); } customCard = withFooter.card; @@ -7315,9 +7435,6 @@ async function cmdSend(rest: string[]): Promise { // Brand segment honours this bot's configured brandLabel (unset → // default botmux, '' → suppressed, else custom). Same resolver/rule as // the daemon's card builders so both send paths render identically. - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(renderBrandTemplate(resolveBrandLabel(appId), s.workingDir)); - if (brandSeg) footerParts.push(brandSeg); // All real mentions land on one footer line: human addressee first, then // explicit @ targets (incl. handoff bots), then cc. Ids already inlined in // the body prose are skipped. Top-level publish keeps sendTo empty. @@ -7327,9 +7444,13 @@ async function cmdSend(rest: string[]): Promise { cc: footerAddressing.cc, inlinedIds: usedIds, }); - if (footerRecipients.length > 0) { - footerParts.push(`${t('card.sent_to', undefined, localeForBot(appId))}${footerRecipients.map(id => ``).join(' ')}`); - } + const usageSnapshot = await readCardUsageSnapshotForSend(s, appId); + const footer = buildReplyCardFooter({ + brand: renderBrandTemplate(resolveBrandLabel(appId), s.workingDir), + recipientOpenIds: footerRecipients, + usage: usageSnapshot, + locale: localeForBot(appId), + }); // Footer line (brand 个性签名 + 发送给) and the optional 🔊 语音总结 button // share ONE row: footer text on the left (weighted, fills), button pinned // to the far right (auto width). When voice isn't configured the footer @@ -7345,9 +7466,7 @@ async function cmdSend(rest: string[]): Promise { voiceOn = isVoiceConfigured(appId); } catch { /* voice module/config unavailable → no button */ } } - const footerContent = footerParts.length > 0 - ? `${footerParts.join(' · ')}` - : ''; + const footerContent = footer?.content ?? ''; if (footerContent || voiceOn) { elements.push({ tag: 'hr' }); if (voiceOn) { @@ -7359,7 +7478,11 @@ async function cmdSend(rest: string[]): Promise { columns: [ { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', - elements: [{ tag: 'markdown', text_size: 'notation_small_v2', content: footerContent || ' ' }], + elements: [footer?.element ?? { + tag: 'markdown', + text_size: 'notation_small_v2', + content: ' ', + }], }, { tag: 'column', width: 'auto', vertical_align: 'center', @@ -7376,11 +7499,7 @@ async function cmdSend(rest: string[]): Promise { ], }); } else { - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: footerContent, - }); + if (footer) elements.push(footer.element); } } diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index df4d189b7..a1134a446 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -28,6 +28,24 @@ export interface SessionTokenUsage extends SessionCost { out: number; } +/** Latest Context Usage reported by the Agent CLI. A missing window means the + * CLI reported usage but not its model's context capacity. `percentUsed`, when + * present, is produced by the source parser from native facts and remains + * consistent with the displayed measurement; the card renderer never infers + * it. */ +export interface SessionContextUsage { + usedTokens: number; + windowTokens?: number; + percentUsed?: number; +} + +/** Card-facing usage snapshot. Context is latest-turn state while Token Usage + * is cumulative for the Session; neither value is inferred from the other. */ +export interface SessionUsageSnapshot { + context: SessionContextUsage | null; + tokens: SessionTokenUsage | null; +} + export interface SessionTokenUsageQuery { cliId?: CliId | 'unknown'; sessionId: string; @@ -37,7 +55,7 @@ export interface SessionTokenUsageQuery { * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ larkAppId?: string; /** Bypass the reparse throttle (stat short-circuit and incremental folding - * still apply). Use at low-frequency exact points like ledger snapshots. */ + * still apply). Use at low-frequency exact points like ledger/card snapshots. */ fresh?: boolean; } @@ -144,6 +162,33 @@ function extractCodexTokenCountUsage(entry: any): SessionTokenUsage | null { }; } +function extractCodexContextUsage(entry: any): SessionContextUsage | null { + if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') return null; + const info = entry.payload?.info; + const last = info?.last_token_usage; + if (!last || typeof last !== 'object') return null; + // Codex's native absolute gauge is last_token_usage.total_tokens. Cached + // input is already a subset of input and must never be added again. + const totalTokens = pickNum(last, ['total_tokens', 'totalTokens']); + const usedTokens = totalTokens > 0 + ? totalTokens + : pickNum(last, ['input_tokens', 'inputTokens']); + if (usedTokens <= 0) return null; + const windowTokens = pickNum(info, ['model_context_window', 'modelContextWindow']); + // This card reports raw Context Usage, so keep the percentage consistent + // with the displayed numerator/denominator. Codex's TUI separately exposes + // a "user-controllable remaining" meter that subtracts fixed baseline + // prompts/tools; that is a different metric and does not belong here. + const percentUsed = windowTokens > 0 + ? Math.round(Math.max(0, Math.min(1, usedTokens / windowTokens)) * 100) + : undefined; + return { + usedTokens, + ...(windowTokens > 0 ? { windowTokens } : {}), + ...(percentUsed !== undefined ? { percentUsed } : {}), + }; +} + interface TokenUsageAggregate { inputTokens: number; outputTokens: number; @@ -152,6 +197,7 @@ interface TokenUsageAggregate { model: string; turns: number; latestCodexUsage: SessionTokenUsage | null; + latestContextUsage: SessionContextUsage | null; } /** Per-CLI transcript dialect. Each kind only counts the events that dialect @@ -180,7 +226,16 @@ function usageKindForCli(cliId: SessionTokenUsageQuery['cliId']): UsageKind { } function newTokenUsageAggregate(): TokenUsageAggregate { - return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, model: '', turns: 0, latestCodexUsage: null }; + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + model: '', + turns: 0, + latestCodexUsage: null, + latestContextUsage: null, + }; } /** Claude Code / Seed: one JSONL line per content block; blocks of the same @@ -199,6 +254,15 @@ function foldClaudeLine(agg: TokenUsageAggregate, seenMessageIds: Set, e agg.outputTokens += num(u.output_tokens); agg.cacheReadTokens += num(u.cache_read_input_tokens); agg.cacheCreateTokens += num(u.cache_creation_input_tokens); + const contextTokens = + num(u.input_tokens) + + num(u.cache_read_input_tokens) + + num(u.cache_creation_input_tokens); + // Synthetic/empty assistant records around compaction must not erase the + // last native context measurement. + if (contextTokens > 0) { + agg.latestContextUsage = { usedTokens: contextTokens }; + } if (!agg.model && typeof msg.model === 'string') agg.model = msg.model; agg.turns++; } @@ -207,6 +271,9 @@ function foldClaudeLine(agg: TokenUsageAggregate, seenMessageIds: Set, e * the latest snapshot counts. The active model rides on turn_context / * session_meta payloads (latest wins — sessions can switch models). */ function foldCodexLine(agg: TokenUsageAggregate, entry: any): void { + const contextUsage = extractCodexContextUsage(entry); + if (contextUsage) agg.latestContextUsage = contextUsage; + const codexUsage = extractCodexTokenCountUsage(entry); if (codexUsage) { agg.latestCodexUsage = codexUsage; @@ -237,6 +304,9 @@ function foldCocoLine(agg: TokenUsageAggregate, entry: any): void { /** Cursor / TraeX / Antigravity: transcripts whose exact dialect is not yet * pinned down — keep the tolerant multi-shape extraction for them. */ function foldGenericLine(agg: TokenUsageAggregate, seenMessageIds: Set, entry: any): void { + const contextUsage = extractCodexContextUsage(entry); + if (contextUsage) agg.latestContextUsage = contextUsage; + const codexUsage = extractCodexTokenCountUsage(entry); if (codexUsage) { agg.latestCodexUsage = codexUsage; @@ -558,7 +628,7 @@ function readTokenUsageFromAidenCheckpoint(path: string): SessionTokenUsage | nu }; } -export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsage | null { +function readSessionUsage(q: SessionTokenUsageQuery): UsageReadResult | null { if (q.cliId === 'aiden') { const sid = q.cliSessionId || q.sessionId; const checkpointPath = cachedTranscriptPathLookup( @@ -571,11 +641,23 @@ export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsa { retryMiss: q.fresh, refreshHit: q.fresh }, ); if (!checkpointPath || !existsSync(checkpointPath)) return null; - return readSessionTokenUsageFile(checkpointPath, 'aiden', { fresh: q.fresh }); + return readSessionTokenAggregateCached(checkpointPath, 'aiden', { fresh: q.fresh }); } const resolved = resolveSessionTranscriptPath(q); if (!resolved || !existsSync(resolved.path)) return null; - return readSessionTokenUsageFile(resolved.path, usageKindForCli(q.cliId), { fresh: q.fresh }); + return readSessionTokenAggregateCached(resolved.path, usageKindForCli(q.cliId), { fresh: q.fresh }); +} + +export function getSessionTokenUsage(q: SessionTokenUsageQuery): SessionTokenUsage | null { + return readSessionUsage(q)?.result ?? null; +} + +export function getSessionUsageSnapshot(q: SessionTokenUsageQuery): SessionUsageSnapshot { + const read = readSessionUsage(q); + return { + context: read?.agg.latestContextUsage ?? null, + tokens: read?.result ?? null, + }; } export function formatNumber(n: number): string { diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 1603f2ab8..bfaef4686 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -10,6 +10,7 @@ import { V3_SESSION_RUN_MUTATION_ROUTE_PREFIX } from '../workflows/v3/session-re import { listenWithProbe } from '../utils/listen-with-probe.js'; import { dashboardSecretPath } from './dashboard-secret.js'; import * as sessionStore from '../services/session-store.js'; +import { cliSupportsNativeUsage } from '../services/transcript-resolver.js'; import * as asyncTriggerStore from '../services/async-trigger-store.js'; import { resolveAsyncTriggerState, decideAsyncOwnership } from '../services/async-trigger-state.js'; import * as scheduleStore from '../services/schedule-store.js'; @@ -66,7 +67,18 @@ import { readGlobalConfig } from '../global-config.js'; import { normalizeChatReplyMode, setChatReplyMode, type ChatReplyMode } from '../services/chat-reply-mode-store.js'; import * as chatFirstSeenStore from '../services/chat-first-seen-store.js'; import * as scheduler from './scheduler.js'; -import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker } from './worker-pool.js'; +import { + listActiveSessions, + findActiveBySessionId, + closeSession, + getActiveSessionsRegistry, + transferSession, + deliverWriteLinkCardToOwners, + forkWorker, + suspendWorker, + killWorker, + getDaemonReplyCardUsageSnapshot, +} from './worker-pool.js'; import { listOnlineDaemons } from '../utils/daemon-discovery.js'; import { isSessionStopped } from './session-liveness.js'; import { isSuspendableBackendType } from './persistent-backend.js'; @@ -131,7 +143,7 @@ import { getBotName, type SessionRow, } from './dashboard-rows.js'; -import { getBotBrand, getBot, getBotOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow } from '../bot-registry.js'; +import { getBotBrand, getBot, getBotOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow, type UsageDisplayMode } from '../bot-registry.js'; import { normalizeKanbanColumn, normalizeKanbanPosition, normalizeSessionTitle } from './session-board.js'; import { validateSlashInjection } from './slash-inject.js'; import { validateRoleLibraryPath } from './role-library.js'; @@ -555,6 +567,16 @@ ipcRoute('GET', '/api/sessions/:sessionId', (_req, res, params) => { jsonRes(res, 404, { error: 'not_found' }); }); +/** Low-frequency card-display read used by `botmux send`. Keeping the + * transcript reader and per-bot visibility decision in the resident daemon + * preserves its incremental cache and live config instead of making every + * short-lived CLI process rescan the Session or guess from sandboxed files. */ +ipcRoute('GET', '/api/sessions/:sessionId/usage', (_req, res, params) => { + const ds = findActiveBySessionId(params.sessionId); + if (!ds) return jsonRes(res, 404, { error: 'not_found' }); + jsonRes(res, 200, { usage: getDaemonReplyCardUsageSnapshot(ds) }); +}); + /** Canonical daemon-side close used by the dashboard and `botmux delete`. * Host callers authenticate with HMAC; a read-isolated CLI may close only its * exact live session with the rotating per-turn capability. */ @@ -2484,6 +2506,11 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { // disables the toggle wherever the worker would fail-close on it. readIsolationSupported: readIsolationEnforceable(cachedLarkAppId), backendType: backendTypeStore.getBotBackendType(cachedLarkAppId) ?? null, + usageDisplay: cardPrefs.usageDisplay, + // Whether this bot's CLI can produce native usage at all. When false the + // dashboard hides the usage-display control (offering it would be a knob + // that is always empty — the CLI has no resolvable transcript). + usageSupported: cliSupportsNativeUsage(cliId), disableStreamingCard: cardPrefs.disableStreamingCard, silentTurnReactions: cardPrefs.silentTurnReactions, codexAppCleanInput: cardPrefs.codexAppCleanInput, @@ -2527,6 +2554,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); let body: { + usageDisplay?: unknown; disableStreamingCard?: unknown; silentTurnReactions?: unknown; codexAppCleanInput?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown; botToBotSameDir?: unknown; autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown; @@ -2537,6 +2565,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } const patch: { + usageDisplay?: UsageDisplayMode; disableStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; botToBotSameDir?: boolean; autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; @@ -2544,6 +2573,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { docSubscribeDefaultMode?: 'mention-only' | 'all'; overloadAlert?: boolean; } = {}; + if (body.usageDisplay === 'streaming' || body.usageDisplay === 'footer' || body.usageDisplay === 'off') patch.usageDisplay = body.usageDisplay; if (typeof body.disableStreamingCard === 'boolean') patch.disableStreamingCard = body.disableStreamingCard; if (typeof body.botToBotSameDir === 'boolean') patch.botToBotSameDir = body.botToBotSameDir; if (typeof body.silentTurnReactions === 'boolean') patch.silentTurnReactions = body.silentTurnReactions; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 9250d6cb3..610c8de24 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -30,7 +30,13 @@ import { traeHome } from '../services/traex-paths.js'; import { botLocale, localeForBot, t as tr } from '../i18n/index.js'; import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js'; import { findUniqueClaudeSessionByCwd } from './session-discovery.js'; -import { buildMarkdownCard, buildContextualReplyCard, type LocalHomeLinkMode } from '../im/lark/md-card.js'; +import { + buildMarkdownCard, + buildContextualReplyCard, + type CardUsageSnapshot, + type LocalHomeLinkMode, +} from '../im/lark/md-card.js'; +import { getSessionUsageSnapshot } from './cost-calculator.js'; import { renderBrandTemplate } from '../im/lark/brand-template.js'; import { replyToDocComment, chunkCommentText, unsubscribeDocFile, removeCommentReaction } from '../im/lark/doc-comment.js'; import { listDocSubscriptionsForSession, removeDocSubscription } from '../services/doc-subs-store.js'; @@ -38,7 +44,7 @@ import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; import { sandboxEnabled } from '../adapters/backend/sandbox.js'; import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, killPersistentBackendTarget, probePersistentBackendTarget, managedTargetsForCliChange, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js'; -import { getBot, getAllBots, loadBotConfigs, resolveBrandLabel } from '../bot-registry.js'; +import { getBot, getAllBots, loadBotConfigs, resolveBrandLabel, resolveUsageDisplay } from '../bot-registry.js'; import { RestartCoordinator, type RestartObserver } from './restart-coordinator.js'; import { runtimeBuildIdentity } from '../utils/runtime-build-id.js'; @@ -67,6 +73,94 @@ function daemonCardLocalHomeLinkMode(ds: DaemonSession): LocalHomeLinkMode { : 'filesystem'; } +/** Read one frozen native-usage snapshot at the reply boundary. Card delivery + * remains best-effort even when a CLI has no supported transcript or a usage + * resolver fails. */ +export function getDaemonSessionUsageSnapshot( + ds: DaemonSession, + effectiveCliId?: CliId, + opts?: { fresh?: boolean }, +): CardUsageSnapshot { + try { + const resolvedCliId = effectiveCliId ?? ( + ds.session.cliId + ?? ds.session.adoptedFrom?.cliId + ?? ds.adoptedFrom?.cliId + ?? getBot(ds.larkAppId).config.cliId + ) as CliId; + return getSessionUsageSnapshot({ + cliId: resolvedCliId, + sessionId: ds.session.sessionId, + cliSessionId: + ds.session.cliSessionId + ?? ds.session.adoptedFrom?.sessionId + ?? ds.adoptedFrom?.sessionId, + cwd: + ds.workingDir + ?? ds.session.workingDir + ?? ds.session.adoptedFrom?.cwd + ?? ds.adoptedFrom?.cwd, + // Enable the BOT_HOME transcript fallback for CLI-data-redirected / + // sandboxed bots (same as the ledger and dashboard-row readers). Without + // it a read-isolated bot's transcript is invisible and the card shows no + // usage even though it exists under BOT_HOME. + larkAppId: ds.larkAppId ?? ds.session.larkAppId, + // Reply cards read at the exact turn boundary (fresh); the live streaming + // card refreshes on every status tick and rides the reader's reparse + // throttle instead (fresh:false) to stay off the disk. + fresh: opts?.fresh ?? true, + }); + } catch (error) { + logger.warn( + `[${ds.session.sessionId.slice(0, 8)}] Failed to read card usage snapshot: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + return { context: null, tokens: null }; + } +} + +/** Reply-card (final output / adopt preamble / local-turn) usage. Only the + * `'footer'` display mode surfaces usage here; `'streaming'` and `'off'` yield a + * concrete empty snapshot. Keeping the display decision out of the native usage + * reader leaves accounting and dashboard consumers intact; the concrete empty + * snapshot (rather than undefined) also freezes "hidden" over final-output + * retries. */ +export function getDaemonReplyCardUsageSnapshot( + ds: DaemonSession, + effectiveCliId?: CliId, +): CardUsageSnapshot { + try { + if (resolveUsageDisplay(ds.larkAppId) !== 'footer') { + return { context: null, tokens: null }; + } + } catch { + // Missing runtime config → default 'streaming' → no footer usage. + return { context: null, tokens: null }; + } + return getDaemonSessionUsageSnapshot(ds, effectiveCliId); +} + +/** Streaming-card usage. Only the `'streaming'` display mode (the default) + * surfaces usage in the live card body; `'footer'` and `'off'` yield empty. + * Returns a concrete empty snapshot on any config failure so the streaming + * renderer stays best-effort. `fresh` forces an exact read at meaningful + * boundaries (turn end / idle); intra-turn ticks leave it false to ride the + * reader's reparse throttle and stay off the disk. */ +export function getDaemonStreamingCardUsageSnapshot( + ds: DaemonSession, + effectiveCliId?: CliId, + opts?: { fresh?: boolean }, +): CardUsageSnapshot { + try { + if (resolveUsageDisplay(ds.larkAppId) !== 'streaming') { + return { context: null, tokens: null }; + } + } catch { + // Missing runtime config → default 'streaming' → show usage (best-effort). + } + return getDaemonSessionUsageSnapshot(ds, effectiveCliId, { fresh: opts?.fresh ?? false }); +} + import { normalizeBrand } from '../im/lark/lark-hosts.js'; import { dashboardEventBus } from './dashboard-events.js'; import { composeRowFromActive, composeRowFromClosed } from './dashboard-rows.js'; @@ -3196,6 +3290,7 @@ function setupWorkerHandlers( cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); // Mark POST in-flight so subsequent screen_updates are dropped, // not POSTed as duplicate cards. @@ -3248,6 +3343,11 @@ function setupWorkerHandlers( cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + // Turn end (→ idle) reads the exact latest usage; intra-turn status + // ticks ride the throttle. See getDaemonStreamingCardUsageSnapshot. + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { + fresh: ds.lastScreenStatus === 'idle', + }), ); scheduleCardPatch(ds, cardJson, msg.turnId); } @@ -3953,6 +4053,7 @@ function setupWorkerHandlers( locale: localeForBot(ds.larkAppId), workingDir: ds.workingDir, localHomeLinkMode: daemonCardLocalHomeLinkMode(ds), + usage: getDaemonReplyCardUsageSnapshot(ds, effectiveCliId), }); scopedReply(cardJson, 'interactive', msg.turnId).catch((err: any) => { logger.warn(`[${t}] Failed to deliver adopt_preamble to Lark: ${err.message}`); @@ -4131,7 +4232,9 @@ function deliverFinalOutput( msg: Extract, t: string, attempt: number, + frozenUsage?: CardUsageSnapshot, ): void { + let cardUsage = frozenUsage; const managedReceiver = !!ds.session.vcMeetingReceiver; // Wait Mode / HTTP Sync Override: // If this turn is being waited for by an HTTP webhook request, intercept the @@ -4323,6 +4426,7 @@ function deliverFinalOutput( : imOrigin?.replyTargetSenderOpenId ?? daemonCardFooterRecipientOpenId(ds, effectiveCliId); const localHomeLinkMode = daemonCardLocalHomeLinkMode(ds); + cardUsage ??= getDaemonReplyCardUsageSnapshot(ds, effectiveCliId); const cardJson = msg.kind === 'local-turn' || msg.kind === 'local-turn-headless' ? buildContextualReplyCard({ title: msg.kind === 'local-turn-headless' @@ -4336,6 +4440,7 @@ function deliverFinalOutput( locale: localeForBot(ds.larkAppId), workingDir: ds.workingDir, localHomeLinkMode, + usage: cardUsage, }) : buildMarkdownCard( safeAssistantText, @@ -4344,6 +4449,7 @@ function deliverFinalOutput( localeForBot(ds.larkAppId), ds.workingDir, localHomeLinkMode, + cardUsage, ); const proposedOutput = { @@ -4477,7 +4583,7 @@ function deliverFinalOutput( return; } logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`); - deliverFinalOutput(ds, msg, t, next); + deliverFinalOutput(ds, msg, t, next, cardUsage); } }, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0); } diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 4afe45a55..70e78b7e0 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -1,5 +1,6 @@ import { defaultSummaryRangePrefs, summaryRangeFromLegacyContentTriggers } from '../services/summary-range-store.js'; import { selectionKeyForBot } from '../setup/cli-selection.js'; +import { normalizeUsageDisplay } from '../bot-registry.js'; export interface DashboardBotDescriptor { larkAppId: string; @@ -55,6 +56,8 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: readIsolation: j?.readIsolation === true, readIsolationSupported: j?.readIsolationSupported === true, backendType: typeof j?.backendType === 'string' ? j.backendType : null, + usageDisplay: normalizeUsageDisplay(j ?? {}), + usageSupported: j?.usageSupported === true, disableStreamingCard: j?.disableStreamingCard === true, silentTurnReactions: j?.silentTurnReactions === true, codexAppCleanInput: j?.codexAppCleanInput === true, diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 767a9ddd1..aa06bdbb4 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -255,6 +255,7 @@ function sessionCapStateLabel(cap: number | null, tr: ReturnType): function patchCardPrefsFromBody(bot: BotDefaultsRow, body: any): BotDefaultsRow { return { ...bot, + usageDisplay: body.usageDisplay, disableStreamingCard: body.disableStreamingCard, silentTurnReactions: body.silentTurnReactions, codexAppCleanInput: body.codexAppCleanInput, @@ -1904,9 +1905,10 @@ function ProfileRoles(props: { appId: string }) { ); } -function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: CardPrefPatch): Promise }) { +export function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: CardPrefPatch): Promise }) { const tr = useT(); const { bot, putCardPref } = props; + const [usageDisplay, setUsageDisplay] = useState<'streaming' | 'footer' | 'off'>(bot.usageDisplay ?? 'streaming'); const [disableStreaming, setDisableStreaming] = useState(bot.disableStreamingCard === true); const [silentReactions, setSilentReactions] = useState(bot.silentTurnReactions === true); const [writableLink, setWritableLink] = useState(bot.writableTerminalLinkInCard === true); @@ -1915,28 +1917,64 @@ function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(patch: Ca const [busy, setBusy] = useState(null); useEffect(() => { + setUsageDisplay(bot.usageDisplay ?? 'streaming'); setDisableStreaming(bot.disableStreamingCard === true); setSilentReactions(bot.silentTurnReactions === true); setWritableLink(bot.writableTerminalLinkInCard === true); setPrivateCard(bot.privateCard === true); - }, [bot.disableStreamingCard, bot.privateCard, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); + }, [bot.disableStreamingCard, bot.privateCard, bot.usageDisplay, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); - async function savePatch(patch: CardPrefPatch, key: string): Promise { + async function savePatch(patch: CardPrefPatch, key: string, rollback?: () => void): Promise { setBusy(key); setStatus(null); try { const res = await putCardPref(patch); - setStatus(res.ok ? { text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true } : { text: `✗ ${responseErrorText(res)}` }); + if (res.ok) { + setStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true }); + } else { + rollback?.(); + setStatus({ text: `✗ ${responseErrorText(res)}` }); + } } catch (e: any) { + rollback?.(); setStatus({ text: `✗ ${caughtErrorText(e)}` }); } finally { setBusy(null); } } + const usageDisplayOptions: DropdownFieldOption<'streaming' | 'footer' | 'off'>[] = [ + { value: 'streaming', label: tr('botDefaults.usageDisplayStreaming') }, + { value: 'footer', label: tr('botDefaults.usageDisplayFooter') }, + { value: 'off', label: tr('botDefaults.usageDisplayOff') }, + ]; + return (

{tr('botDefaults.sectionCard')}

+ {bot.usageSupported === true && ( +
+
+ {tr('botDefaults.usageDisplay')} + { + const previous = usageDisplay; + setUsageDisplay(next); + void savePatch( + { usageDisplay: next }, + 'usage', + () => setUsageDisplay(previous), + ); + }} + /> +
+
+ )}
= { 'card.config.p2p.thread': '🧵 thread (separate session/DM)', 'card.config.p2p.chat': '💬 chat (continuous session, default)', 'config.label.disableStreamingCard': 'Disable live card', + 'config.label.usageDisplay': 'Usage display', 'config.label.silentTurnReactions': 'Disable status reactions', 'config.label.writableTerminalLinkInCard': 'Writable terminal in card', 'config.label.privateCard': 'Private snapshot card', @@ -1074,6 +1075,8 @@ export const messages: Record = { // Markdown / contextual reply card chrome 'card.you': 'You', 'card.sent_to': 'Sent to: ', + 'card.usage.context': 'Context', + 'card.usage.tokens': 'Tokens', // Adopt preamble card title 'card.adopt_last_round': '📜 Last exchange before /adopt', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e9e493450..72334ba75 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -397,6 +397,7 @@ export const messages: Record = { 'card.config.p2p.thread': '🧵 thread(每条 DM 独立会话)', 'card.config.p2p.chat': '💬 chat(连续单聊会话,默认)', 'config.label.disableStreamingCard': '关闭实时卡片', + 'config.label.usageDisplay': '用量显示位置', 'config.label.silentTurnReactions': '关闭状态 reaction', 'config.label.writableTerminalLinkInCard': '卡内嵌可写终端', 'config.label.privateCard': '私有快照卡', @@ -1077,6 +1078,8 @@ export const messages: Record = { // Markdown / contextual reply card chrome 'card.you': '你', 'card.sent_to': '发送给:', + 'card.usage.context': '上下文', + 'card.usage.tokens': 'Token', // Adopt preamble card title 'card.adopt_last_round': '📜 /adopt 前最后一轮', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index b5da3ed94..9a99d5fbb 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -6,6 +6,7 @@ import type { CodexAppThreadSummary } from '../../services/codex-app-threads.js' import type { DisplayMode, StreamStatus } from '../../types.js'; import type { CliUsageLimitState } from '../../utils/cli-usage-limit.js'; import { t, type Locale } from '../../i18n/index.js'; +import { cardUsageFooterSegment, type CardUsageSnapshot } from './md-card.js'; import { readGlobalConfig } from '../../global-config.js'; import type { ConfigCardData } from '../../services/bot-config-store.js'; import { isLocalCliOpenEnabled } from '../../services/local-cli-opener.js'; @@ -659,9 +660,9 @@ function streamStatusLabel(status: StreamStatus, usageLimit: CliUsageLimitState * by both {@link buildStreamingCard} and {@link buildPrivateSnapshotCard}. */ function pushStreamBody( elements: any[], - opts: { status: StreamStatus; usageLimit?: CliUsageLimitState; displayMode: DisplayMode; imageKey?: string; cliName: string; locale?: Locale }, + opts: { status: StreamStatus; usageLimit?: CliUsageLimitState; displayMode: DisplayMode; imageKey?: string; cliName: string; locale?: Locale; usage?: CardUsageSnapshot }, ): void { - const { status, usageLimit, displayMode, imageKey, cliName, locale } = opts; + const { status, usageLimit, displayMode, imageKey, cliName, locale, usage } = opts; if (status === 'limited' && usageLimit) { elements.push({ tag: 'markdown', @@ -679,6 +680,17 @@ function pushStreamBody( } elements.push({ tag: 'hr' }); } + // Native Context / Token usage line (grey, small) when this bot displays usage + // on the streaming card. Missing metrics are omitted independently by + // cardUsageFooterSegment; a fully-empty snapshot renders nothing. + const usageSeg = usage ? cardUsageFooterSegment(usage, locale) : null; + if (usageSeg) { + elements.push({ + tag: 'markdown', + text_size: 'notation_small_v2', + content: `${usageSeg}`, + }); + } } /** @@ -709,6 +721,7 @@ export function buildStreamingCard( usageLimit?: CliUsageLimitState, writableTerminalUrl?: string, localCliReady = false, + usage?: CardUsageSnapshot, ): string { const effectiveCliId = cliId ?? 'claude-code'; const cliName = getCliDisplayName(effectiveCliId); @@ -718,7 +731,7 @@ export function buildStreamingCard( const elements: any[] = []; // ── Output body (shared with the private snapshot card) ────────────────── - pushStreamBody(elements, { status, usageLimit, displayMode, imageKey, cliName, locale }); + pushStreamBody(elements, { status, usageLimit, displayMode, imageKey, cliName, locale, usage }); // ── Main control row: display toggle, mode toggle, terminal, manage ───── const headerActions: any[] = []; @@ -2002,4 +2015,3 @@ export function buildCodexAppThreadSelectCard(threads: CodexAppThreadSummary[], }; return JSON.stringify(card); } - diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index b41a2e724..f6658ce8c 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -10,7 +10,12 @@ import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js'; import { canOperate, canTalk } from './event-dispatcher.js'; import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js'; import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildTuiPromptResolvedCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js'; -import { findConfigField, applyConfigField, coerceConfigValue, getConfigCardData } from '../../services/bot-config-store.js'; +import { + findConfigField, + applyConfigField, + coerceConfigValue, + getConfigCardData, +} from '../../services/bot-config-store.js'; import { updateBotGrantPrefs } from '../../services/grant-prefs-store.js'; import { writeTeamRoleFile, deleteTeamRoleFile } from '../../core/role-resolver.js'; import { addChatGrant, addGlobalGrant } from '../../services/grant-store.js'; @@ -69,7 +74,7 @@ import { ttadkConfigModelChoices } from '../../setup/cli-selection.js'; import { logger } from '../../utils/logger.js'; 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, requestSessionRestart } from '../../core/worker-pool.js'; +import { forkWorker, sendWorkerInput, killWorker, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, getDaemonStreamingCardUsageSnapshot } 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'; @@ -1938,6 +1943,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: sessionCliId(ds) }), + getDaemonStreamingCardUsageSnapshot(ds, sessionCliId(ds)), ); scheduleCardPatch(ds, cardJson); } @@ -2268,6 +2274,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); updateMessage(ds.larkAppId, cardMessageId, cardJson).catch(err => logger.debug(`[${tag(ds)}] Failed to migrate unknown frozen card: ${err}`), @@ -2311,6 +2318,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); updateMessage(ds.larkAppId, frozen.messageId, cardJson).catch(err => logger.debug(`[${tag(ds)}] Failed to migrate frozen card: ${err}`), @@ -2352,6 +2360,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); if (cardMessageId && cardMessageId !== ds.streamCardId) { updateMessage(ds.larkAppId, cardMessageId, cardJson).catch(err => @@ -2418,6 +2427,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); if (cardMessageId && cardMessageId !== ds.streamCardId) { updateMessage(ds.larkAppId, cardMessageId, cardJson).catch(err => @@ -2459,6 +2469,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), + getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), ); try { return JSON.parse(cardJson); } catch { /* fall through */ } } diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts index a4b4ed5d5..003262947 100644 --- a/src/im/lark/md-card.ts +++ b/src/im/lark/md-card.ts @@ -26,12 +26,45 @@ import { resolve } from 'node:path'; import MarkdownIt from 'markdown-it'; import type Token from 'markdown-it/lib/token.mjs'; import { t, type Locale } from '../../i18n/index.js'; +import { + REPLY_CARD_FOOTER_ELEMENT_ID, + REPLY_CARD_FOOTER_MARKER, +} from './reply-card-footer-signature.js'; + +export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js'; const md = new MarkdownIt({ html: false, linkify: false, breaks: false }); const MAX_LOCAL_HOME_LINK_REPAIRS = 256; export type LocalHomeLinkMode = 'filesystem' | 'lexical' | 'disabled'; +/** Native usage facts rendered in a Bot reply-card footer. Context is the + * latest context-window measurement; tokens are cumulative for the Session. + * Missing facts are omitted independently and must never be estimated. */ +export interface CardUsageSnapshot { + context: { + usedTokens: number; + windowTokens?: number; + percentUsed?: number; + } | null; + tokens: { + in: number; + out: number; + } | null; +} + +export interface ReplyCardFooter { + /** Fully wrapped markdown content, reusable inside the voice-button row. */ + content: string; + /** Standalone footer element used by ordinary reply cards. */ + element: { + tag: 'markdown'; + element_id: typeof REPLY_CARD_FOOTER_ELEMENT_ID; + text_size: 'notation_small_v2'; + content: string; + }; +} + interface LocalHomeCandidate { id: number; start: number; @@ -232,13 +265,157 @@ export const DEFAULT_BRAND_LABEL = '[botmux](https://github.com/deepcoldy/botmux * `brandLabel` (see {@link resolveBrandLabel}): * • `undefined` (unset) → the default botmux link * • `''` / whitespace → `null` (brand suppressed) - * • any other string → returned verbatim (markdown allowed) + * • any other string → one trimmed line (markdown allowed) * Returning `null` lets callers drop the brand — and, when there's also no * recipient, the whole footer (HR included) — so an empty brand reads clean. */ export function brandFooterSegment(brand: string | undefined): string | null { if (brand === undefined) return DEFAULT_BRAND_LABEL; - return brand.trim() ? brand : null; + const normalized = brand + .trim() + .replace(/[ \t]*(?:\r\n?|\n|\u2028|\u2029)+[ \t]*/g, ' '); + return normalized || null; +} + +function compactTokenCount(value: number): string { + const units = [ + { threshold: 1_000_000_000, suffix: 'B' }, + { threshold: 1_000_000, suffix: 'M' }, + { threshold: 1_000, suffix: 'K' }, + ] as const; + let unitIndex = units.findIndex(candidate => value >= candidate.threshold); + if (unitIndex < 0) return Math.round(value).toString(); + let unit = units[unitIndex]; + let scaled = value / unit.threshold; + // Avoid boundary artifacts such as 1000K/1000M after one-decimal rounding. + if (unitIndex > 0 && Number(scaled.toFixed(1)) >= 1_000) { + unit = units[--unitIndex]; + scaled = value / unit.threshold; + } + return `${scaled.toFixed(1).replace(/\.0$/, '')}${unit.suffix}`; +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +/** Format usage as one footer segment shared by direct `botmux send` cards and + * daemon fallback cards. The caller supplies native facts; this module only + * formats them and never infers a context window or token count. Returns null + * when no valid native metric is available. */ +export function cardUsageFooterSegment(usage: CardUsageSnapshot, locale?: Locale): string | null { + const parts: string[] = []; + if (usage.context && isNonNegativeFinite(usage.context.usedTokens)) { + const used = compactTokenCount(usage.context.usedTokens); + const window = usage.context.windowTokens; + const windowSuffix = isNonNegativeFinite(window) && window > 0 + ? `/${compactTokenCount(window)}` + : ''; + const percentSuffix = isNonNegativeFinite(usage.context.percentUsed) + ? ` (${Math.min(100, Math.round(usage.context.percentUsed))}%)` + : ''; + const suffix = `${windowSuffix}${percentSuffix}`; + parts.push(`${t('card.usage.context', undefined, locale)} ${used}${suffix}`); + } + if (usage.tokens + && isNonNegativeFinite(usage.tokens.in) + && isNonNegativeFinite(usage.tokens.out)) { + parts.push( + `${t('card.usage.tokens', undefined, locale)} ` + + `↑${compactTokenCount(usage.tokens.in)} ↓${compactTokenCount(usage.tokens.out)}`, + ); + } + return parts.length > 0 ? parts.join(' · ') : null; +} + +/** Build the one canonical footer shared by all Bot Session reply cards. + * Ordering, i18n, the parser marker, grey styling, and recipient rendering live + * here so direct sends and daemon fallbacks cannot drift apart. */ +export function buildReplyCardFooter(opts: { + brand?: string; + recipientOpenIds?: readonly string[]; + usage?: CardUsageSnapshot; + locale?: Locale; +}): ReplyCardFooter | null { + const parts: string[] = []; + const brandSeg = brandFooterSegment(opts.brand); + if (brandSeg) parts.push(brandSeg); + if (opts.usage) { + const usageSeg = cardUsageFooterSegment(opts.usage, opts.locale); + if (usageSeg) parts.push(usageSeg); + } + const recipientOpenIds = [...new Set((opts.recipientOpenIds ?? []).filter(Boolean))]; + if (recipientOpenIds.length > 0) { + parts.push( + `${t('card.sent_to', undefined, opts.locale)}` + + recipientOpenIds.map(id => ``).join(' '), + ); + } + if (parts.length === 0) return null; + + // The first ordinary separator doubles as a visible, versioned marker. Lark + // preserves non-empty links in its simplified Format A, so custom/disabled + // brands remain identifiable without changing the rendered multi-part text. + // A one-part footer also gets the marker. The canonical repository URL alone + // is legitimate body content, so it must never double as proof of ownership. + const signedContent = parts.length > 1 + ? `${parts[0]} ${REPLY_CARD_FOOTER_MARKER} ${parts.slice(1).join(' · ')}` + : `${parts[0]} ${REPLY_CARD_FOOTER_MARKER}`; + const content = `${signedContent}`; + return { + content, + element: { + tag: 'markdown', + element_id: REPLY_CARD_FOOTER_ELEMENT_ID, + text_size: 'notation_small_v2', + content, + }, + }; +} + +/** Clone a caller-supplied schema-2 card and append the canonical reply + * footer. Returns null for cards without a v2 `body.elements` array or when a + * caller-owned element already occupies the globally unique footer id. */ +export function appendReplyCardFooterToV2Card( + card: Record, + opts: Parameters[0], +): Record | null { + const cloned = JSON.parse(JSON.stringify(card)) as Record; + if (cloned.schema !== '2.0') return null; + const body = cloned.body as { elements?: unknown } | undefined; + if (!body || !Array.isArray(body.elements)) return null; + const header = cloned.header as { + text_tag_list?: unknown; + i18n_text_tag_list?: unknown; + } | undefined; + const i18nHeaderTagLists = header?.i18n_text_tag_list + && typeof header.i18n_text_tag_list === 'object' + ? Object.values(header.i18n_text_tag_list as Record) + : []; + if ( + containsReplyCardFooterId(body.elements) + || containsReplyCardFooterId(header?.text_tag_list) + || containsReplyCardFooterId(i18nHeaderTagLists) + ) { + return null; + } + const footer = buildReplyCardFooter(opts); + if (footer) body.elements.push({ tag: 'hr' }, footer.element); + return cloned; +} + +function containsReplyCardFooterId(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsReplyCardFooterId); + if (!value || typeof value !== 'object') return false; + const record = value as Record; + if (record.element_id === REPLY_CARD_FOOTER_ELEMENT_ID) return true; + // Follow only documented component-child slots. Callback `value`, behavior + // payloads, and other arbitrary business JSON are deliberately outside the + // card component tree even when they contain tag/element_id-shaped fields. + return containsReplyCardFooterId(record.elements) + || containsReplyCardFooterId(record.columns) + || containsReplyCardFooterId(record.actions) + || containsReplyCardFooterId(record.extra); } /** Build a Feishu native `table` element from a `table_open … table_close` token slice. */ @@ -644,8 +821,8 @@ export function hasMarkdown(text: string): boolean { * * `brand` is the sending bot's configured `brandLabel` (see * {@link brandFooterSegment}): unset → default botmux link, `''` → brand - * suppressed, else custom. When brand and recipient are both absent the whole - * footer (HR included) is omitted. + * suppressed, else custom. When brand, usage, and recipient are all absent the + * whole footer (HR included) is omitted. */ export function buildMarkdownCard( md: string, @@ -654,20 +831,19 @@ export function buildMarkdownCard( locale?: Locale, workingDir?: string, localHomeLinkMode: LocalHomeLinkMode = 'filesystem', + usage?: CardUsageSnapshot, ): string { const elements = md ? buildCardBodyElements(md, workingDir, localHomeLinkMode) : []; - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(brand); - if (brandSeg) footerParts.push(brandSeg); - if (recipientOpenId) footerParts.push(`${t('card.sent_to', undefined, locale)}`); - // Empty brand + no recipient → no footer at all (skip the orphan HR too). - if (footerParts.length > 0) { + const footer = buildReplyCardFooter({ + brand, + recipientOpenIds: recipientOpenId ? [recipientOpenId] : [], + usage, + locale, + }); + // No brand, usage, or recipient → no footer at all (skip the orphan HR too). + if (footer) { elements.push({ tag: 'hr' }); - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${footerParts.join(' · ')}`, - }); + elements.push(footer.element); } return JSON.stringify({ schema: '2.0', @@ -710,6 +886,7 @@ export function buildContextualReplyCard(opts: { locale?: Locale; workingDir?: string; localHomeLinkMode?: LocalHomeLinkMode; + usage?: CardUsageSnapshot; }): string { const { title, @@ -721,6 +898,7 @@ export function buildContextualReplyCard(opts: { locale, workingDir, localHomeLinkMode = 'filesystem', + usage, } = opts; const elements: any[] = []; @@ -749,17 +927,15 @@ export function buildContextualReplyCard(opts: { : [{ tag: 'markdown', content: `*${t('common.empty_paren', undefined, locale)}*` }]; for (const el of bodyElements) elements.push(el); - const footerParts: string[] = []; - const brandSeg = brandFooterSegment(brand); - if (brandSeg) footerParts.push(brandSeg); - if (recipientOpenId) footerParts.push(`${t('card.sent_to', undefined, locale)}`); - if (footerParts.length > 0) { + const footer = buildReplyCardFooter({ + brand, + recipientOpenIds: recipientOpenId ? [recipientOpenId] : [], + usage, + locale, + }); + if (footer) { elements.push({ tag: 'hr' }); - elements.push({ - tag: 'markdown', - text_size: 'notation_small_v2', - content: `${footerParts.join(' · ')}`, - }); + elements.push(footer.element); } return JSON.stringify({ diff --git a/src/im/lark/message-parser.ts b/src/im/lark/message-parser.ts index 30f03fecd..40c991610 100644 --- a/src/im/lark/message-parser.ts +++ b/src/im/lark/message-parser.ts @@ -1,6 +1,9 @@ import type { LarkMessage, LarkMention } from '../../types.js'; import { getMessageDetail } from './client.js'; import { logger } from '../../utils/logger.js'; +import { + REPLY_CARD_FOOTER_ELEMENT_ID, +} from './reply-card-footer-signature.js'; // Event data structure from WSClient im.message.receive_v1 // sender is at data top-level, NOT inside data.message @@ -613,23 +616,107 @@ function extractTextContent(msgType: string, rawContent: string, mentions?: RawE } /** - * botmux-generated card footer signature. Every card `botmux send` / - * buildMarkdownCard emits ends with a small grey note linking back to the repo - * (`[botmux](https://github.com/deepcoldy/botmux)`, optionally `· 发送给:@owner`). - * That footer is human-facing chrome — when another bot receives the card it - * must NOT leak into the receiving bot's prompt (it surfaces as a stray - * `botmux` block and duplicates mention info). Both - * Lark render formats carry the canonical repo URL on that footer line (Format B - * as the markdown link target, Format A as the ``), so the URL is a - * reliable, format-agnostic marker. Anchored on the full repo URL so genuine - * body text merely mentioning "botmux" survives. Known, accepted trade-off: a - * card whose own body puts this exact repo URL on a line would lose that line - * too — vanishingly rare versus the value of a simple format-agnostic anchor. + * botmux-generated reply-card footer signature. New cards carry both a visible + * versioned link marker and an exact element id. The canonical repository URL + * is NOT a signature: it is valid body content. For pre-signature cards we + * recognize only the exact old default-brand + recipient chrome shape; a + * default-brand-only old card is intentionally preserved because it is + * indistinguishable from an ordinary repository link. */ -const BOTMUX_FOOTER_MARKER = 'github.com/deepcoldy/botmux'; +const LEGACY_DEFAULT_FOOTER_URL = 'https://github.com/deepcoldy/botmux'; +const FOOTER_MARKER_HASHES = new Set(['#reply-card-footer', '#reply-card-footer-v1']); -function isBotmuxFooterLine(line: string): boolean { - return line.includes(BOTMUX_FOOTER_MARKER); +function isBotmuxFooterMarkerUrl(value: unknown): boolean { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname === 'github.com' + && url.port === '' + && url.username === '' + && url.password === '' + && url.search === '' + && decodeURIComponent(url.pathname) === '/deepcoldy/botmux' + && FOOTER_MARKER_HASHES.has(url.hash); + } catch { + return false; + } +} + +function isBotmuxFooterMarkerText(value: unknown): boolean { + return value === '·' || value === '\u200B'; +} + +function isBotmuxFooterMarkerAnchor(href: unknown, text: unknown): boolean { + return isBotmuxFooterMarkerText(text) && isBotmuxFooterMarkerUrl(href); +} + +function greyFontInner(line: string): string | null { + const font = /^\s*]*)>([\s\S]*?)<\/font>\s*$/i.exec(line); + if (!font || !/\bcolor\s*=\s*(?:"grey"|'grey'|grey)(?:\s|$)/i.test(font[1])) return null; + return font[2]; +} + +function hasExactMarkerMarkdown(value: string): boolean { + // Match only the protocol's reserved anchor texts. A generic Markdown-link + // regex can start at an unmatched `[` inside a custom brand and swallow the + // later marker before it gets a chance to match. + for (const match of value.matchAll(/\[(·|\u200B)\]\(([^)\s]+)\)/gu)) { + if (isBotmuxFooterMarkerAnchor(match[2], match[1])) return true; + } + return false; +} + +function isSignedFormatBFooterLine(line: string): boolean { + const inner = greyFontInner(line); + return inner !== null && hasExactMarkerMarkdown(inner); +} + +/** Strict compatibility recognizer for Format A cards emitted before the + * versioned marker existed. Recipient chrome is required; without it, the old + * default-brand-only footer is indistinguishable from a real repository link. */ +function isMeaningfulFormatANode(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const value = node as any; + if (value.tag === 'text') return typeof value.text === 'string' && value.text.trim().length > 0; + return true; +} + +function isLegacyFormatAFooterParagraph(paragraph: unknown[]): boolean { + const nodes = paragraph.filter(isMeaningfulFormatANode) as any[]; + const first = nodes[0]; + if ( + first?.tag !== 'a' + || first.href !== LEGACY_DEFAULT_FOOTER_URL + || typeof first.text !== 'string' + || first.text.trim().toLowerCase() !== 'botmux' + ) { + return false; + } + + let label = ''; + let sawAt = false; + for (const node of nodes.slice(1)) { + if (node.tag === 'at') { + sawAt = true; + continue; + } + if (node.tag !== 'text' || typeof node.text !== 'string') return false; + if (sawAt && node.text.trim() !== '') return false; + label += node.text; + } + return sawAt && /^\s*·\s*(?:发送给:|Sent to:)\s*$/i.test(label); +} + +/** Strict compatibility recognizer for an old original-format footer line. + * Grey styling plus exact default brand, locale label, and one or more native + * mentions are all required; styling or the canonical URL alone proves + * nothing. */ +function isLegacyFormatBFooterLine(line: string): boolean { + const inner = greyFontInner(line); + if (inner === null) return false; + return /^\s*\[botmux\]\(https:\/\/github\.com\/deepcoldy\/botmux\)\s*·\s*(?:发送给:|Sent to:)\s*(?:]+)\s*><\/at>\s*)+$/i + .test(inner); } /** @@ -677,13 +764,32 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): if (isApiFormat) { // Format A: [[{tag:"text",text:"..."}, {tag:"img",...}, {tag:"button",...}], ...] - for (const paragraph of rootElements) { + let lastMeaningfulParagraphIndex = -1; + for (let i = rootElements.length - 1; i >= 0; i--) { + const candidate = rootElements[i]; + if (Array.isArray(candidate) && candidate.some(isMeaningfulFormatANode)) { + lastMeaningfulParagraphIndex = i; + break; + } + } + for (let paragraphIndex = 0; paragraphIndex < rootElements.length; paragraphIndex++) { + const paragraph = rootElements[paragraphIndex]; if (!Array.isArray(paragraph)) continue; + if ( + paragraphIndex === lastMeaningfulParagraphIndex + && isLegacyFormatAFooterParagraph(paragraph) + ) { + continue; + } const textNodes: string[] = []; const buttons: string[] = []; + let hasFooterProof = false; for (const node of paragraph) { if (node.tag === 'text') { if (node.text) textNodes.push(node.text); } else if (node.tag === 'a') { + if (isBotmuxFooterMarkerAnchor(node.href, node.text)) { + hasFooterProof = true; + } // Keep the href so links survive — Format A separates text/href, // and dropping href loses real content (规则配置/详情/Trace 链接). // Bare long URLs (e.g. Argos Kibana 处理建议) come back with @@ -725,7 +831,7 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): } } const line = textNodes.join('').trim(); - if (line) parts.push(line); + if (line && !hasFooterProof) parts.push(line); if (buttons.length) parts.push(buttons.join(' ')); } } else { @@ -738,11 +844,30 @@ export function extractCardContent(rawContent: string, numberer?: ImgNumberer): // Drop the botmux footer chrome so a receiving bot's prompt isn't polluted // by the grey `botmux` badge / `发送给:@owner` line. Line-level (not // part-level) so a footer never takes adjacent real content with it. - const cleaned = parts - .join('\n') - .split('\n') - .filter(line => !isBotmuxFooterLine(line)) - .join('\n'); + // Stable legacy markers may occur inside a multiline element, so remove + // only their exact line. Marker-less footer-shaped text is deliberately + // preserved: an arbitrary custom brand is indistinguishable from user data. + let visibleParts = parts + .map(part => part + .split('\n') + .filter(line => !isSignedFormatBFooterLine(line)) + .join('\n')) + .filter(part => !!part.trim()); + // Marker-less legacy compatibility is intentionally tail-only. Old + // botmux footers were always last; applying this heuristic in the middle + // of a foreign card would turn a footer-shaped body note into data loss. + if (visibleParts.length > 0) { + const lastIndex = visibleParts.length - 1; + const lines = visibleParts[lastIndex].split('\n'); + let lastLine = lines.length - 1; + while (lastLine >= 0 && !lines[lastLine].trim()) lastLine--; + if (lastLine >= 0 && isLegacyFormatBFooterLine(lines[lastLine])) { + lines.splice(lastLine, 1); + visibleParts[lastIndex] = lines.join('\n'); + visibleParts = visibleParts.filter(part => !!part.trim()); + } + } + const cleaned = visibleParts.join('\n'); return cleaned || '[卡片]'; } catch { return '[卡片]'; @@ -961,16 +1086,17 @@ function extractElementText(el: any, parts: string[], imgLabel: (key: string) => const tag = el.tag; - // botmux card footer: the only element rendered as a small grey notation - // (text_size 'notation_small_v2' + a grey wrapper). Drop it - // structurally — brand-agnostic, so a peer bot's *custom* brandLabel footer - // is stripped from cross-bot / quote / history prompts without us needing to - // know its label (the receiving bot can't see the sender's config). The - // repo-URL line filter below still covers the default brand in the simplified - // Format A representation, which carries no text_size. - if ((tag === 'markdown' || tag === 'div' || tag === 'plain_text') && el.text_size === 'notation_small_v2') { - const c = el.text?.content ?? el.content ?? ''; - if (/color=['"]grey['"]/i.test(c)) return; + // The public element id alone is not ownership proof: third-party cards may + // collide with it. New botmux footers carry all four invariants together. + const elementText = el.text?.content ?? el.content; + if ( + el.element_id === REPLY_CARD_FOOTER_ELEMENT_ID + && tag === 'markdown' + && el.text_size === 'notation_small_v2' + && typeof elementText === 'string' + && isSignedFormatBFooterLine(elementText) + ) { + return; } // div / markdown / plain_text blocks diff --git a/src/im/lark/reply-card-footer-signature.ts b/src/im/lark/reply-card-footer-signature.ts new file mode 100644 index 000000000..24e11f5d9 --- /dev/null +++ b/src/im/lark/reply-card-footer-signature.ts @@ -0,0 +1,8 @@ +/** Versioned identity shared by reply-card producers and the card parser. + * The marker uses visible link text because Lark may discard zero-width links + * while simplifying a card into Format A. */ +export const REPLY_CARD_FOOTER_ELEMENT_ID = 'botmux_reply_footer'; +export const REPLY_CARD_FOOTER_MARKER_URL = + 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1'; +export const REPLY_CARD_FOOTER_MARKER = + `[·](${REPLY_CARD_FOOTER_MARKER_URL})`; diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index e59d4be84..31d7f3e00 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -69,6 +69,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'skillInjection', configKey: 'skillInjection', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['global', 'prompt', 'off'], hint: 'botmux skills 注入方式(仅影响 codex/gemini 等全局 skills 目录的 CLI):prompt=注入会话不落全局盘(默认)|global=装进 CLI 全局目录(会被独立 CLI 看到)|off=只留提示+botmux --help;切到/离开 global 需重启 daemon 才完全生效;unset 回机器级默认' }, { key: 'defaultWorkingDir', configKey: 'defaultWorkingDir', kind: 'dir', effect: 'next-session', clearable: true, hint: '新话题默认工作目录(跳过仓库选择卡片)' }, { key: 'brandLabel', configKey: 'brandLabel', kind: 'string', effect: 'immediate', clearable: true, hint: '卡片页脚品牌文案;unset 回默认 botmux 链接' }, + { key: 'usageDisplay', configKey: 'usageDisplay', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['streaming', 'footer', 'off'], hint: '用量显示位置:streaming=流式卡正文(默认)|footer=回复卡页脚|off=不显示' }, { key: 'autoStartPrompt', configKey: 'autoStartOnGroupJoinPrompt', kind: 'string', effect: 'immediate', clearable: true, hint: '被拉进新群主动开工的首轮 prompt(配合 autoStartOnGroupJoin)' }, { key: 'allowedUsers', configKey: 'allowedUsers', kind: 'allowedUsers', effect: 'immediate', clearable: false, hint: '管理员名单(邮箱/on_/ou_,逗号或空格分隔);改后需加 确认' }, { key: 'skills', configKey: 'skills', kind: 'json', effect: 'next-session', clearable: true, hint: 'bot 级 skill policy JSON;unset 回底层 CLI 默认行为' }, diff --git a/src/services/card-prefs-store.ts b/src/services/card-prefs-store.ts index 77e306221..27cde3872 100644 --- a/src/services/card-prefs-store.ts +++ b/src/services/card-prefs-store.ts @@ -4,7 +4,11 @@ * in-memory registry sync so the daemon's own card builders pick up the change * without a restart. * - * Three independent toggles: + * Per-bot card and related session preferences: + * • usageDisplay — where to show native Context / Token usage: + * 'streaming' (default) = live streaming card + * body, 'footer' = ordinary reply-card footer, + * 'off' = nowhere * • disableStreamingCard — suppress the live streaming session card * • silentTurnReactions — in card-off sessions, also drop the ✋→✅ * lightweight status reactions on the trigger @@ -19,10 +23,20 @@ * (see chat-reply-mode-store). Default 'chat'. */ import { rmwBotEntry } from './config-store.js'; -import { getBot, type ChatReplyMode } from '../bot-registry.js'; +import { + getBot, + normalizeUsageDisplay, + DEFAULT_USAGE_DISPLAY, + type ChatReplyMode, + type UsageDisplayMode, +} from '../bot-registry.js'; import { logger } from '../utils/logger.js'; export interface BotCardPrefs { + /** Where to show native Context / Token usage: + * 'streaming' (default) = live streaming card body, 'footer' = ordinary + * reply-card footer, 'off' = nowhere. */ + usageDisplay: UsageDisplayMode; disableStreamingCard: boolean; silentTurnReactions: boolean; /** Experimental Codex App presentation mode. Default false preserves the @@ -53,11 +67,13 @@ export interface BotCardPrefs { docSubscribeDefaultMode: 'mention-only' | 'all'; } -/** Current card prefs for a bot (booleans default false, prompt defaults '' when unset). */ +/** Current card prefs for a bot (`usageDisplay` defaults to 'streaming'; + * `botToBotSameDir` defaults true; other booleans default false). */ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { try { const c = getBot(larkAppId).config; return { + usageDisplay: normalizeUsageDisplay(c), disableStreamingCard: c.disableStreamingCard === true, silentTurnReactions: c.silentTurnReactions === true, codexAppCleanInput: c.codexAppCleanInput === true, @@ -75,6 +91,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { }; } catch { return { + usageDisplay: DEFAULT_USAGE_DISPLAY, disableStreamingCard: false, silentTurnReactions: false, codexAppCleanInput: false, @@ -143,8 +160,16 @@ export async function updateBotCardPrefs( if (val === 'all') entry[key] = 'all'; else delete entry[key]; }; + // 用量显示位置:只存非默认模式;'streaming'(默认)删键保持 bots.json 干净 + // (absent === 'streaming')。 + const applyUsageDisplay = (entry: any, key: keyof BotCardPrefs, val: UsageDisplayMode | undefined) => { + if (val === undefined) return; + if (val === 'footer' || val === 'off') entry[key] = val; + else delete entry[key]; + }; const r = await rmwBotEntry(larkAppId, (entry) => { + applyUsageDisplay(entry, 'usageDisplay', patch.usageDisplay); apply(entry, 'disableStreamingCard', patch.disableStreamingCard); apply(entry, 'silentTurnReactions', patch.silentTurnReactions); apply(entry, 'codexAppCleanInput', patch.codexAppCleanInput); @@ -161,6 +186,7 @@ export async function updateBotCardPrefs( return { write: true, result: { + usageDisplay: normalizeUsageDisplay(entry), disableStreamingCard: entry.disableStreamingCard === true, silentTurnReactions: entry.silentTurnReactions === true, codexAppCleanInput: entry.codexAppCleanInput === true, @@ -184,6 +210,12 @@ export async function updateBotCardPrefs( if (!r.ok) return { ok: false, reason: r.reason }; // Sync in-memory config so live card builders / routing react without a restart. + if (patch.usageDisplay !== undefined) { + // Store only a non-default mode; 'streaming' (default) clears the key. + bot.config.usageDisplay = (patch.usageDisplay && patch.usageDisplay !== DEFAULT_USAGE_DISPLAY) + ? patch.usageDisplay + : undefined; + } if (patch.disableStreamingCard !== undefined) { bot.config.disableStreamingCard = patch.disableStreamingCard || undefined; } @@ -229,7 +261,8 @@ export async function updateBotCardPrefs( bot.config.docSubscribeDefaultMode = patch.docSubscribeDefaultMode === 'all' ? 'all' : undefined; } logger.info( - `[card-prefs:${larkAppId}] disableStreamingCard=${r.result.disableStreamingCard} ` + + `[card-prefs:${larkAppId}] usageDisplay=${r.result.usageDisplay} ` + + `disableStreamingCard=${r.result.disableStreamingCard} ` + `silentTurnReactions=${r.result.silentTurnReactions} ` + `codexAppCleanInput=${r.result.codexAppCleanInput} ` + `writableTerminalLinkInCard=${r.result.writableTerminalLinkInCard} privateCard=${r.result.privateCard} ` + diff --git a/src/services/codex-transcript.ts b/src/services/codex-transcript.ts index 8547740f4..2e9b289b4 100644 --- a/src/services/codex-transcript.ts +++ b/src/services/codex-transcript.ts @@ -24,13 +24,28 @@ * * Pure I/O. Attribution belongs in CodexBridgeQueue. */ -import { existsSync, statSync, openSync, readSync, closeSync, readdirSync, readlinkSync } from 'node:fs'; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + opendirSync, + readdirSync, + readlinkSync, + readSync, + statSync, + type Dirent, +} from 'node:fs'; import { execSync } from 'node:child_process'; import { platform } from 'node:os'; import { join } from 'node:path'; import { codexHistoryPath, codexSessionsRoot } from './codex-paths.js'; const IS_LINUX = platform() === 'linux'; +const UNTRUSTED_SESSION_SCAN_MAX_DEPTH = 3; +const UNTRUSTED_SESSION_SCAN_MAX_ENTRIES = 50_000; /** Extract the cliSessionId encoded in a rollout filename. Codex's session * id is UUID-shaped (8-4-4-4-12 hex), which lets us anchor the regex on @@ -189,24 +204,72 @@ export interface CodexDrainResult { * `rollout--.jsonl`, so a suffix match is unambiguous. The * directory tree is small (year/month/day) — a one-shot recursive scan * is cheap enough that we don't bother caching. */ -export function findCodexRolloutBySessionId(cliSessionId: string): string | undefined { - const sessionsRoot = codexSessionsRoot(); - if (!cliSessionId || !existsSync(sessionsRoot)) return undefined; +export function findCodexRolloutBySessionId( + cliSessionId: string, + opts?: { codexHome?: string; noFollow?: boolean }, +): string | undefined { + const sessionsRoot = opts?.codexHome + ? join(opts.codexHome, 'sessions') + : codexSessionsRoot(); + if (!cliSessionId) return undefined; + try { + // BOT_HOME is untrusted and requires no-follow roots. A normal CODEX_HOME + // remains compatible with legitimate user-managed symlinks. + if (opts?.noFollow && opts.codexHome && !lstatSync(opts.codexHome).isDirectory()) return undefined; + const rootStat = opts?.noFollow ? lstatSync(sessionsRoot) : statSync(sessionsRoot); + if (!rootStat.isDirectory()) return undefined; + } catch { + return undefined; + } const suffix = `-${cliSessionId}.jsonl`; - const stack: string[] = [sessionsRoot]; + const stack: Array<{ dir: string; depth: number }> = [{ dir: sessionsRoot, depth: 0 }]; + let visitedEntries = 0; while (stack.length > 0) { - const dir = stack.pop()!; - let entries: string[]; - try { entries = readdirSync(dir); } catch { continue; } - for (const name of entries) { - const full = join(dir, name); - let st: ReturnType; - try { st = statSync(full); } catch { continue; } - if (st.isDirectory()) { - stack.push(full); - } else if (st.isFile() && name.endsWith(suffix)) { - return full; + const { dir, depth } = stack.pop()!; + try { + const dirStat = opts?.noFollow ? lstatSync(dir) : statSync(dir); + if (!dirStat.isDirectory()) continue; + } catch { + continue; + } + let directory: ReturnType; + try { directory = opendirSync(dir); } catch { continue; } + try { + let entry: Dirent | null; + while ((entry = directory.readSync()) !== null) { + visitedEntries++; + if (opts?.noFollow && visitedEntries > UNTRUSTED_SESSION_SCAN_MAX_ENTRIES) { + return undefined; + } + const full = join(dir, entry.name); + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + // Some filesystems report DT_UNKNOWN. Resolve those with the same trust + // policy, but never stat through a known symlink in untrusted BOT_HOME. + if (!isDirectory && !isFile && !entry.isSymbolicLink()) { + try { + const stat = opts?.noFollow ? lstatSync(full) : statSync(full); + isDirectory = stat.isDirectory(); + isFile = stat.isFile(); + } catch { + continue; + } + } + if (isDirectory) { + if (!opts?.noFollow || depth < UNTRUSTED_SESSION_SCAN_MAX_DEPTH) { + stack.push({ dir: full, depth: depth + 1 }); + } + } else if (isFile && entry.name.endsWith(suffix)) { + try { + const fileStat = opts?.noFollow ? lstatSync(full) : statSync(full); + if (fileStat.isFile()) return full; + } catch { + continue; + } + } } + } finally { + try { directory.closeSync(); } catch { /* already closed */ } } } return undefined; @@ -228,23 +291,34 @@ const HISTORY_TAIL_BYTES = 4 * 1024 * 1024; * between the two. Only the trailing `maxTailBytes` of the file is scanned. */ export function findCodexSessionIdByBotmuxSessionId( botmuxSessionId: string, - opts?: { maxTailBytes?: number }, + opts?: { maxTailBytes?: number; codexHome?: string; noFollow?: boolean }, ): string | undefined { if (!botmuxSessionId) return undefined; - const historyPath = codexHistoryPath(); - if (!existsSync(historyPath)) return undefined; + const historyPath = opts?.codexHome + ? join(opts.codexHome, 'history.jsonl') + : codexHistoryPath(); + let fd: number | undefined; try { - const size = statSync(historyPath).size; + // O_NOFOLLOW rejects a symlink swapped in after lstat; O_NONBLOCK keeps a + // malicious FIFO from blocking the daemon. fstat then accepts only regular + // files. (O_NOFOLLOW is available on the supported Linux/macOS targets.) + if (opts?.noFollow && opts.codexHome && !lstatSync(opts.codexHome).isDirectory()) return undefined; + const pathStat = opts?.noFollow ? lstatSync(historyPath) : statSync(historyPath); + if (!pathStat.isFile()) return undefined; + fd = openSync( + historyPath, + constants.O_RDONLY + | (opts?.noFollow ? (constants.O_NOFOLLOW ?? 0) : 0) + | (constants.O_NONBLOCK ?? 0), + ); + const opened = fstatSync(fd); + if (!opened.isFile()) return undefined; + const size = opened.size; const maxTailBytes = Math.max(1, opts?.maxTailBytes ?? HISTORY_TAIL_BYTES); const start = Math.max(0, size - maxTailBytes); const length = size - start; - const fd = openSync(historyPath, 'r'); const buf = Buffer.alloc(length); - try { - readSync(fd, buf, 0, length, start); - } finally { - closeSync(fd); - } + readSync(fd, buf, 0, length, start); let text = buf.toString('utf8'); if (start > 0) { // The window almost certainly opens mid-line — drop the partial line. @@ -267,6 +341,10 @@ export function findCodexSessionIdByBotmuxSessionId( } } catch { return undefined; + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* already closed / invalid fd */ } + } } return undefined; } diff --git a/src/services/jsonl-cursor.ts b/src/services/jsonl-cursor.ts index c64a4da78..b25b9f393 100644 --- a/src/services/jsonl-cursor.ts +++ b/src/services/jsonl-cursor.ts @@ -1,4 +1,12 @@ -import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs'; +import { + closeSync, + constants, + existsSync, + fstatSync, + openSync, + readSync, + statSync, +} from 'node:fs'; import { StringDecoder } from 'node:string_decoder'; export interface JsonlCursor { @@ -32,7 +40,13 @@ export function scanJsonlFromOffset(path: string, fromOffset: number, opts: Json const buf = Buffer.alloc(chunkSize); try { - fd = openSync(path, 'r'); + // O_NONBLOCK prevents an untrusted transcript path swapped to a FIFO from + // hanging the daemon. Validate the opened fd (rather than only the path) + // so directories/devices and the stat→open replacement window fail closed. + fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)); + if (!fstatSync(fd).isFile()) { + throw new Error(`JSONL source is not a regular file: ${path}`); + } while (true) { const remaining = endOffset === undefined ? chunkSize : endOffset - nextReadOffset; if (remaining <= 0) break; diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index 4b748bd41..9082a623b 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -1,11 +1,12 @@ -import { existsSync, realpathSync, statSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { existsSync, lstatSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { homedir } from 'node:os'; import type { CliId } from '../adapters/cli/types.js'; import { botHomePath } from '../adapters/cli/read-isolation.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { expandHome } from '../core/working-dir.js'; import { findCodexRolloutBySessionId, findCodexSessionIdByBotmuxSessionId } from './codex-transcript.js'; +import { codexHome as configuredCodexHome } from './codex-paths.js'; import { cocoEventsPathForSession } from './coco-transcript.js'; import { findCursorTranscriptByChatId } from './cursor-transcript.js'; import { findTraexRolloutBySessionId } from './traex-transcript.js'; @@ -78,7 +79,12 @@ function realCwd(cwd: string): string { try { return realpathSync(expanded); } catch { return resolve(expanded); } } -export function getClaudeSessionJsonlPath(sessionId: string, cwd: string, dataDir: string): string | null { +export function getClaudeSessionJsonlPath( + sessionId: string, + cwd: string, + dataDir: string, + opts?: { noFollow?: boolean }, +): string | null { // Claude stores sessions at ~/.claude/projects//.jsonl // where project-key = the REALPATH of cwd with non [A-Za-z0-9-] chars → '-'. // Resolve symlinks (realCwd), NOT just resolve(): under a symlinked cwd a @@ -86,8 +92,16 @@ export function getClaudeSessionJsonlPath(sessionId: string, cwd: string, dataDi // found and the usage ledger silently writes no delta (claude-code.ts's // realpathCwd already does this for the idle bridge — this path was the laggard). const projectKey = realCwd(cwd).replace(/[^A-Za-z0-9-]/g, '-'); - const jsonlPath = join(dataDir, 'projects', projectKey, `${sessionId}.jsonl`); - return existsSync(jsonlPath) ? jsonlPath : null; + const projectsDir = join(dataDir, 'projects'); + const projectDir = join(projectsDir, projectKey); + const jsonlPath = join(projectDir, `${sessionId}.jsonl`); + if (!opts?.noFollow) return existsSync(jsonlPath) ? jsonlPath : null; + return isDirectoryNoFollow(dataDir) + && isDirectoryNoFollow(projectsDir) + && isDirectoryNoFollow(projectDir) + && regularFileMtime(jsonlPath) !== null + ? jsonlPath + : null; } /** Resolve a Claude-family fork's (seed / relay) data root EXACTLY as the worker @@ -101,22 +115,26 @@ function claudeForkDataDir(cliId: 'seed' | 'relay'): string { return dir; } -/** The redirected Claude data dir of a sandboxed bot: `/bots//claude`. - * Sandboxed bots with CLI-data redirect get CLAUDE_CONFIG_DIR pointed here by the - * worker, so their transcripts never appear under the global data dir and every - * daemon-side reader (dashboard token column, usage ledger, insight) must fall - * back to this dir on a global miss. botmuxHome is derived exactly like the - * worker derives BOT_HOME (`dirname(SESSION_DATA_DIR)`); no SESSION_DATA_DIR - * means no redirect ever happened, so no fallback. Deliberately existence-driven - * rather than re-deriving the redirect decision (sandbox × adapter capability × - * wrapper): probing global-then-BOT_HOME is correct in every combination and - * can't drift from worker.ts. */ -function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { +/** Resolve one CLI data root redirected beneath a sandboxed bot's BOT_HOME. + * botmuxHome is derived exactly like worker.ts (`dirname(SESSION_DATA_DIR)`); + * no SESSION_DATA_DIR means no redirect ever happened, so there is no fallback. + * Deliberately probe by existence rather than re-deriving the redirect decision + * (sandbox × adapter capability × wrapper), which could drift from the worker. */ +function botHomeCliDataDir( + larkAppId: string | undefined, + cliDirName: 'claude' | 'codex', +): string | null { if (!larkAppId) return null; const sessionDataDir = process.env.SESSION_DATA_DIR; if (!sessionDataDir) return null; try { - return join(botHomePath(dirname(sessionDataDir), larkAppId), 'claude'); + const botHome = botHomePath(dirname(sessionDataDir), larkAppId); + const cliDataDir = join(botHome, cliDirName); + // BOT_HOME is an agent-writable mount. Reject a replaced app root or CLI + // root before any descendant lookup; checking only the final transcript + // component would still follow these intermediate symlinks. + if (!isDirectoryNoFollow(botHome) || !isDirectoryNoFollow(cliDataDir)) return null; + return cliDataDir; } catch { return null; // unsafe app id — never build a path from it } @@ -125,26 +143,135 @@ function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { function claudeJsonlWithBotHomeFallback(sid: string, q: TranscriptPathQuery, primaryDataDir: string): string | null { if (!q.cwd) return null; const globalPath = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); - const botHomeDir = botHomeClaudeDataDir(q.larkAppId); - const botHomeJsonl = botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; + const botHomeDir = botHomeCliDataDir(q.larkAppId, 'claude'); + const botHomeJsonl = botHomeDir + ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir, { noFollow: true }) + : null; // Both exist when a persistent session straddles a sandbox flip (the CLI kept // its session id but moved data dirs — either direction). The stale copy stops // growing while the live one keeps its mtime fresh, so newest-wins tracks the // file the CLI is actually writing; a fixed preference would freeze usage at // the flip point forever. - if (globalPath && botHomeJsonl) return newerFile(globalPath, botHomeJsonl); - return globalPath ?? botHomeJsonl; + return newerFile( + globalPath, + botHomeJsonl, + regularFileMtime, + path => botHomeDir ? noFollowRegularFileMtime(botHomeDir, path) : null, + ); +} + +/** Return a no-follow regular file's mtime, rejecting symlinks, directories, + * FIFOs, and stale cache entries. BOT_HOME is writable by the sandboxed agent, + * so transcript readers must never follow an agent-created link out of it. */ +function regularFileMtime(path: string | null): number | null { + if (!path) return null; + try { + const stat = lstatSync(path); + return stat.isFile() ? stat.mtimeMs : null; + } catch { + return null; + } } -/** Ties (e.g. a byte-identical copy) keep `a` — the global/stock path. */ -function newerFile(a: string, b: string): string { +function isDirectoryNoFollow(path: string): boolean { try { - return statSync(b).mtimeMs > statSync(a).mtimeMs ? b : a; + return lstatSync(path).isDirectory(); } catch { - return a; + return false; } } +/** Validate every component beneath an untrusted root with lstat. This closes + * the positive-cache hole where an agent replaces a previously valid parent + * directory with a symlink while keeping the same lexical rollout path. */ +function noFollowRegularFileMtime(root: string, candidate: string | null): number | null { + if (!candidate) return null; + const resolvedRoot = resolve(root); + const resolvedCandidate = resolve(candidate); + const suffix = relative(resolvedRoot, resolvedCandidate); + if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return null; + if (!isDirectoryNoFollow(resolvedRoot)) return null; + + const components = suffix.split(sep).filter(Boolean); + let current = resolvedRoot; + for (let i = 0; i < components.length; i++) { + current = join(current, components[i]); + try { + const stat = lstatSync(current); + if (i === components.length - 1) return stat.isFile() ? stat.mtimeMs : null; + if (!stat.isDirectory()) return null; + } catch { + return null; + } + } + return null; +} + +/** Pick the newest surviving candidate. Ties keep `a` (the global/stock path); + * each side is statted independently so one deleted candidate cannot mask the + * other. */ +function newerFile( + a: string | null, + b: string | null, + aMtimeReader = regularFileMtime, + bMtimeReader = regularFileMtime, +): string | null { + const aMtime = aMtimeReader(a); + const bMtime = bMtimeReader(b); + if (aMtime === null) return bMtime === null ? null : b; + if (bMtime === null) return a; + return bMtime > aMtime ? b : a; +} + +function codexRolloutInHome( + q: TranscriptPathQuery, + codexHome: string, + noFollow: boolean, +): string | null { + const key = `codex:${noFollow ? 'untrusted' : 'global'}:${codexHome}:${q.sessionId}:${q.cliSessionId ?? ''}`; + const finderOpts = noFollow ? { codexHome, noFollow: true } : { codexHome }; + const candidateMtime = (path: string | null) => noFollow + ? noFollowRegularFileMtime(codexHome, path) + : regularFileMtime(path); + const lookup = () => { + // cliSessionId comes from the live/adopted CLI and is authoritative. Avoid + // both the bounded history scan and any chance that an unrelated history + // row changes the selected rollout. + const mappedSid = q.cliSessionId + ? undefined + : findCodexSessionIdByBotmuxSessionId(q.sessionId, finderOpts); + const codexSid = q.cliSessionId || mappedSid || q.sessionId; + return findCodexRolloutBySessionId(codexSid, finderOpts) ?? null; + }; + let path = cachedTranscriptPathLookup( + key, + null, + lookup, + { retryMiss: q.fresh }, + ); + // Positive entries were historically cached forever. Refresh immediately if + // the file vanished or was replaced by a symlink/non-regular node. + if (path && candidateMtime(path) === null) { + path = cachedTranscriptPathLookup(key, null, lookup, { refreshHit: true, retryMiss: true }); + } + return candidateMtime(path) === null ? null : path; +} + +/** CLIs whose sessions expose a botmux-resolvable native transcript (the only + * source of Context / Token usage). Kept byte-for-byte in sync with the + * switch in {@link resolveSessionTranscriptPath}; a CLI absent here can never + * surface usage, so UI should hide usage-display options for it rather than + * offer a control that is always empty. */ +const USAGE_RESOLVABLE_CLI_IDS: ReadonlySet = new Set([ + 'claude-code', 'aiden', 'seed', 'relay', 'codex', 'coco', 'cursor', 'traex', 'antigravity', +]); + +/** True when this CLI can produce native usage (has a resolvable transcript). + * UI uses this to decide whether to offer usage-display configuration. */ +export function cliSupportsNativeUsage(cliId: string | undefined): boolean { + return !!cliId && USAGE_RESOLVABLE_CLI_IDS.has(cliId); +} + export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTranscriptPath | null { const sid = q.cliSessionId || q.sessionId; switch (q.cliId) { @@ -162,10 +289,27 @@ export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTr return path ? { path, kind: 'claude' } : null; } case 'codex': { - const path = cachedTranscriptPathLookup(`codex:${q.sessionId}:${q.cliSessionId ?? ''}`, null, () => { - const codexSid = q.cliSessionId || findCodexSessionIdByBotmuxSessionId(q.sessionId) || q.sessionId; - return findCodexRolloutBySessionId(codexSid) ?? null; - }, { retryMiss: q.fresh }); + // Resolve on every call: CODEX_HOME is intentionally dynamic, and the + // absolute path is part of the cache key so changing it cannot reuse a + // rollout discovered under a previous root. + const globalCodexHome = resolve(configuredCodexHome()); + const globalPath = codexRolloutInHome(q, globalCodexHome, false); + const botHomeDir = botHomeCliDataDir(q.larkAppId, 'codex'); + const resolvedBotHomeDir = botHomeDir ? resolve(botHomeDir) : null; + const botHomeRollout = resolvedBotHomeDir + ? codexRolloutInHome(q, resolvedBotHomeDir, true) + : null; + // A persistent session can straddle a sandbox flip while retaining its + // CLI session id. Compare the two cached path candidates on every read + // so a stale positive hit never pins usage to the pre-flip transcript. + const path = newerFile( + globalPath, + botHomeRollout, + regularFileMtime, + candidate => resolvedBotHomeDir + ? noFollowRegularFileMtime(resolvedBotHomeDir, candidate) + : null, + ); return path ? { path, kind: 'codex' } : null; } case 'coco': { diff --git a/src/utils/child-env.ts b/src/utils/child-env.ts index 016622565..ac019ee0c 100644 --- a/src/utils/child-env.ts +++ b/src/utils/child-env.ts @@ -175,6 +175,10 @@ export const BOTMUX_INJECTED_ENV_KEYS = [ // Resolved display footer for sandboxed `botmux send`; avoids reading the // credential-bearing bots.json from inside the child. 'BOTMUX_BRAND_LABEL', + // Per-bot display preference for Context / Token usage + // ('streaming' | 'footer' | 'off'). Injected explicitly so sandboxed offline + // fallback cannot drift to the default when bots.json is unreadable. + 'BOTMUX_USAGE_DISPLAY', // Pi deferred long-first-prompt extension reads one exact per-session file. 'BOTMUX_PI_INITIAL_PROMPT_FILE', // Loopback port of the owning daemon's agent-facing IPC. Read-isolated CLIs diff --git a/src/worker.ts b/src/worker.ts index 2904d175f..742f76691 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -91,7 +91,12 @@ import { type SessionMcpRuntimeManifest, } from './core/plugins/mcp/session-runtime.js'; import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js'; -import { loadBotConfigs, resolveBrandLabel, type BotConfig } from './bot-registry.js'; +import { + loadBotConfigs, + resolveBrandLabel, + resolveUsageDisplay, + type BotConfig, +} from './bot-registry.js'; import { readGlobalConfig } from './global-config.js'; import { deriveTerminalViewToken, @@ -6697,6 +6702,7 @@ async function spawnCli( BOTMUX_SESSION_ID: cfg.sessionId, BOTMUX_CHAT_ID: cfg.chatId, BOTMUX_LARK_APP_ID: cfg.larkAppId, + BOTMUX_USAGE_DISPLAY: resolveUsageDisplay(cfg.larkAppId), }; // Session scope for `botmux send` inside the sandbox. Thread sessions // anchor on a real om_ message (reply_in_thread); chat-scope sessions use @@ -7374,6 +7380,7 @@ async function spawnCli( const bl = resolveBrandLabel(cfg.larkAppId); if (typeof bl === 'string') childEnv.BOTMUX_BRAND_LABEL = bl; } + childEnv.BOTMUX_USAGE_DISPLAY = resolveUsageDisplay(cfg.larkAppId); // NOTE: under read isolation `botmux send` gets this bot's secret from the worker- // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT diff --git a/test/bot-config-store.test.ts b/test/bot-config-store.test.ts index a2a59aebd..1bc21f46c 100644 --- a/test/bot-config-store.test.ts +++ b/test/bot-config-store.test.ts @@ -390,6 +390,33 @@ describe('bot-config store', () => { expect(registry.getBot('app_default').config.disableStreamingCard).toBeUndefined(); }); + it('usageDisplay is an immediate three-state enum persisted verbatim, cleared via unset', async () => { + const { registry, store } = await loaded(); + const spec = store.findConfigField('usageDisplay')!; + expect(spec.effect).toBe('immediate'); + expect(spec.kind).toBe('enum'); + expect(spec.clearable).toBe(true); + expect(spec.enumValues).toEqual(['streaming', 'footer', 'off']); + + // coerce validates the enum (case-insensitive) and rejects nonsense. + expect(store.coerceConfigValue(spec, 'footer')).toEqual({ ok: true, value: 'footer' }); + expect(store.coerceConfigValue(spec, 'nonsense')).toEqual({ ok: false, reason: 'invalid_enum' }); + + const toFooter = await store.applyConfigField('app_default', spec, 'footer'); + expect(toFooter).toMatchObject({ ok: true, newText: 'footer', effect: 'immediate' }); + expect(readConfig().usageDisplay).toBe('footer'); + expect(registry.getBot('app_default').config.usageDisplay).toBe('footer'); + + const toOff = await store.applyConfigField('app_default', spec, 'off'); + expect(toOff).toMatchObject({ ok: true, newText: 'off' }); + expect(readConfig().usageDisplay).toBe('off'); + + // Clearing (unset) drops the key → back to the default 'streaming'. + await store.applyConfigField('app_default', spec, null); + expect(readConfig().usageDisplay).toBeUndefined(); + expect(registry.getBot('app_default').config.usageDisplay).toBeUndefined(); + }); + it('codexAppCleanInput is immediate, default-off, and deletes its key when disabled', async () => { const { registry, store } = await loaded({ cliId: 'codex-app' }); const spec = store.findConfigField('codexAppCleanInput')!; diff --git a/test/bot-registry-grant.test.ts b/test/bot-registry-grant.test.ts index 149455745..31b2de1ad 100644 --- a/test/bot-registry-grant.test.ts +++ b/test/bot-registry-grant.test.ts @@ -64,6 +64,31 @@ describe('bot-registry grant additions', () => { expect(cfgs[3].brandLabel).toBeUndefined(); // non-string ignored }); + it('parses usageDisplay as a three-state enum, defaulting to streaming and honoring legacy showUsageInCardFooter', () => { + const cfgs = parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'usage-default', larkAppSecret: 's' }, + { larkAppId: 'usage-streaming', larkAppSecret: 's', usageDisplay: 'streaming' }, + { larkAppId: 'usage-footer', larkAppSecret: 's', usageDisplay: 'footer' }, + { larkAppId: 'usage-off', larkAppSecret: 's', usageDisplay: 'off' }, + { larkAppId: 'usage-invalid', larkAppSecret: 's', usageDisplay: 'nonsense' }, + { larkAppId: 'usage-legacy-off', larkAppSecret: 's', showUsageInCardFooter: false }, + { larkAppId: 'usage-legacy-on', larkAppSecret: 's', showUsageInCardFooter: true }, + ])); + // Default (unset) and explicit 'streaming' both persist as undefined (default). + expect(cfgs[0].usageDisplay).toBeUndefined(); + expect(cfgs[1].usageDisplay).toBeUndefined(); + // Non-default modes persist verbatim. + expect(cfgs[2].usageDisplay).toBe('footer'); + expect(cfgs[3].usageDisplay).toBe('off'); + // Invalid enum falls back to the default (undefined). + expect(cfgs[4].usageDisplay).toBeUndefined(); + // Legacy boolean: false → 'off'; true → default streaming (undefined). + expect(cfgs[5].usageDisplay).toBe('off'); + expect(cfgs[6].usageDisplay).toBeUndefined(); + // Legacy field is never re-emitted. + expect((cfgs[5] as any).showUsageInCardFooter).toBeUndefined(); + }); + it('getOwnerOpenId returns first ou_ in resolvedAllowedUsers', () => { registerBot({ larkAppId: 'a2', larkAppSecret: 's', cliId: 'claude-code', allowedUsers: ['x@y.com', 'ou_owner', 'ou_2'] }); expect(getOwnerOpenId('a2')).toBe('ou_owner'); diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 555c15da9..dec9e1731 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -775,11 +775,17 @@ describe('getBot / getBotClient', () => { describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => { let mod: Awaited>; - const saved = { app: process.env.BOTMUX_LARK_APP_ID, brand: process.env.BOTMUX_BRAND_LABEL }; + const saved = { + app: process.env.BOTMUX_LARK_APP_ID, + brand: process.env.BOTMUX_BRAND_LABEL, + usageDisplay: process.env.BOTMUX_USAGE_DISPLAY, + }; beforeEach(async () => { mod = await freshImport(); }); afterEach(() => { if (saved.app === undefined) delete process.env.BOTMUX_LARK_APP_ID; else process.env.BOTMUX_LARK_APP_ID = saved.app; if (saved.brand === undefined) delete process.env.BOTMUX_BRAND_LABEL; else process.env.BOTMUX_BRAND_LABEL = saved.brand; + if (saved.usageDisplay === undefined) delete process.env.BOTMUX_USAGE_DISPLAY; + else process.env.BOTMUX_USAGE_DISPLAY = saved.usageDisplay; }); it('returns the injected env brandLabel for the own appId WITHOUT reading bots.json (the sandbox path)', () => { @@ -800,6 +806,50 @@ describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => process.env.BOTMUX_BRAND_LABEL = '[self]()'; expect(mod.resolveBrandLabel('app_other')).toBeUndefined(); }); + + it('resolves the usage-display mode from registry or sandbox env (default streaming)', () => { + expect(mod.resolveUsageDisplay('app_default')).toBe('streaming'); + + mod.registerBot(makeCfg({ + larkAppId: 'app_registered_footer', + usageDisplay: 'footer', + })); + expect(mod.resolveUsageDisplay('app_registered_footer')).toBe('footer'); + + mod.registerBot(makeCfg({ + larkAppId: 'app_registered_off', + usageDisplay: 'off', + })); + expect(mod.resolveUsageDisplay('app_registered_off')).toBe('off'); + + process.env.BOTMUX_LARK_APP_ID = 'app_sbx'; + process.env.BOTMUX_USAGE_DISPLAY = 'footer'; + expect(mod.resolveUsageDisplay('app_sbx')).toBe('footer'); + expect(mod.resolveUsageDisplay('app_other')).toBe('streaming'); + }); + + it('reads a legacy showUsageInCardFooter:false as off', () => { + mod.registerBot(makeCfg({ + larkAppId: 'app_legacy_off', + // legacy field, no usageDisplay set + showUsageInCardFooter: false, + } as any)); + expect(mod.resolveUsageDisplay('app_legacy_off')).toBe('off'); + }); + + it('prefers freshly loaded registry config over a frozen pane env for the same app', () => { + process.env.BOTMUX_LARK_APP_ID = 'app_hot'; + process.env.BOTMUX_USAGE_DISPLAY = 'streaming'; + mod.registerBot(makeCfg({ + larkAppId: 'app_hot', + usageDisplay: 'off', + })); + expect(mod.resolveUsageDisplay('app_hot')).toBe('off'); + + process.env.BOTMUX_USAGE_DISPLAY = 'off'; + mod.registerBot(makeCfg({ larkAppId: 'app_hot' })); + expect(mod.resolveUsageDisplay('app_hot')).toBe('streaming'); + }); }); // ─── getAllBots ──────────────────────────────────────────────────────────── diff --git a/test/bridge-final-output-retry.test.ts b/test/bridge-final-output-retry.test.ts index 9e556abba..330b15891 100644 --- a/test/bridge-final-output-retry.test.ts +++ b/test/bridge-final-output-retry.test.ts @@ -45,6 +45,9 @@ vi.mock('../src/bot-registry.js', () => ({ getBotClient: vi.fn(), getBotBrand: vi.fn(() => undefined), resolveBrandLabel: vi.fn(() => undefined), + // Reply-card footer usage only renders in 'footer' mode; tests override this + // per case. Default 'footer' keeps the positive usage-render tests below green. + resolveUsageDisplay: vi.fn(() => 'footer'), })); vi.mock('../src/config.js', () => ({ @@ -55,6 +58,14 @@ vi.mock('../src/config.js', () => ({ }, })); +vi.mock('../src/core/cost-calculator.js', () => ({ + getSessionTokenUsage: vi.fn(() => null), + getSessionUsageSnapshot: vi.fn(() => ({ + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + })), +})); + vi.mock('../src/services/session-store.js', () => ({ closeSession: vi.fn(), updateSession: vi.fn(), @@ -74,7 +85,11 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ LoggerLevel: { info: 2 }, })); -import { initWorkerPool, __testOnly_setupWorkerHandlers } from '../src/core/worker-pool.js'; +import { + getDaemonReplyCardUsageSnapshot, + initWorkerPool, + __testOnly_setupWorkerHandlers, +} from '../src/core/worker-pool.js'; import { MessageWithdrawnError } from '../src/im/lark/client.js'; import type { DaemonSession } from '../src/core/types.js'; import type { WorkerToDaemon } from '../src/types.js'; @@ -92,6 +107,8 @@ import { } from '../src/services/vc-meeting-delivery-store.js'; import { listVcMeetingActions } from '../src/services/vc-meeting-action-store.js'; import { listVcMeetingListenerMessageIds } from '../src/services/vc-meeting-listener-message-store.js'; +import { getSessionUsageSnapshot } from '../src/core/cost-calculator.js'; +import { getBot, resolveUsageDisplay } from '../src/bot-registry.js'; // Build a fake worker child process whose IPC `message` event we can fire // manually, then wire it through setupWorkerHandlers via forkAdoptWorker. @@ -195,6 +212,15 @@ describe('Bridge final_output delivery (P2 retry)', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + vi.mocked(getBot).mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code' }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + } as any); + // clearAllMocks wipes the factory return; re-arm footer mode so the + // positive usage-render tests see footer usage (individual tests override). + vi.mocked(resolveUsageDisplay).mockReturnValue('footer'); rmSync('/tmp/test-sessions', { recursive: true, force: true }); mkdirSync('/tmp/test-sessions', { recursive: true }); }); @@ -1272,6 +1298,148 @@ describe('Bridge final_output delivery (P2 retry)', () => { await vi.advanceTimersByTimeAsync(15000); expect(sessionReply).toHaveBeenCalledTimes(3); expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY); + expect(getSessionUsageSnapshot).toHaveBeenCalledTimes(1); + expect(getSessionUsageSnapshot).toHaveBeenCalledWith({ + cliId: 'claude-code', + sessionId: 'sid-final-out', + cliSessionId: 'claude-session-xyz', + cwd: '/tmp', + larkAppId: 'app_test', + fresh: true, + }); + const cards = sessionReply.mock.calls.map(call => call[1] as string); + expect(new Set(cards)).toHaveLength(1); + expect(cards[0]).toContain('上下文 12.3K/100K (12%)'); + expect(cards[0]).toContain('Token ↑67.9K ↓123'); + }); + + it('reads a sandboxed Claude transcript through the daemon reply-card boundary', async () => { + const actualCostCalculator = + await vi.importActual( + '../src/core/cost-calculator.js', + ); + vi.mocked(getSessionUsageSnapshot) + .mockImplementationOnce(actualCostCalculator.getSessionUsageSnapshot); + + const sandboxRoot = mkdtempSync(join(tmpdir(), 'botmux-card-usage-sandbox-')); + const previousSessionDataDir = process.env.SESSION_DATA_DIR; + try { + process.env.SESSION_DATA_DIR = join(sandboxRoot, 'data'); + const transcriptDir = join( + sandboxRoot, + 'bots', + 'app_test', + 'claude', + 'projects', + '-tmp', + ); + mkdirSync(transcriptDir, { recursive: true }); + writeFileSync( + join(transcriptDir, 'claude-session-xyz.jsonl'), + JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-sonnet-test', + usage: { + input_tokens: 100, + output_tokens: 20, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 5, + }, + }, + }) + '\n', + ); + + expect(getDaemonReplyCardUsageSnapshot(makeDs())).toMatchObject({ + context: { usedTokens: 115 }, + tokens: { in: 115, out: 20 }, + }); + } finally { + if (previousSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = previousSessionDataDir; + rmSync(sandboxRoot, { recursive: true, force: true }); + } + }); + + it('reads a sandboxed Codex rollout through the daemon reply-card boundary', async () => { + const actualCostCalculator = + await vi.importActual( + '../src/core/cost-calculator.js', + ); + vi.mocked(getSessionUsageSnapshot) + .mockImplementationOnce(actualCostCalculator.getSessionUsageSnapshot); + + const sandboxRoot = mkdtempSync(join(tmpdir(), 'botmux-card-usage-codex-')); + const previousSessionDataDir = process.env.SESSION_DATA_DIR; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4ef'; + try { + process.env.SESSION_DATA_DIR = join(sandboxRoot, 'data'); + const rolloutDir = join( + sandboxRoot, + 'bots', + 'app_test', + 'codex', + 'sessions', + '2026', + '07', + '29', + ); + mkdirSync(rolloutDir, { recursive: true }); + writeFileSync( + join(rolloutDir, `rollout-2026-07-29T12-00-00-${codexSid}.jsonl`), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 1_000, + cached_input_tokens: 400, + output_tokens: 50, + }, + last_token_usage: { total_tokens: 250 }, + model_context_window: 2_000, + }, + }, + }) + '\n', + ); + const ds = makeDs(); + ds.session.cliId = 'codex'; + ds.session.cliSessionId = codexSid; + + expect(getDaemonReplyCardUsageSnapshot(ds)).toMatchObject({ + context: { usedTokens: 250, windowTokens: 2_000, percentUsed: 13 }, + tokens: { in: 1_000, out: 50 }, + }); + } finally { + if (previousSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = previousSessionDataDir; + rmSync(sandboxRoot, { recursive: true, force: true }); + } + }); + + it('does not read or render footer usage when this bot is not in footer mode', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/tmp', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + // Default streaming (or off) → the reply-card footer must not carry usage, + // and the gate must short-circuit before touching the transcript reader. + vi.mocked(resolveUsageDisplay).mockReturnValue('off'); + + const ds = makeDs(); + const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any; + __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0); + await vi.advanceTimersByTimeAsync(10); + + expect(getSessionUsageSnapshot).not.toHaveBeenCalled(); + const card = sessionReply.mock.calls[0]?.[1] as string; + expect(card).toContain('[botmux]('); + expect(card).not.toContain('上下文'); + expect(card).not.toContain('Token'); }); it('gives up after 3 attempts and does NOT commit dedup', async () => { diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts index 4b7b22965..ebd265ded 100644 --- a/test/card-builder.test.ts +++ b/test/card-builder.test.ts @@ -161,7 +161,7 @@ describe('getCliDisplayName', () => { }); describe('buildConfigCard', () => { - it('renders silentTurnReactions as a card-behaviour toggle', () => { + it('renders card-behaviour toggles', () => { const card = parse(buildConfigCard({ larkAppId: 'app_cfg', botName: 'Config Bot', @@ -196,6 +196,14 @@ describe('buildConfigCard', () => { expect(toggle.value.action).toBe('config_toggle'); expect(toggle.type).toBe('primary'); expect(toggle.text.content).toContain('Disable status reactions'); + + // usageDisplay is an enum (streaming/footer/off), configured via dashboard + // and `/botconfig set` — like skillInjection it is intentionally NOT a + // config-card boolean quick-toggle. + const usageToggle = allActions(card).find( + (a: any) => a.value?.field === 'usageDisplay' || a.value?.field === 'showUsageInCardFooter', + ); + expect(usageToggle).toBeFalsy(); }); }); @@ -628,6 +636,64 @@ describe('buildStreamingCard', () => { }); }); + // ── Native usage line (Context / Token) ──────────────────────────────── + + describe('usage snapshot on the streaming card body', () => { + const USAGE = { + context: { usedTokens: 159_816, windowTokens: 258_400, percentUsed: 62 }, + tokens: { in: 3_739_570, out: 23_299 }, + }; + + it('renders a grey Context / Token line when a usage snapshot is supplied', () => { + const card = parse(buildStreamingCard( + SID, ROOT, URL, TITLE, CONTENT, 'working', 'codex', 'hidden', + undefined, undefined, false, false, 'en', undefined, undefined, false, USAGE, + )); + const md = card.elements.filter((e: any) => e.tag === 'markdown'); + const usageEl = md.find((e: any) => typeof e.content === 'string' && e.content.includes('Context')); + expect(usageEl).toBeTruthy(); + expect(usageEl.content).toContain('159.8K/258.4K (62%)'); + expect(usageEl.content).toContain('↑3.7M'); + expect(usageEl.content).toContain('↓23.3K'); + expect(usageEl.content).toContain("color='grey'"); + }); + + it('omits the usage line entirely when no snapshot is supplied', () => { + const card = parse(buildStreamingCard(SID, ROOT, URL, TITLE, CONTENT, 'working', 'codex', 'hidden')); + const hasUsage = card.elements.some( + (e: any) => e.tag === 'markdown' && typeof e.content === 'string' + && (e.content.includes('Context') || e.content.includes('Tokens')), + ); + expect(hasUsage).toBe(false); + }); + + it('renders only the present metric (context missing → tokens only)', () => { + const card = parse(buildStreamingCard( + SID, ROOT, URL, TITLE, CONTENT, 'working', 'codex', 'hidden', + undefined, undefined, false, false, 'en', undefined, undefined, false, + { context: null, tokens: { in: 1_200, out: 3_400 } }, + )); + const md = card.elements.filter((e: any) => e.tag === 'markdown'); + const usageEl = md.find((e: any) => typeof e.content === 'string' && e.content.includes('Tokens')); + expect(usageEl).toBeTruthy(); + expect(usageEl.content).not.toContain('Context'); + expect(usageEl.content).toContain('↑1.2K'); + }); + + it('renders nothing for a fully-empty snapshot (unsupported CLI)', () => { + const card = parse(buildStreamingCard( + SID, ROOT, URL, TITLE, CONTENT, 'working', 'gemini', 'hidden', + undefined, undefined, false, false, 'en', undefined, undefined, false, + { context: null, tokens: null }, + )); + const hasUsage = card.elements.some( + (e: any) => e.tag === 'markdown' && typeof e.content === 'string' + && (e.content.includes('Context') || e.content.includes('Tokens')), + ); + expect(hasUsage).toBe(false); + }); + }); + // ── Screenshot display mode ──────────────────────────────────────────── describe('screenshot display mode', () => { diff --git a/test/card-handler-config.test.ts b/test/card-handler-config.test.ts new file mode 100644 index 000000000..513b70f56 --- /dev/null +++ b/test/card-handler-config.test.ts @@ -0,0 +1,88 @@ +/** + * `/botconfig` 交互卡片:usageDisplay 是三态枚举,经 config_set 选项写入, + * coerce 校验枚举值并即时落盘 + 同步内存 config。 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record) {} } + return { Client: FakeClient }; +}); + +const deps = { + activeSessions: new Map(), + sessionReply: vi.fn(async () => 'om_reply'), + lastRepoScan: new Map(), +} as any; + +let root: string; +let configPath: string; + +async function fresh() { + vi.resetModules(); + const registry = await import('../src/bot-registry.js'); + const handler = await import('../src/im/lark/card-handler.js'); + registry.loadBotConfigs().forEach(cfg => registry.registerBot(cfg)); + return { registry, handler }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'botmux-card-config-')); + configPath = join(root, 'bots.json'); + writeFileSync(configPath, JSON.stringify([{ + larkAppId: 'app_config', + larkAppSecret: 'secret', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + }], null, 2)); + process.env.BOTS_CONFIG = configPath; +}); + +afterEach(() => { + delete process.env.BOTS_CONFIG; + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('/botconfig usageDisplay enum', () => { + it('config_set writes a non-default usageDisplay (footer) and syncs runtime config', async () => { + const { registry, handler } = await fresh(); + const result = await handler.handleCardAction({ + operator: { open_id: 'ou_owner' }, + action: { + value: { + action: 'config_set', + field: 'usageDisplay', + loc: 'en', + }, + option: 'footer', + }, + }, deps, 'app_config'); + + expect(result?.toast).toMatchObject({ + type: 'success', + content: '✓ usageDisplay = footer', + }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].usageDisplay).toBe('footer'); + expect(registry.getBot('app_config').config.usageDisplay).toBe('footer'); + }); + + it('config_set rejects an invalid usageDisplay value', async () => { + const { registry, handler } = await fresh(); + const result = await handler.handleCardAction({ + operator: { open_id: 'ou_owner' }, + action: { + value: { action: 'config_set', field: 'usageDisplay', loc: 'en' }, + option: 'nonsense', + }, + }, deps, 'app_config'); + + expect(result?.toast?.type).toBe('error'); + // Unchanged on disk / in memory. + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].usageDisplay).toBeUndefined(); + expect(registry.getBot('app_config').config.usageDisplay).toBeUndefined(); + }); +}); diff --git a/test/cost-calculator.test.ts b/test/cost-calculator.test.ts index df914a290..c5bab064e 100644 --- a/test/cost-calculator.test.ts +++ b/test/cost-calculator.test.ts @@ -20,6 +20,10 @@ vi.mock('node:fs', async (importOriginal) => { return { ...original, existsSync: vi.fn(() => false), + lstatSync: vi.fn(() => ({ + isFile: () => true, + mtimeMs: 0, + })), readFileSync: vi.fn(() => ''), }; }); @@ -81,6 +85,7 @@ import { findTraexRolloutBySessionId } from '../src/services/traex-transcript.js import { getSessionJsonlPath, getSessionCost, + getSessionUsageSnapshot, getSessionTokenUsage, formatNumber, __resetSessionUsageCachesForTest, @@ -410,6 +415,37 @@ describe('getSessionTokenUsage', () => { }); }); + it('reports Claude latest prompt-side context without inventing a window', () => { + setupJsonl([ + assistantLine({ input: 100, output: 50, cacheRead: 10, cacheCreate: 5 }), + assistantLine({ input: 200, output: 80, cacheRead: 20, cacheCreate: 7 }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'claude-code', + sessionId: 's1', + cwd: '/tmp', + fresh: true, + })).toMatchObject({ + context: { usedTokens: 227 }, + tokens: { in: 342, out: 130 }, + }); + }); + + it('keeps the last valid Claude context across an all-zero synthetic record', () => { + setupJsonl([ + assistantLine({ input: 200, output: 80, cacheRead: 20, cacheCreate: 7 }), + assistantLine({ input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'claude-code', + sessionId: 's1', + cwd: '/tmp', + fresh: true, + }).context).toEqual({ usedTokens: 227 }); + }); + it('returns null when an Agent CLI has no native token usage available', () => { vi.mocked(existsSync).mockReturnValue(false); @@ -496,8 +532,139 @@ describe('getSessionTokenUsage', () => { turns: 0, model: '', }); - expect(findCodexSessionIdByBotmuxSessionId).toHaveBeenCalledWith('botmux-sid'); - expect(findCodexRolloutBySessionId).toHaveBeenCalledWith('codex-sid'); + expect(findCodexSessionIdByBotmuxSessionId).toHaveBeenCalledWith( + 'botmux-sid', + { codexHome: expect.any(String) }, + ); + expect(findCodexRolloutBySessionId).toHaveBeenCalledWith( + 'codex-sid', + { codexHome: expect.any(String) }, + ); + }); + + it('keeps Codex latest context usage separate from cumulative Session tokens', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl([ + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 3_579_709, cached_input_tokens: 3_404_544, output_tokens: 22_920 }, + last_token_usage: { + input_tokens: 159_508, + cached_input_tokens: 158_464, + output_tokens: 308, + reasoning_output_tokens: 148, + total_tokens: 159_816, + }, + model_context_window: 258_400, + }, + }, + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 3_739_570, cached_input_tokens: 3_563_008, output_tokens: 23_299 }, + last_token_usage: { + input_tokens: 159_861, + cached_input_tokens: 158_464, + output_tokens: 379, + reasoning_output_tokens: 100, + total_tokens: 160_240, + }, + model_context_window: 258_400, + }, + }, + }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toEqual({ + context: { usedTokens: 160_240, windowTokens: 258_400, percentUsed: 62 }, + tokens: { + in: 3_739_570, + out: 23_299, + inputTokens: 176_562, + outputTokens: 23_299, + cacheReadTokens: 3_563_008, + cacheCreateTokens: 0, + turns: 0, + model: '', + }, + }); + }); + + it('reports Codex context when token_count omits cumulative totals', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 49_978, + cached_input_tokens: 49_536, + output_tokens: 274, + reasoning_output_tokens: 38, + total_tokens: 50_252, + }, + model_context_window: 258_400, + }, + }, + })); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toEqual({ + context: { usedTokens: 50_252, windowTokens: 258_400, percentUsed: 19 }, + tokens: null, + }); + }); + + it('keeps the last valid Codex context when a later cumulative snapshot omits last usage', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl([ + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 100, output_tokens: 10 }, + last_token_usage: { input_tokens: 80, output_tokens: 10, total_tokens: 90 }, + model_context_window: 1_000, + }, + }, + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 120, output_tokens: 12 }, + }, + }, + }), + ].join('\n')); + + expect(getSessionUsageSnapshot({ + cliId: 'codex', + sessionId: 'botmux-sid', + fresh: true, + })).toMatchObject({ + context: { usedTokens: 90, windowTokens: 1_000, percentUsed: 9 }, + tokens: { in: 120, out: 12 }, + }); }); it('reports TraeX rollouts via the codex fold, capturing the turn_context model', () => { @@ -822,6 +989,27 @@ describe('getSessionTokenUsage', () => { expect(findCodexRolloutBySessionId).toHaveBeenCalledTimes(1); }); + it('does not scan Codex history when cliSessionId is already authoritative', () => { + vi.mocked(findCodexRolloutBySessionId).mockReturnValue( + '/home/testuser/.codex/sessions/rollout-codex-explicit.jsonl', + ); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 1, output_tokens: 1 } }, + }, + })); + + getSessionTokenUsage({ + cliId: 'codex', + sessionId: 'botmux-sid', + cliSessionId: 'codex-explicit', + }); + + expect(findCodexSessionIdByBotmuxSessionId).not.toHaveBeenCalled(); + }); + it('retries a missed codex path lookup only after the retry window', () => { vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue(undefined); vi.mocked(findCodexRolloutBySessionId).mockReturnValue(undefined); diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index 8b1766f42..8d0158472 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -2,7 +2,7 @@ import React from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { displayCliId } from '../src/dashboard/web/bot-defaults.js'; -import { BotAgentSection, CodexAppDisplaySection } from '../src/dashboard/web/bot-defaults-page.js'; +import { BotAgentSection, CardBehaviorSection, CodexAppDisplaySection } from '../src/dashboard/web/bot-defaults-page.js'; import { isOnboardingSubmitDisabled } from '../src/dashboard/web/bot-onboarding.js'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -203,3 +203,62 @@ describe('Codex App history switch', () => { .toContain('write_failed'); }); }); + +describe('reply-card usage display mode', () => { + it('defaults to streaming and persists explicit footer/off changes', async () => { + const putCardPref = vi.fn(async (patch: Record) => ({ + ok: true, + status: 200, + body: { ok: true, ...patch }, + })); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(CardBehaviorSection, { + bot: { larkAppId: 'cli_usage', usageSupported: true }, + putCardPref, + })); + }); + + const menu = () => renderer.root.findByProps({ id: 'bd-menu-usageDisplay' }); + expect(menu().props.value).toBe('streaming'); + + await act(async () => { + menu().props.onChange('footer'); + await Promise.resolve(); + }); + expect(putCardPref).toHaveBeenLastCalledWith({ usageDisplay: 'footer' }); + expect(menu().props.value).toBe('footer'); + + await act(async () => { + menu().props.onChange('off'); + await Promise.resolve(); + }); + expect(putCardPref).toHaveBeenLastCalledWith({ usageDisplay: 'off' }); + expect(menu().props.value).toBe('off'); + }); + + it('rolls back when persistence fails', async () => { + const putCardPref = vi.fn(async () => ({ + ok: false, + status: 500, + body: { error: 'write_failed' }, + })); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(CardBehaviorSection, { + bot: { larkAppId: 'cli_usage', usageSupported: true }, + putCardPref, + })); + }); + + const menu = () => renderer.root.findByProps({ id: 'bd-menu-usageDisplay' }); + await act(async () => { + menu().props.onChange('off'); + await Promise.resolve(); + }); + + expect(menu().props.value).toBe('streaming'); + expect(renderer.root.findByProps({ 'data-card-pref-status': '' }).children.join('')) + .toContain('write_failed'); + }); +}); diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts index 13b33c3ef..b7b0a8b08 100644 --- a/test/dashboard-bot-payload.test.ts +++ b/test/dashboard-bot-payload.test.ts @@ -86,6 +86,18 @@ describe('dashboard bot payload helpers', () => { .toMatchObject({ codexAppCleanInput: true }); }); + it('projects the usage-display mode, defaulting to streaming and honoring legacy/off', () => { + const daemon = { larkAppId: 'app_usage', botName: 'Usage', cliId: 'codex' }; + expect(botDefaultsPayload(daemon, {})).toMatchObject({ usageDisplay: 'streaming' }); + expect(botDefaultsPayload(daemon, { usageDisplay: 'footer' })) + .toMatchObject({ usageDisplay: 'footer' }); + expect(botDefaultsPayload(daemon, { usageDisplay: 'off' })) + .toMatchObject({ usageDisplay: 'off' }); + // Legacy boolean projects to 'off'. + expect(botDefaultsPayload(daemon, { showUsageInCardFooter: false })) + .toMatchObject({ usageDisplay: 'off' }); + }); + it('projects sandboxPaths three tiers, defaulting to null when absent or malformed', () => { const daemon = { larkAppId: 'app_sbx', botName: 'Sbx', cliId: 'claude-code' }; // Absent → null (pure deny-by-default baseline, no rules to render). diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 160a86e66..79d847308 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -330,6 +330,69 @@ describe('PUT /api/bot-card-prefs — Codex App clean history', () => { }); }); +describe('PUT /api/bot-card-prefs — reply-card usage display mode', () => { + it('defaults to streaming and persists explicit footer/off changes immediately', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-usage-display-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-usage-display-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'codex', + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + const initial = await (await fetch(`${base}/api/bot-default-oncall`)).json(); + expect(initial.usageDisplay).toBe('streaming'); + + const footer = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ usageDisplay: 'footer' }), + }); + expect(footer.status).toBe(200); + expect(await footer.json()).toMatchObject({ ok: true, usageDisplay: 'footer' }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].usageDisplay).toBe('footer'); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ usageDisplay: 'footer' }); + + // Back to the default 'streaming' → key dropped, GET reflects the default. + const streaming = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ usageDisplay: 'streaming' }), + }); + expect(streaming.status).toBe(200); + expect(await streaming.json()).toMatchObject({ ok: true, usageDisplay: 'streaming' }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].usageDisplay).toBeUndefined(); + expect(await (await fetch(`${base}/api/bot-default-oncall`)).json()) + .toMatchObject({ usageDisplay: 'streaming' }); + + // 'off' persists verbatim. + const off = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ usageDisplay: 'off' }), + }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ ok: true, usageDisplay: 'off' }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].usageDisplay).toBe('off'); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('POST /api/grants/chat', () => { it('requires loopback HMAC before invoking the permission service', async () => { const handler = vi.fn(); @@ -638,6 +701,74 @@ describe('GET /api/sessions/:sessionId', () => { }); }); +describe('GET /api/sessions/:sessionId/usage', () => { + it('returns the daemon-cached native usage snapshot for an active Session', async () => { + const ds = { session: { sessionId: 's-usage' } } as any; + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(ds); + const usageSpy = vi.spyOn(workerPool, 'getDaemonReplyCardUsageSnapshot').mockReturnValue({ + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + }); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/s-usage/usage`); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + usage: { + context: { usedTokens: 12_345, windowTokens: 100_000, percentUsed: 12 }, + tokens: { in: 67_890, out: 123 }, + }, + }); + expect(usageSpy).toHaveBeenCalledWith(ds); + } finally { + findSpy.mockRestore(); + usageSpy.mockRestore(); + } + }); + + it('returns the card-specific empty snapshot when footer usage is disabled', async () => { + const ds = { session: { sessionId: 's-usage-hidden' } } as any; + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(ds); + const rawSpy = vi.spyOn(workerPool, 'getDaemonSessionUsageSnapshot').mockReturnValue({ + context: { usedTokens: 12_345 }, + tokens: { in: 67_890, out: 123 }, + }); + const cardSpy = vi.spyOn(workerPool, 'getDaemonReplyCardUsageSnapshot').mockReturnValue({ + context: null, + tokens: null, + }); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch( + `http://127.0.0.1:${handle.port}/api/sessions/s-usage-hidden/usage`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + usage: { context: null, tokens: null }, + }); + expect(cardSpy).toHaveBeenCalledWith(ds); + expect(rawSpy).not.toHaveBeenCalled(); + } finally { + findSpy.mockRestore(); + rawSpy.mockRestore(); + cardSpy.mockRestore(); + } + }); + + it('returns 404 when the Session is not active', async () => { + const findSpy = vi.spyOn(workerPool, 'findActiveBySessionId').mockReturnValue(undefined); + try { + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const res = await fetch(`http://127.0.0.1:${handle.port}/api/sessions/missing/usage`); + expect(res.status).toBe(404); + } finally { + findSpy.mockRestore(); + } + }); +}); + describe('POST /api/sessions/:sessionId/rename', () => { it('updates the canonical title and requests native sync from a live Codex worker', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-session-rename-')); diff --git a/test/md-card.test.ts b/test/md-card.test.ts index 7a91c10d8..8d9355aad 100644 --- a/test/md-card.test.ts +++ b/test/md-card.test.ts @@ -11,10 +11,12 @@ import { homedir, tmpdir } from 'node:os'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { + appendReplyCardFooterToV2Card, buildCardBodyElements, buildImageCardElements, buildMarkdownCard, buildContextualReplyCard, + buildReplyCardFooter, brandFooterSegment, DEFAULT_BRAND_LABEL, hasMarkdown, @@ -659,6 +661,98 @@ describe('normalizeLocalHomeLinks', () => { }); describe('buildMarkdownCard', () => { + it('renders native context and cumulative token usage in the footer', () => { + const json = buildMarkdownCard('hello', 'ou_abc', undefined, 'zh', undefined, 'filesystem', { + context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, + tokens: { in: 3_739_570, out: 23_299 }, + }); + const card = JSON.parse(json); + const footer = card.body.elements.at(-1).content; + + expect(footer).toContain('上下文 159.9K/258.4K (62%)'); + expect(footer).toContain('Token ↑3.7M ↓23.3K'); + expect(footer).toContain(''); + }); + + it('omits malformed native usage instead of rendering NaN or Infinity', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: { usedTokens: Number.NaN, windowTokens: 258_400 }, + tokens: { in: Number.POSITIVE_INFINITY, out: 23_299 }, + }); + const card = JSON.parse(json); + const rendered = JSON.stringify(card); + + expect(card.body.elements.map((element: any) => element.tag)).not.toContain('hr'); + expect(rendered).not.toContain('上下文'); + expect(rendered).not.toContain('Token'); + expect(rendered).not.toContain('NaN'); + expect(rendered).not.toContain('Infinity'); + }); + + it('does not invent a percentage when the native snapshot has no percentage', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: { usedTokens: 12_345, windowTokens: 100_000 }, + tokens: null, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('上下文 12.3K/100K'); + expect(footer).not.toContain('(12%)'); + expect(footer).not.toContain('Token'); + }); + + it('omits missing context while retaining cumulative token usage', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: null, + tokens: { in: 67_890, out: 123 }, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('Token ↑67.9K ↓123'); + expect(footer).not.toContain('上下文'); + expect(footer).not.toContain('不可用'); + }); + + it('promotes rounded compact values at unit boundaries', () => { + const json = buildMarkdownCard('hello', undefined, '', 'en', undefined, 'filesystem', { + context: { usedTokens: 999_950, windowTokens: 999_950_000 }, + tokens: { in: 999_950_000, out: 999_950 }, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('Context 1M/1B'); + expect(footer).toContain('Tokens ↑1B ↓1M'); + expect(footer).not.toMatch(/1000[KM]/); + }); + + it('omits the usage footer when the Agent CLI reports neither metric', () => { + const json = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { + context: null, + tokens: null, + }); + const card = JSON.parse(json); + const rendered = JSON.stringify(card); + + expect(card.body.elements.map((element: any) => element.tag)).not.toContain('hr'); + expect(rendered).not.toContain('上下文'); + expect(rendered).not.toContain('Token'); + expect(rendered).not.toContain('不可用'); + }); + + it('keeps brand and recipient chrome when usage is entirely missing', () => { + const json = buildMarkdownCard('hello', 'ou_abc', undefined, 'zh', undefined, 'filesystem', { + context: null, + tokens: null, + }); + const footer = JSON.parse(json).body.elements.at(-1).content; + + expect(footer).toContain('[botmux]('); + expect(footer).toContain(''); + expect(footer).not.toContain('上下文'); + expect(footer).not.toContain('Token'); + expect(footer).not.toContain('不可用'); + }); + it('appends footer hr + grey link element', () => { const json = buildMarkdownCard('hello'); const card = JSON.parse(json); @@ -683,6 +777,134 @@ describe('buildMarkdownCard', () => { }); }); +describe('buildReplyCardFooter', () => { + it('centralizes brand, usage, and ordered recipients for every reply-card path', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + usage: { + context: { usedTokens: 12_345 }, + tokens: { in: 67_890, out: 123 }, + }, + recipientOpenIds: ['ou_owner', 'ou_reviewer'], + locale: 'zh', + }); + + expect(footer?.content).toContain( + 'Acme [·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1) ' + + '上下文 12.3K · Token ↑67.9K ↓123 · ' + + '发送给: ', + ); + expect(footer?.content).not.toContain('\u200B'); + expect(footer?.element).toMatchObject({ + tag: 'markdown', + element_id: 'botmux_reply_footer', + text_size: 'notation_small_v2', + content: footer?.content, + }); + }); + + it('appends the canonical signed footer to caller-supplied v2 cards', () => { + const original = { + schema: '2.0', + body: { elements: [{ tag: 'markdown', content: 'body' }] }, + }; + const card = appendReplyCardFooterToV2Card(original, { + brand: '', + recipientOpenIds: ['ou_owner', 'ou_owner'], + locale: 'en', + }) as any; + + expect(card).not.toBe(original); + expect(original.body.elements).toHaveLength(1); + expect(card.body.elements.at(-1)).toMatchObject({ + tag: 'markdown', + element_id: 'botmux_reply_footer', + content: expect.stringContaining('Sent to: '), + }); + expect(card.body.elements.at(-1).content).toContain( + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + ); + }); + + it('rejects caller-supplied cards without schema-2 body elements', () => { + expect(appendReplyCardFooterToV2Card( + { elements: [] }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + expect(appendReplyCardFooterToV2Card( + { schema: '1.0', body: { elements: [] } }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('rejects a custom card that already occupies the canonical footer id', () => { + expect(appendReplyCardFooterToV2Card( + { + schema: '2.0', + body: { + elements: [{ + tag: 'column_set', + columns: [{ + tag: 'column', + elements: [{ tag: 'markdown', element_id: 'botmux_reply_footer', content: 'x' }], + }], + }], + }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('rejects footer-id collisions in localized header tag components', () => { + expect(appendReplyCardFooterToV2Card( + { + schema: '2.0', + header: { + i18n_text_tag_list: { + zh_cn: [{ + tag: 'text_tag', + element_id: 'botmux_reply_footer', + text: { tag: 'plain_text', content: '状态' }, + }], + }, + }, + body: { elements: [{ tag: 'markdown', content: 'body' }] }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + )).toBeNull(); + }); + + it('does not treat callback payload fields as card element-id collisions', () => { + const card = appendReplyCardFooterToV2Card( + { + schema: '2.0', + body: { + elements: [{ + tag: 'button', + text: { tag: 'plain_text', content: '提交' }, + behaviors: [{ + type: 'callback', + value: { tag: 'deploy', element_id: 'botmux_reply_footer' }, + }], + }], + }, + }, + { brand: '', recipientOpenIds: ['ou_owner'] }, + ) as any; + + expect(card).not.toBeNull(); + expect(card.body.elements.at(-1).element_id).toBe('botmux_reply_footer'); + }); + + it('signs a default-brand-only footer with the versioned marker', () => { + const footer = buildReplyCardFooter({}); + expect(footer?.content).toContain(DEFAULT_BRAND_LABEL); + expect(footer?.content).toContain( + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + ); + }); +}); + describe('hasMarkdown', () => { it('detects fences', () => expect(hasMarkdown('a\n```\nx\n```')).toBe(true)); it('detects headings', () => expect(hasMarkdown('# title')).toBe(true)); @@ -705,6 +927,9 @@ describe('brandFooterSegment', () => { it('custom string → verbatim (markdown allowed)', () => { expect(brandFooterSegment('[Acme](https://acme.test)')).toBe('[Acme](https://acme.test)'); }); + it('normalizes custom brands to the footer single-line invariant', () => { + expect(brandFooterSegment(' Acme\n Team\r\nBot ')).toBe('Acme Team Bot'); + }); }); describe('buildMarkdownCard footer brand', () => { @@ -877,6 +1102,24 @@ describe('buildImageCardElements', () => { }); describe('buildContextualReplyCard footer brand', () => { + it('renders the same native usage footer as a regular reply card', () => { + const els = JSON.parse(buildContextualReplyCard({ + title: 'T', + assistantText: 'a', + assistantLabel: 'Codex', + recipientOpenId: 'ou_x', + usage: { + context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, + tokens: { in: 3_739_570, out: 23_299 }, + }, + })).body.elements; + const footer = els.at(-1).content; + + expect(footer).toContain('上下文 159.9K/258.4K (62%)'); + expect(footer).toContain('Token ↑3.7M ↓23.3K'); + expect(footer).toContain(''); + }); + it('custom brand renders; default botmux omitted', () => { const els = JSON.parse(buildContextualReplyCard({ title: 'T', assistantText: 'a', assistantLabel: 'Claude', recipientOpenId: 'ou_x', brand: 'Acme', diff --git a/test/message-parser.test.ts b/test/message-parser.test.ts index ba036b520..8ae8ef21c 100644 --- a/test/message-parser.test.ts +++ b/test/message-parser.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; import { parseApiMessage, extractResources, parseEventMessage, stripLeadingMentions, createImgNumberer, cardContentHasUpgradeFallback, isPureCardUpgradeFallback, mergeCardText, wrapResolvedCardText, mentionOpenId, CARD_EMBEDDED_PLACEHOLDER } from '../src/im/lark/message-parser.js'; -import { buildMarkdownCard } from '../src/im/lark/md-card.js'; +import { buildMarkdownCard, buildReplyCardFooter } from '../src/im/lark/md-card.js'; // ─── Helpers ────────────────────────────────────────────────────────────── @@ -303,6 +303,334 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('botmux'); }); + it('preserves an ambiguous legacy default-brand-only line', () => { + const formatA = { + elements: [[ + { tag: 'a', text: 'botmux', href: 'https://github.com/deepcoldy/botmux' }, + ]], + }; + const formatB = { + body: { elements: [{ + tag: 'markdown', + text_size: 'notation_small_v2', + content: "[botmux](https://github.com/deepcoldy/botmux)", + }] }, + }; + + expect(parseApiMessage(makeMsg('interactive', formatA)).content) + .toContain('botmux(https://github.com/deepcoldy/botmux)'); + expect(parseApiMessage(makeMsg('interactive', formatB)).content) + .toContain('[botmux](https://github.com/deepcoldy/botmux)'); + }); + + it('only applies marker-less legacy footer compatibility at the card tail', () => { + const formatA = { + elements: [ + [ + { tag: 'a', text: 'botmux', href: 'https://github.com/deepcoldy/botmux' }, + { tag: 'text', text: ' · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + [{ tag: 'text', text: '后续正文' }], + ], + }; + const formatB = { + body: { elements: [ + { + tag: 'markdown', + text_size: 'notation_small_v2', + content: "[botmux](https://github.com/deepcoldy/botmux) · 发送给:", + }, + { tag: 'markdown', content: '后续正文' }, + ] }, + }; + + expect(parseApiMessage(makeMsg('interactive', formatA)).content).toContain('botmux'); + expect(parseApiMessage(makeMsg('interactive', formatB)).content).toContain('[botmux]'); + }); + + it('drops a Format A usage-only footer when the bot brand is disabled', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: '上下文 159.9K/258.4K (62%)' }, + { tag: 'text', text: ' · Token ↑3.7M ↓23.3K ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('上下文 159.9K'); + expect(result.content).not.toContain('Token ↑3.7M'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A token-only footer when context is missing', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Token ↑3.7M ↓23.3K ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Token ↑3.7M'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A context-only footer when cumulative tokens are missing', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: '上下文 159.9K/258.4K (62%) ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { tag: 'text', text: ' 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('上下文 159.9K'); + expect(result.content).not.toContain('发送给'); + }); + + it('drops a Format A custom-brand footer through its stable hidden marker', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Acme · 发送给:' }, + { tag: 'at', user_name: 'Owner' }, + { + tag: 'a', + text: '\u200B', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer', + }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('发送给'); + }); + + it('accepts a semantically equivalent decoded marker URL from Lark', () => { + const card = { + elements: [ + [{ tag: 'text', text: '正文内容' }], + [ + { tag: 'text', text: 'Acme ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/botmux#reply-card-footer-v1', + }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + }); + + it('preserves marker-prefix URLs and exact marker URLs with ordinary link text', () => { + const formatA = { + elements: [ + [{ + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1-guide', + }], + [{ + tag: 'a', + text: '协议文档', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }], + ], + }; + const formatB = { + body: { elements: [ + { + tag: 'markdown', + content: "" + + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1-guide)' + + '', + }, + { + tag: 'markdown', + content: '[协议文档](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1)', + }, + ] }, + }; + + const textA = parseApiMessage(makeMsg('interactive', formatA)).content; + expect(textA).toContain('reply-card-footer-v1-guide'); + expect(textA).toContain('协议文档'); + const textB = parseApiMessage(makeMsg('interactive', formatB)).content; + expect(textB).toContain('reply-card-footer-v1-guide'); + expect(textB).toContain('协议文档'); + }); + + it('drops an English custom-brand footer through its visible separator marker', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { tag: 'text', text: 'Acme ' }, + { + tag: 'a', + text: '·', + href: 'https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1', + }, + { + tag: 'text', + text: ' Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('Context 50.3K'); + expect(result.content).not.toContain('Tokens ↑1M'); + expect(result.content).not.toContain('Sent to'); + }); + + it('keeps a marker-less English usage-and-recipient paragraph as user data', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { + tag: 'text', + text: 'Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).toContain('Context 50.3K/258.4K (19%)'); + expect(result.content).toContain('Tokens ↑1M ↓2K'); + expect(result.content).toContain('Sent to: @Owner'); + }); + + it('keeps a marker-less custom-brand footer-shaped paragraph as user data', () => { + const card = { + elements: [ + [{ tag: 'text', text: 'body' }], + [ + { + tag: 'text', + text: 'Acme · Context 50.3K/258.4K (19%) · Tokens ↑1M ↓2K · Sent to: ', + }, + { tag: 'at', user_name: 'Owner' }, + ], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('Acme · Context 50.3K/258.4K (19%)'); + expect(result.content).toContain('Tokens ↑1M ↓2K'); + expect(result.content).toContain('Sent to: @Owner'); + }); + + it('keeps ordinary prose that mentions context and tokens', () => { + const card = { + elements: [[ + { tag: 'text', text: '上下文和 Token 是两个不同指标,请分别分析。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文和 Token 是两个不同指标'); + }); + + it('keeps a usage-shaped body paragraph when it is not the final paragraph', () => { + const card = { + elements: [ + [{ tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }], + [{ tag: 'text', text: '这是正文中的观测值,不是页脚。' }], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + expect(result.content).toContain('这是正文中的观测值'); + }); + + it('keeps a pure usage-shaped final body paragraph when no recipient chrome proves it is a footer', () => { + const card = { + elements: [ + [{ tag: 'text', text: '本轮统计如下:' }], + [{ tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }], + ], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('本轮统计如下'); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + }); + + it('keeps a single usage-shaped body paragraph instead of reducing the card to a placeholder', () => { + const card = { + elements: [[ + { tag: 'text', text: '上下文 12.3K/100K (12%) · Token ↑67.9K ↓123' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + expect(result.content).not.toBe('[卡片]'); + }); + + it('keeps a sentence that merely contains the compact usage shape', () => { + const card = { + elements: [[ + { tag: 'text', text: '请记录:上下文 12.3K/100K (12%) · Token ↑67.9K ↓123,稍后对比。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('请记录:上下文'); + expect(result.content).toContain('稍后对比'); + }); + + it('keeps a usage-shaped final line when it belongs to the same body paragraph', () => { + const card = { + elements: [[ + { + tag: 'text', + text: '正文中的指标如下:\n上下文 12.3K/100K (12%) · Token ↑67.9K ↓123', + }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文中的指标如下'); + expect(result.content).toContain('上下文 12.3K/100K (12%)'); + }); + it('round-trips a real buildMarkdownCard output without footer leakage', () => { const raw = buildMarkdownCard('帮我看下这个 bug', 'ou_owner'); const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); @@ -311,6 +639,14 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = expect(result.content).not.toContain('发送给'); }); + it('round-trips a footer whose custom brand contains an unmatched bracket', () => { + const raw = buildMarkdownCard('正文内容', 'ou_owner', 'Acme [beta'); + const result = parseApiMessage(makeMsg('interactive', JSON.parse(raw))); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme [beta'); + expect(result.content).not.toContain('发送给'); + }); + it('keeps body text that merely mentions botmux without the repo link', () => { // The filter anchors on the canonical repo URL, so genuine prose about // botmux survives — only the footer chrome is removed. @@ -322,21 +658,81 @@ describe('Interactive card parsing: botmux footer is stripped from prompt', () = const result = parseApiMessage(makeMsg('interactive', card)); expect(result.content).toContain('botmux 这个项目挺好用的'); }); + + it('keeps a real canonical Botmux repository link in Format A body text', () => { + const card = { + elements: [[ + { tag: 'text', text: '项目地址:' }, + { + tag: 'a', + text: 'botmux', + href: 'https://github.com/deepcoldy/botmux', + }, + { tag: 'text', text: ',请查看 README。' }, + ]], + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain( + '项目地址:botmux(https://github.com/deepcoldy/botmux),请查看 README。', + ); + }); + + it('keeps a real canonical Botmux repository link in Format B body text', () => { + const card = { + body: { elements: [{ + tag: 'markdown', + content: '项目地址:[botmux](https://github.com/deepcoldy/botmux),请查看 README。', + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain( + '项目地址:[botmux](https://github.com/deepcoldy/botmux),请查看 README。', + ); + }); }); // ─── Structural footer strip (brand-agnostic, for per-bot custom brands) ── describe('Interactive card parsing: footer stripped structurally (custom brand)', () => { - it('drops a custom-brand grey notation_small_v2 footer element (no botmux URL to anchor on)', () => { - // A peer bot configured brandLabel='Acme' — the receiving bot can't know - // that label, so the URL anchor can't catch it. The text_size+grey - // structural signal does. + it('drops a footer carrying the complete Botmux structural signature', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + recipientOpenIds: ['ou_owner'], + })!; + const card = { + body: { elements: [ + { tag: 'markdown', content: '正文内容' }, + { tag: 'hr' }, + footer.element, + ] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('发送给'); + }); + + it('keeps third-party body content that only collides with the public element id', () => { + const card = { + body: { elements: [{ + tag: 'markdown', + element_id: 'botmux_reply_footer', + content: '这是第三方卡片正文', + }] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('这是第三方卡片正文'); + }); + + it('drops a custom-brand grey footer carrying the stable separator marker', () => { const card = { body: { elements: [ { tag: 'markdown', content: '正文内容' }, { tag: 'hr' }, { tag: 'markdown', text_size: 'notation_small_v2', - content: "[Acme](https://acme.test) · 发送给:" }, + content: "[Acme](https://acme.test) " + + '[·](https://github.com/deepcoldy/bot%6Dux#reply-card-footer-v1) ' + + '发送给:' }, ] }, }; const result = parseApiMessage(makeMsg('interactive', card)); @@ -345,6 +741,56 @@ describe('Interactive card parsing: footer stripped structurally (custom brand)' expect(result.content).not.toContain('发送给'); }); + it('keeps a third-party grey notation element without a Botmux id or marker', () => { + const card = { + body: { elements: [ + { tag: 'markdown', content: '正文内容' }, + { tag: 'hr' }, + { + tag: 'markdown', + text_size: 'notation_small_v2', + content: "报警来源:第三方监控系统", + }, + ] }, + }; + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('正文内容'); + expect(result.content).toContain('报警来源:第三方监控系统'); + }); + + it('drops the canonical footer nested beside a voice button', () => { + const footer = buildReplyCardFooter({ + brand: 'Acme', + recipientOpenIds: ['ou_owner'], + locale: 'en', + })!; + const card = { + body: { elements: [ + { tag: 'markdown', content: 'body' }, + { tag: 'hr' }, + { + tag: 'column_set', + columns: [ + { tag: 'column', elements: [footer.element] }, + { + tag: 'column', + elements: [{ + tag: 'button', + text: { tag: 'plain_text', content: 'Voice summary' }, + }], + }, + ], + }, + ] }, + }; + + const result = parseApiMessage(makeMsg('interactive', card)); + expect(result.content).toContain('body'); + expect(result.content).toContain('[Voice summary]'); + expect(result.content).not.toContain('Acme'); + expect(result.content).not.toContain('Sent to'); + }); + it('keeps a small-text element that is NOT a grey footer (foreign card content survives)', () => { // notation_small_v2 alone is not enough — the botmux footer is always grey. // A foreign card's small note without grey font must not be dropped. diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index 38a806b45..8dddff875 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -83,6 +83,16 @@ describe('buildBotmuxEnvAssignments()', () => { expect(buildBotmuxEnvAssignments({ BOTMUX: '1' }).some(s => s.startsWith('BOTMUX_OWNER_OPEN_ID='))).toBe(false); }); + it('forwards the reply-card usage visibility into persistent CLI panes', () => { + const out = buildBotmuxEnvAssignments({ + BOTMUX: '1', + BOTMUX_USAGE_DISPLAY: 'footer', + PATH: '/usr/bin', + }); + expect(out).toContain('BOTMUX_USAGE_DISPLAY=footer'); + expect(out).not.toContain('PATH=/usr/bin'); + }); + it('forwards CLAUDE_CODE_RESUME_TOKEN_THRESHOLD so the resume-summary bypass reaches the tmux pane (issue #62)', () => { // The worker injects this for claude-code to suppress Claude Code 2.1.x's // blocking resume-summary menu. Under the tmux backend it ONLY reaches the diff --git a/test/transcript-resolver-bot-home.test.ts b/test/transcript-resolver-bot-home.test.ts index fa95c6253..bd12ef83a 100644 --- a/test/transcript-resolver-bot-home.test.ts +++ b/test/transcript-resolver-bot-home.test.ts @@ -1,5 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, utimesSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + realpathSync, + symlinkSync, + unlinkSync, + utimesSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -18,7 +27,11 @@ vi.mock('node:os', async (importOriginal) => ({ homedir: () => fake.home, })); -import { resolveSessionTranscriptPath } from '../src/services/transcript-resolver.js'; +import { + __resetTranscriptResolverCacheForTest, + resolveSessionTranscriptPath, + cliSupportsNativeUsage, +} from '../src/services/transcript-resolver.js'; const APP_ID = 'cli_testbot0001'; @@ -31,9 +44,13 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () let base: string; let cwd: string; let savedSessionDataDir: string | undefined; + let savedCodexHome: string | undefined; beforeEach(() => { savedSessionDataDir = process.env.SESSION_DATA_DIR; + savedCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; + __resetTranscriptResolverCacheForTest(); base = mkdtempSync(join(tmpdir(), 'botmux-bot-home-')); trash.push(base); fake.home = join(base, 'home'); @@ -46,6 +63,8 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () afterEach(() => { if (savedSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; else process.env.SESSION_DATA_DIR = savedSessionDataDir; + if (savedCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = savedCodexHome; for (const d of trash.splice(0)) rmSync(d, { recursive: true, force: true }); }); @@ -65,6 +84,22 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () return p; } + function writeCodexRollout(codexHome: string, sid: string): string { + const dir = join(codexHome, 'sessions', '2026', '07', '29'); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `rollout-2026-07-29T12-00-00-${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + function writeCodexHistory(codexHome: string, botmuxSid: string, codexSid: string): void { + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, 'history.jsonl'), JSON.stringify({ + session_id: codexSid, + text: `resume ${botmuxSid}`, + }) + '\n'); + } + it('falls back to /bots//claude when the global dir misses', () => { const expected = writeBotHomeTranscript('sb-1'); const resolved = resolveSessionTranscriptPath({ @@ -143,4 +178,248 @@ describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () }); expect(resolved).toEqual({ path: expected, kind: 'claude' }); }); + + it('resolves a Codex rollout under the sandboxed bot CODEX_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4ec'; + const expected = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-1', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toEqual({ path: expected, kind: 'codex' }); + }); + + it('maps a botmux session through sandboxed Codex history when cliSessionId is absent', () => { + const botmuxSid = 'botmux-codex-2'; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4ed'; + const codexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexHistory(codexHome, botmuxSid, codexSid); + const expected = writeCodexRollout(codexHome, codexSid); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: botmuxSid, + larkAppId: APP_ID, + fresh: true, + })).toEqual({ path: expected, kind: 'codex' }); + }); + + it('picks the newer Codex rollout when a session straddles a sandbox flip', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4ee'; + const global = writeCodexRollout(join(fake.home, '.codex'), sid); + const botHome = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + utimesSync(global, new Date('2026-01-01'), new Date('2026-01-01')); + utimesSync(botHome, new Date('2026-01-02'), new Date('2026-01-02')); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-flip', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })?.path).toBe(botHome); + }); + + it('keys the Codex path cache by the effective CODEX_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f0'; + const firstHome = join(base, 'codex-global-first'); + const secondHome = join(base, 'codex-global-second'); + const first = writeCodexRollout(firstHome, sid); + const second = writeCodexRollout(secondHome, sid); + process.env.CODEX_HOME = firstHome; + + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-dynamic-home', + cliSessionId: sid, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)?.path).toBe(first); + + process.env.CODEX_HOME = secondHome; + expect(resolveSessionTranscriptPath(query)?.path).toBe(second); + }); + + it('keeps normal CODEX_HOME root symlinks compatible', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f6'; + const actualHome = join(base, 'codex-global-symlink-target'); + writeCodexRollout(actualHome, sid); + const linkedHome = join(base, 'codex-global-symlink'); + symlinkSync(actualHome, linkedHome, 'dir'); + process.env.CODEX_HOME = linkedHome; + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-global-symlink', + cliSessionId: sid, + fresh: true, + })?.path).toBe(join( + linkedHome, + 'sessions', + '2026', + '07', + '29', + `rollout-2026-07-29T12-00-00-${sid}.jsonl`, + )); + }); + + it('falls back to BOT_HOME when a cached global Codex rollout disappears', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f1'; + const globalHome = join(base, 'codex-global-stale'); + process.env.CODEX_HOME = globalHome; + const global = writeCodexRollout(globalHome, sid); + const botHome = writeCodexRollout( + join(base, '.botmux', 'bots', APP_ID, 'codex'), + sid, + ); + utimesSync(global, new Date('2026-01-02'), new Date('2026-01-02')); + utimesSync(botHome, new Date('2026-01-01'), new Date('2026-01-01')); + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-stale-global', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)?.path).toBe(global); + + unlinkSync(global); + expect(resolveSessionTranscriptPath(query)?.path).toBe(botHome); + }); + + it('does not follow a sandboxed Codex sessions symlink outside BOT_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f2'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling', 'codex'); + writeCodexRollout(siblingHome, sid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + mkdirSync(botCodexHome, { recursive: true }); + symlinkSync(join(siblingHome, 'sessions'), join(botCodexHome, 'sessions'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('does not follow a sandboxed Codex root symlink outside BOT_HOME', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f5'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_root', 'codex'); + writeCodexRollout(siblingHome, sid); + const botHome = join(base, '.botmux', 'bots', APP_ID); + mkdirSync(botHome, { recursive: true }); + symlinkSync(siblingHome, join(botHome, 'codex'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-root-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('does not follow a nested directory symlink while scanning Codex sessions', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f4'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_nested', 'codex'); + writeCodexRollout(siblingHome, sid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + mkdirSync(join(botCodexHome, 'sessions'), { recursive: true }); + symlinkSync( + join(siblingHome, 'sessions', '2026'), + join(botCodexHome, 'sessions', '2026'), + 'dir', + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: 'botmux-codex-nested-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); + + it('invalidates a cached Codex rollout after a parent directory becomes a symlink', () => { + const sid = '019dd80d-d922-7a11-8339-0208d8c5b4f7'; + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexRollout(botCodexHome, sid); + const query = { + cliId: 'codex' as const, + sessionId: 'botmux-codex-cached-parent-symlink', + cliSessionId: sid, + larkAppId: APP_ID, + fresh: true, + }; + expect(resolveSessionTranscriptPath(query)).not.toBeNull(); + + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling_cached', 'codex'); + writeCodexRollout(siblingHome, sid); + rmSync(join(botCodexHome, 'sessions'), { recursive: true }); + symlinkSync(join(siblingHome, 'sessions'), join(botCodexHome, 'sessions'), 'dir'); + + expect(resolveSessionTranscriptPath(query)).toBeNull(); + }); + + it('does not follow a sandboxed Claude projects symlink outside BOT_HOME', () => { + const sid = 'claude-projects-symlink'; + const siblingClaude = join(base, '.botmux', 'bots', 'cli_sibling_claude', 'claude'); + const siblingProject = join(siblingClaude, 'projects', projectKey(cwd)); + mkdirSync(siblingProject, { recursive: true }); + writeFileSync(join(siblingProject, `${sid}.jsonl`), '{}'); + const botClaude = join(base, '.botmux', 'bots', APP_ID, 'claude'); + mkdirSync(botClaude, { recursive: true }); + symlinkSync(join(siblingClaude, 'projects'), join(botClaude, 'projects'), 'dir'); + + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', + sessionId: sid, + cwd, + larkAppId: APP_ID, + })).toBeNull(); + }); + + it('does not follow a sandboxed Codex history symlink', () => { + const botmuxSid = 'botmux-codex-history-symlink'; + const codexSid = '019dd80d-d922-7a11-8339-0208d8c5b4f3'; + const siblingHome = join(base, '.botmux', 'bots', 'cli_sibling', 'codex'); + writeCodexHistory(siblingHome, botmuxSid, codexSid); + const botCodexHome = join(base, '.botmux', 'bots', APP_ID, 'codex'); + writeCodexRollout(botCodexHome, codexSid); + symlinkSync( + join(siblingHome, 'history.jsonl'), + join(botCodexHome, 'history.jsonl'), + 'file', + ); + + expect(resolveSessionTranscriptPath({ + cliId: 'codex', + sessionId: botmuxSid, + larkAppId: APP_ID, + fresh: true, + })).toBeNull(); + }); +}); + +describe('cliSupportsNativeUsage', () => { + it('is true only for CLIs with a resolvable transcript (sync with the resolver switch)', () => { + for (const id of ['claude-code', 'aiden', 'seed', 'relay', 'codex', 'coco', 'cursor', 'traex', 'antigravity']) { + expect(cliSupportsNativeUsage(id)).toBe(true); + } + // CLIs the resolver's switch has no case for → no native usage → hide the UI. + for (const id of ['gemini', 'opencode', 'pi', 'mtr', 'hermes', 'kiro-cli', 'unknown', undefined]) { + expect(cliSupportsNativeUsage(id)).toBe(false); + } + }); }); diff --git a/test/worker-riff-env.integration.test.ts b/test/worker-riff-env.integration.test.ts new file mode 100644 index 000000000..72f5e0015 --- /dev/null +++ b/test/worker-riff-env.integration.test.ts @@ -0,0 +1,157 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { DaemonToWorker, WorkerToDaemon } from '../src/types.js'; + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectPromise); + resolvePromise(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Riff test server has no TCP address'); + return address.port; +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.once('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8'))); + req.once('error', rejectPromise); + }); +} + +describe('Riff worker session environment', () => { + it('forwards a disabled reply-card usage switch into the remote sandbox', async () => { + const root = mkdtempSync(join(tmpdir(), 'botmux-worker-riff-env-')); + const sockets = new Set(); + let child: ChildProcess | undefined; + let settleRequest!: (body: Record) => void; + let rejectRequest!: (error: Error) => void; + let requestSettled = false; + const taskExecuteRequest = new Promise>((resolvePromise, rejectPromise) => { + settleRequest = body => { + if (requestSettled) return; + requestSettled = true; + resolvePromise(body); + }; + rejectRequest = error => { + if (requestSettled) return; + requestSettled = true; + rejectPromise(error); + }; + }); + + const server = createServer(async (req, res) => { + if (req.url === '/api/task-execute' && req.method === 'POST') { + try { + settleRequest(JSON.parse(await readRequestBody(req))); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { id: 'task-env-1', status: 'running' } })); + } catch (error) { + rejectRequest(error instanceof Error ? error : new Error(String(error))); + res.writeHead(400); + res.end(); + } + return; + } + if (req.url?.startsWith('/api2/task-stream')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(': keepalive\n\n'); + return; + } + if (req.url?.startsWith('/api/task-detail')) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { task: {} } })); + return; + } + if (req.url === '/api/task-cancel') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + return; + } + res.writeHead(404); + res.end(); + }); + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + try { + const port = await listen(server); + const appId = 'app_riff_usage_hidden'; + const botsPath = join(root, 'bots.json'); + writeFileSync(botsPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'riff', + backendType: 'riff', + riff: { baseUrl: `http://127.0.0.1:${port}` }, + usageDisplay: 'footer', + }])); + + const logs: string[] = []; + child = spawn(process.execPath, ['--import', 'tsx', resolve('src/worker.ts')], { + cwd: resolve('.'), + env: { + ...process.env, + HOME: root, + SESSION_DATA_DIR: root, + BOTS_CONFIG: botsPath, + BOTMUX_SESSION_ID: 'sid-riff-env', + LARK_APP_ID: appId, + LARK_APP_SECRET: 'secret', + }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + child.stdout?.on('data', chunk => logs.push(chunk.toString())); + child.stderr?.on('data', chunk => logs.push(chunk.toString())); + child.on('message', (raw) => { + const msg = raw as WorkerToDaemon; + if (msg.type === 'error') { + rejectRequest(new Error(`worker error: ${msg.message}\n${logs.join('')}`)); + } + }); + child.once('exit', (code, signal) => { + rejectRequest(new Error(`worker exited before task-execute (${code ?? signal})\n${logs.join('')}`)); + }); + + const init: DaemonToWorker = { + type: 'init', + sessionId: 'sid-riff-env', + chatId: 'oc_riff_env', + rootMessageId: 'om_riff_env', + workingDir: root, + cliId: 'riff', + backendType: 'riff', + backendConfig: { baseUrl: `http://127.0.0.1:${port}`, injectStatusLines: false }, + prompt: 'verify remote session environment', + larkAppId: appId, + larkAppSecret: 'secret', + }; + child.send(init); + + const request = await Promise.race([ + taskExecuteRequest, + new Promise((_, rejectPromise) => { + setTimeout(() => rejectPromise(new Error(`task-execute timeout\n${logs.join('')}`)), 15_000); + }), + ]); + expect(request.config?.env?.BOTMUX_USAGE_DISPLAY).toBe('footer'); + } finally { + if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + for (const socket of sockets) socket.destroy(); + await new Promise(resolvePromise => server.close(() => resolvePromise())); + rmSync(root, { recursive: true, force: true }); + } + }, 25_000); +});