diff --git a/.superpowers/sdd/2026-08-28-pin-streaming-card/task-3-5-report.md b/.superpowers/sdd/2026-08-28-pin-streaming-card/task-3-5-report.md new file mode 100644 index 000000000..0100af658 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-pin-streaming-card/task-3-5-report.md @@ -0,0 +1,123 @@ +# Tasks 3-5: 实时卡片 Pin 生命周期 + +## Status + +Implemented as one lifecycle unit. The focused lifecycle matrix and build pass. + +## Files + +- `src/core/worker-pool.ts` +- `src/im/lark/card-handler.ts` +- `test/streaming-card-pinning.test.ts` + +## TDD evidence + +### Task 3 policy + +- RED: `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts` failed with `pinStreamingCardIfEnabled is not a function` and `reconcileStreamingCardPins is not a function`. +- GREEN: `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts` passed: 63 tests. +- Coverage added: default-off and invalid/sentinel/inactive/displaced refusals, ownership/current-ID checking, stale post-Pin compensating Unpin, enable Pin-before-frozen-Unpin, disable deduplicated cleanup, and cross-topic frozen IDs. + +### Tasks 4-5 integration + +- RED diagnostic: the first expanded publication matrix exposed a real fence-scope regression (`ReferenceError: ownsFreshReadyPost is not defined`) and one dependent worker-ready assertion failed. +- GREEN after fix: publication/resume matrix passed: 142 tests. +- Close/transfer matrix passed: 140 tests. + +## Verification + +- Focused final matrix: 9 files, 279 tests passed. +- Build: `mise exec bun@1.4.0 -- bun run build` passed. +- `git diff --check` passed. +- Full unit suite: completed with 18,803 passed and 14 failed, plus 17 skipped. Failures are unrelated baseline/environment issues: `/home` versus `/data00/home` path-alias expectations in runtime/plugin/npm/OMP/Grok tests, Bubblewrap sandbox cleanup permissions and MCP connection closure, process-name expectation under Bun (`MainThread` vs `node`), and DSH sandbox timeout. None are in the Pin lifecycle matrix. + +## Race invariants reviewed + +- Only real `streamCardId` values are eligible; the posting sentinel is rejected. +- Pin checks active status, route ownership, object/current-ID identity, transfer state, and preference before and after the request. A stale successful Pin gets a best-effort compensating Unpin of the captured ID. +- Fresh `/card`, worker-ready, and screen-update POST paths recheck captured session/app/anchor/registry/nonce state before commit; stale posts are deleted without Pin. +- New cards persist before Pin; frozen IDs are unpinned only after successor Pin succeeds; destination-sensitive `recallFrozenCards` remains unchanged and always executes afterward. +- Persisted reuse reconciles the live ID after its PATCH. Resume repost captures identity before POST and deletes stale results without Pin. +- Transfer and close snapshot/deduplicate IDs before source fields or frozen sidecars disappear, then launch best-effort Unpin only after their durable commit. Neither path awaits Unpin; refused or failed durable close does no cleanup. + +## Self-review and concerns + +- Pin/Unpin failures are fail-open and do not change publication, resume, transfer, or close results. +- No repo picker, private card, final card, CoT, or closed-card path calls the policy; calls are restricted to `streamCardId` lifecycle points. +- The intentional scope has no durable Pin-operation journal or chat-wide Pin scan. A crash between remote mutation and later lifecycle reconciliation can leave a stale Pin until a later known lifecycle boundary; this matches the approved QoL/fail-open design. + +## Fix round 1/5 + +### Findings resolved + +- Publication continuations now capture/deduplicate frozen predecessor IDs before awaiting the successor Pin. `reconcilePublishedStreamingCard` returns whether the captured real card is still the authoritative current identity after Pin; turn-start, `/card`, worker-ready fresh POST, and screen-update POST use that result to skip `recallFrozenCards` and all later post-publication mutation if a successor won the race. +- Screen-update POST rejection now requires both the broad lifecycle fence and its captured POST nonce/sentinel fence before rollback, clear, or persistence. A rejected stale request therefore cannot erase a successor `streamCardId`. +- Resume repost rechecks its captured session/app/registry/current-card identity after its awaited Pin before deleting the stale predecessor or sending a receipt. +- Removed the obsolete post-Pin frozen-ID snapshot helper: predecessor IDs are now always captured before the await. +- Added actual-path coverage for deferred ready Pin ownership loss, deferred screen-update POST rejection, transfer source cleanup after routing commit, and close’s non-deleting asynchronous Unpin. Existing focused harnesses continue to cover turn-start, `/card`, worker-ready reuse/fresh, screen-update, resume, transfer, and close. + +### TDD evidence + +- RED: the deferred worker-ready Pin test failed because the older continuation called `recallFrozenCards`, deleting `om_frozen_predecessor` after a successor became current. +- GREEN: after fencing post-Pin continuation effects, that test passes and confirms the stale card gets only a compensating Unpin. +- Screen-update deferred rejection test confirms a stale POST error leaves `om_successor` unchanged. + +### Verification + +- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 282 tests passed. +- `mise exec bun@1.4.0 -- bun run build` — passed. +- `git diff --check` — passed (no output). + +## Persisted worker-ready reuse PATCH rejection fence + +### Finding resolved + +- The persisted reuse restore-PATCH rejection path now uses the same captured restored-card ownership fence as its success path. If a successor wins while `updateMessage` is in flight, the old catch exits without clearing `streamCardId` or falling through to a fresh-card POST. +- If the restored card still owns the authoritative identity, the previous PATCH-failure fallback remains unchanged: clear the failed restored ID and continue to the normal fresh POST path. + +### TDD evidence + +- RED: deferred `updateMessage` rejection after successor takeover changed `om_successor` into fallback `om_new_card`. +- GREEN: the successor remains current and no fallback POST is sent after the stale rejection. + +### Verification + +- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 288 tests passed. +- `mise exec bun@1.4.0 -- bun run build` — passed. +- `git diff --check` — passed (no output). + +## Persisted worker-ready reuse race fix + +### Finding resolved + +- The persisted worker-ready reuse branch now captures session, app, anchor, registry key, and `restoredCardId` before its restore PATCH/Pin reconciliation. After the awaited reconciliation it rechecks that the captured card is still the authoritative current identity before recalling frozen cards, publishing reuse completion, or arming the usage refresh. +- A lost-ownership old restore stays fail-open: Pin policy compensates the old Pin with Unpin, while the reuse continuation leaves successor-owned card and frozen state untouched. + +### TDD evidence + +- RED: a deferred Pin during the real persisted worker-ready reuse path removed `om_frozen_predecessor` after `streamCardId` changed to `om_successor`. +- GREEN: the same test preserves `om_successor` and its frozen entry, emits no predecessor deletion, and verifies compensating Unpin of `om_restored_card`. + +### Verification + +- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 287 tests passed. +- `mise exec bun@1.4.0 -- bun run build` — passed. +- `git diff --check` — passed (no output). + +## Fix round 2/5 + +### Finding resolved + +- A stale publication after an awaited Pin must suppress captured-card mutation, but it must not strand a later turn already marked pending. Turn-start, worker-ready fresh POST, and screen-update POST now retain their successor-card scheduling path after `reconcilePublishedStreamingCard()` reports lost ownership. +- The scheduling predicate rechecks the live `streamCardTurnGeneration` after the await instead of relying on the pre-await `superseded` snapshot. This preserves the successor's liveness while the captured-ID fence still excludes recall, refresh patches, timer arming, and other old-card side effects. + +### TDD evidence + +- RED: three deferred-Pin liveness tests failed with only one POST: turn-start, worker-ready, and screen-update each left the successor pending card unscheduled after the older card lost ownership. +- GREEN: each orchestration shape now posts its successor (two POSTs total) and clears the successor pending turn, while the earlier concurrency test continues to prove stale continuations do not recall the successor state. + +### Verification + +- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 285 tests passed. +- `mise exec bun@1.4.0 -- bun run build` — passed. +- `git diff --check` — passed (no output). diff --git a/.superpowers/sdd/2026-08-28-pin-streaming-card/task-6-7-report.md b/.superpowers/sdd/2026-08-28-pin-streaming-card/task-6-7-report.md new file mode 100644 index 000000000..d3be3cd26 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-pin-streaming-card/task-6-7-report.md @@ -0,0 +1,71 @@ +# Tasks 6-7: 实时卡片 Pin 热重算与文档 + +## Status + +Implemented as one hot-reconciliation and documentation unit. The focused Task 6/7 matrix passed after fixing the active-session key collision in the multi-session reconciliation coverage. + +## Files + +- `src/services/pin-streaming-card-change.ts` +- `src/services/bot-config-store.ts` +- `src/services/card-prefs-store.ts` +- `src/daemon.ts` +- `test/pin-streaming-card-change.test.ts` +- `test/bot-config-store.test.ts` +- `test/card-prefs-auto-start.test.ts` +- `test/command-handler.test.ts` +- `test/dashboard-ipc.test.ts` +- `test/streaming-card-pinning.test.ts` +- `docs-site/docs/zh/bots-json.md` +- `docs-site/docs/en/bots-json.md` +- `docs-site/docs/zh/cards.md` +- `docs-site/docs/en/cards.md` + +## TDD evidence + +### Task 6 hot reconciliation seam + +- RED: `mise exec bun@1.4.0 -- bun run test -- test/pin-streaming-card-change.test.ts` failed because `src/services/pin-streaming-card-change.ts` did not exist. +- GREEN: the new process-local callback seam passed coverage for registration, disposal, replacement, and swallowed/logged handler failures. +- Store-path RED diagnostics: the first expanded config-path matrix showed missing notifications and incorrect blocking assumptions in the `/botconfig` and dashboard flows. +- GREEN after wiring: `bot-config-store` now notifies only after successful `pinStreamingCard` write plus live-memory sync; `card-prefs-store` now notifies only when `patch.pinStreamingCard !== undefined` after successful write plus live-memory sync; unrelated patches and failed writes emit nothing. +- Non-blocking confirmation: `/botconfig` and dashboard mutation tests prove the visible response completes even when the registered reconciliation handler throws or does deferred work. + +### Task 6 active-session reconciliation coverage + +- Daemon startup now registers `registerPinStreamingCardChangeHandler(reconcileBotStreamingCardPins)` immediately after `setActiveSessionsRegistry(activeSessions);`, preserving the required `reconcileBotStreamingCardPins(larkAppId, enabled): void` interface and keeping the seam in the services layer to avoid a worker-pool import cycle. +- The multi-session reconciliation test initially failed twice for real harness reasons: first because sessions reused the same `sessionId`, then because `activeSessionKey(ds)` is derived from `rootMessageId` under thread scope and the test still reused the same root anchor. +- GREEN after fix: the helper now supports distinct `sessionId`, `rootMessageId`, and explicit `scope: 'thread'`, and the coverage proves reconciliation snapshots only the target bot's active sessions, ignores other bots, and isolates one session failure from the rest. + +### Task 7 documentation + +- Added bilingual documentation for `pinStreamingCard` in bot config and cards docs. +- The docs now state the exact approved scope: per-bot opt-in, default off, only the current public live-status `streamCardId` participates, hot on/off reconciliation applies to existing active sessions, repo picker/private `/card`/final reply/CoT/closed/other cards remain unpinned, failures are fail-open, temporary zero or multiple Pins are possible, and there is no durable retry journal or full-chat Pin audit after exceptional crashes. + +## Verification + +- Focused matrix: `mise exec bun@1.4.0 -- bun run test -- test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts test/streaming-card-pinning.test.ts` passed. +- Full suite: `mise exec bun@1.4.0 -- bun run test` still reports unrelated baseline failures, not introduced by this unit. Observed failing files remain `test/codex-browser-broker.test.ts`, `test/grok-transcript.test.ts`, `test/npm-binary-distribution.test.ts`, `test/cli-runtime-update.test.ts`, `test/session-discovery.smoke.test.ts`, `test/plugin-mcp-sandbox.test.ts`, and `test/oh-my-pi-legacy-migration.test.ts`. +- Build: `mise exec bun@1.4.0 -- bun run build` passed. +- `git diff --check` passed. + +## Self-review and concerns + +- Notification is intentionally fail-open. Reconciliation handler failures are swallowed and logged so config writes and API responses are never blocked by Feishu Pin/Unpin work. +- The callback seam is process-local and has no durable backlog. This matches the approved scope and means an exceptional crash between remote state mutation and the next lifecycle boundary can leave a stale Pin until a later reconciliation opportunity. +- The implementation preserves the narrow contract: only `streamCardId` participates, `pinStreamingCard` stays default-off, and no broader card classes were added to the policy. + +## Follow-up fix: async handler rejection remains fail-open + +- Review found that TypeScript still allows an `async` `PinStreamingCardChangeHandler` even when the type was declared as `void`, so the original `notifyPinStreamingCardChanged()` only caught synchronous throws and could leak an unhandled Promise rejection. +- The seam contract now explicitly accepts `void | PromiseLike`. `notifyPinStreamingCardChanged()` remains non-blocking, wraps the return value with `Promise.resolve(...)`, and logs any asynchronous rejection via `.catch(...)` while still catching synchronous throws in the outer `try/catch`. +- Added a regression test proving `notifyPinStreamingCardChanged()` does not throw or block when the handler rejects asynchronously, and that the rejection is consumed and logged. + +## Follow-up fix: bot-wide latest-state reconciliation queue + +- Review found a bot-wide race on rapid `pinStreamingCard` toggles: an older disable reconciliation could still finish its deferred Unpin after a newer enable started, leaving the final `on` state without the current card pinned. +- `reconcileBotStreamingCardPins()` now uses a per-`larkAppId` fire-and-forget queue. Different bots still reconcile in parallel, but each bot serializes generations and collapses them to the latest desired state before rerunning. +- Each generation takes a fresh snapshot of matching active sessions only: same `larkAppId`, `session.status === 'active'`, current registry owner for that session key, and not displaced by a newer live owner. Disable uses that same bounded snapshot. +- Inside one bot generation, per-session reconciliation now runs concurrently with `Promise.allSettled(...)`, so one slow or failing session does not block peer sessions while bot-level generations remain ordered. +- The queue teardown now tracks the last absorbed desired version explicitly. Because no `await` occurs between the loop's `break` decision and `finally`, a fresh toggle cannot interleave in that window; deleting the queue only when the settled version still matches the live state avoids dropping a just-arrived notification while keeping the cleanup branch simple. +- Added lifecycle regressions for deferred disable→enable and enable→disable ordering, plus snapshot filtering for inactive and displaced sessions, and a test-only queue reset to prevent cross-test leakage. diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index 78f3b3c67..e89d3ed99 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -189,6 +189,7 @@ This option addresses one narrow gap: Codex running through Botmux's app-server | `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 | +| `pinStreamingCard` | When `true`, the bot **pins the current public live-status card**. It is opt-in and default-off: only an explicit `true` enables it. Only the current public live-status real `streamCardId` participates; repo-picker cards, private `/card` snapshots, final reply cards, CoT, closed cards, and every other interactive card stay out of scope. The switch is hot-updated: once dashboard or `/botconfig set pinStreamingCard on/off` successfully writes local config and changes the effective value, Botmux runs a best-effort reconciliation across this bot's **existing active sessions**. The configuration response does **not wait** for Feishu Pin/Unpin calls. Failures never interrupt publication, transfer, resume, close, or configuration itself; during exceptional periods there may temporarily be zero or multiple Pins. This feature adds **no durable retry journal and no full-chat Pin audit**: an explicit on-to-off transition cleans the session's known live-card IDs; if the setting is already off and process-local provenance was lost, later close or transfer does not risk removing a Pin that may have been created manually | | `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) | | `doneReactionEmoji` | Feishu emoji_type for the "done" reaction in card-off sessions; `undefined` = default `DONE` (✅). Set it equal to `receivedReactionEmoji` to keep the marker unchanged on turn-end — handy for CLIs whose idle detection can fire early (e.g. Pi), avoiding a premature, misleading ✅ | diff --git a/docs-site/docs/en/cards.md b/docs-site/docs/en/cards.md index 85c6f0c80..3022152c3 100644 --- a/docs-site/docs/en/cards.md +++ b/docs-site/docs/en/cards.md @@ -10,6 +10,17 @@ Every conversation turn produces a live-updating Lark card, your primary window - **A fresh card per turn**: the previous card freezes as an archive, keeping conversation history clear and traceable; after a session is moved to another group with [`/relay`](/en/relay), the original card also automatically freezes as an archive (buttons removed). - **A "recoverable" card on close**: it carries a "▶️ Resume session" button to click back in anytime; **if the CLI supports native resume** (the adapter implements `buildResumeCommand` and a native session id exists), it also includes the native command (e.g. `claude --resume `) for manual recovery; when unsupported, only botmux's resume button plus a short note is shown. +## Pinning The Current Live Card + +When a bot enables `pinStreamingCard`, Botmux tries to pin the **current public live-status card** to the top of the chat so the close-session and terminal entry points stay easy to reach. + +- This is a **per-bot, opt-in, default-off** setting. +- Only the current public live-status real `streamCardId` participates. +- Repo-picker cards, private `/card` snapshots, final reply cards, CoT, closed cards, and every other interactive card stay **out of scope**. +- After the switch changes through the dashboard or `/botconfig set pinStreamingCard on/off`, Botmux immediately runs a best-effort hot reconciliation across that bot's **existing active sessions**; the configuration response itself does not wait for Feishu Pin/Unpin completion. +- Failures are **fail-open**: they never interrupt card publication, transfer, resume, close, or the configuration write. During exceptional periods you may temporarily see zero Pins or multiple Pins. +- The feature only compensates from the session's known `streamCardId` and frozen-card ids. It does not scan the chat's full Pin state or keep a durable retry journal. An explicit on-to-off transition cleans those known IDs; if provenance was already lost while the setting is off, later close or transfer cannot safely distinguish feature Pins from manual Pins and therefore does not attempt broad cleanup. + > **Open terminal = read-only**: the card's main "🖥️ Open Web Terminal" button is read-only viewing; for **writable** control, tap "🔑 Get operation link" — delivered **privately**: a flat group prefers an in-chat "visible-to-you" ephemeral card (so you never leave the conversation), falling back to a DM only for topic/thread or p2p chats, or when the ephemeral card fails. Management buttons like "🔄 Restart" and "apply profile" live on the **session card**, not on each turn's streaming card. ## Interrupting / correcting a running turn diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index ed1998803..fedba2202 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -189,6 +189,7 @@ | `brandLabel` | 卡片底部品牌文案。`undefined`=默认 `botmux` 链接;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 | | `showUsageInCardFooter` | 回复卡片页脚是否展示 Agent CLI 原生提供的 Context / Token 用量。缺省 / `true`=展示,`false`=同时隐藏两项;单项数据缺失时仍只省略缺失项。仅控制卡片展示,不停止 Usage Ledger 或其它统计 | | `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 | +| `pinStreamingCard` | `true` 时为该 bot **置顶当前公开的实时状态卡片**;默认关闭,只有显式 `true` 才开启。只认当前公开 live-status 的真实 `streamCardId`,repo 选择卡、私有 `/card`、最终回复卡、CoT、关闭卡、以及其它交互卡都不参与。开关支持热更新:通过 dashboard 或 `/botconfig set pinStreamingCard on/off` 成功写盘且有效值发生变化后,会对这个 bot 的**现有活跃会话**做 best-effort 热重算;配置响应**不等待**飞书 Pin/Unpin 完成。失败不会中断发卡、转移、恢复、关闭或配置本身;异常期间可能暂时出现 0 个或多个 Pin。该功能**不维护持久重试日志,也不会全量扫描整群 Pin 状态**:显式从 on 切到 off 时会清理当前会话已知的实时卡 ID;若配置已经是 off 且进程丢失了来源记录,后续关闭或转移不会冒险移除可能由人工创建的 Pin | | `silentTurnReactions` | `true` 时,无卡片会话不再给触发消息添加 GoGoGo / DONE reaction。只影响 `disableStreamingCard` 或 `noCardChats` 关闭实时卡片后的轻量状态提示;默认 `false` | | `receivedReactionEmoji` | 无卡片会话「已收到」reaction 的飞书 emoji_type;`undefined`=默认 `GoGoGo`(冲!)。自由字符串,填错只是静默不加表情(best-effort) | | `doneReactionEmoji` | 无卡片会话「已完成」reaction 的飞书 emoji_type;`undefined`=默认 `DONE`(✅)。设成与 `receivedReactionEmoji` 相同值可让完成态不翻脸——适合 idle 判定可能提前触发的 CLI(如 Pi),避免过早出现误导性的 ✅ | diff --git a/docs-site/docs/zh/cards.md b/docs-site/docs/zh/cards.md index 2e38fc0e1..1ce5e227d 100644 --- a/docs-site/docs/zh/cards.md +++ b/docs-site/docs/zh/cards.md @@ -10,6 +10,17 @@ - **每轮一张新卡片**:上一轮卡片冻结存档,对话历史清晰可回溯;会话用 [`/relay`](/relay) 搬到别的群后,原卡片也会自动冻结为存档(移除按钮)。 - **关闭时给「可恢复」卡片**:带「▶️ 恢复会话」按钮随时点回来继续;**该 CLI 若支持原生 resume**(adapter 实现了 `buildResumeCommand` 且有原生 session id),还会附上原生命令(如 `claude --resume `)方便手动恢复;不支持时只给 botmux 的恢复按钮 + 一句提示。 +## 置顶当前实时卡片 + +如果某个 bot 开启了 `pinStreamingCard`,Botmux 会尝试把**当前公开实时状态卡片**置顶到聊天顶部,方便随时点「关闭会话」或打开终端。 + +- 这是 **per-bot、默认关闭、显式开启** 的选项。 +- 只会处理当前公开 live-status 的真实 `streamCardId`。 +- repo 选择卡、私有 `/card`、最终回复卡、CoT、关闭卡,以及其它交互卡都**不会**被置顶。 +- 通过 dashboard 或 `/botconfig set pinStreamingCard on/off` 改开关后,Botmux 会对这个 bot 的**现有活跃会话**立即做 best-effort 热重算;配置响应本身不会等待飞书 Pin/Unpin 完成。 +- 失败是 **fail-open**:不会影响发卡、转移、恢复、关闭或配置写入。异常期间可能暂时没有任何 Pin,也可能短时间同时存在多个 Pin。 +- 该能力只基于当前会话已知的 `streamCardId` / 冻结卡 ID 做补偿,不做全群 Pin 扫描,也没有持久重试日志。显式从 on 切到 off 时会按这些已知 ID 清理;如果进程丢失来源记录时配置已经是 off,后续关闭或转移可能无法区分功能 Pin 与人工 Pin,因此不会冒险清理。 + > **打开终端 = 只读**:卡片主按钮「🖥️ 打开 Web 终端」是只读查看;要**可写操作**点「🔑 获取操作链接」——**私密投递**:普通平铺群优先发一张群内「仅你可见」的 ephemeral 卡(不用离开会话),话题/线程或私聊、以及 ephemeral 失败时才走私聊 DM。「🔄 重启」「接管配置」等管理按钮在**会话卡**上,不在每轮的流式卡上。 ## 打断 / 纠偏正在跑的一轮 diff --git a/docs/superpowers/plans/2026-08-28-pin-streaming-card.md b/docs/superpowers/plans/2026-08-28-pin-streaming-card.md new file mode 100644 index 000000000..ff3e826cc --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-pin-streaming-card.md @@ -0,0 +1,930 @@ +# Per-Bot Streaming Card Pinning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an opt-in, per-bot `pinStreamingCard` setting that keeps the current real `streamCardId` pinned and removes known streaming-card Pins when the card is replaced, transferred, or the session closes, without letting Pin failures affect session behavior. + +**Architecture:** Keep `streamCardId` and the existing durable `frozenCards` sidecar as the only card identities; do not add a Pin journal or inspect arbitrary card JSON. Add small Lark Pin/Unpin primitives, centralize best-effort policy and race checks in `worker-pool.ts`, and reuse the existing per-bot card-preference and `/botconfig` pipelines. A post-config-write callback reconciles already-active sessions without delaying the configuration response. + +**Tech Stack:** TypeScript, Bun 1.4.0-compatible source, `@larksuiteoapi/node-sdk` 1.73.0 API surface, React 19 Dashboard, Vitest. + +## Global Constraints + +- `pinStreamingCard` is per bot and default-off; only literal `true` enables it. +- Only real `streamCardId` values participate. Never Pin `repoCardMessageId`, private `/card` snapshots, final replies, CoT messages, closed cards, or other interactive messages. +- Pin/Unpin is QoL and fail-open: no Pin API outcome may change card publication, session mutation, transfer, resume, close, or configuration results. +- Desired steady state is one Pin for the current `streamCardId` of each active session; API failures may temporarily produce zero or multiple Pins. +- Pin a successor only after its publication ownership fence and durable `streamCardId` commit; Unpin predecessor IDs only after successor Pin success. +- Close refusal/durable-close failure leaves Pins unchanged. Successful close, including `closed_with_residual`, starts best-effort cleanup. +- Keep destination-sensitive `recallFrozenCards` semantics unchanged; Unpin selection is session-wide because Feishu Pins are chat-wide. +- No `bun install` in a worktree. Before tests, link this worktree's `node_modules` to the canonical checkout only if dependency manifests match exactly. +- Every implementation unit ends in focused tests, full unit tests, `bun run build`, an independent commit, and a push to `fork/feat/pin-streaming-card`. +- The merge request is written in Chinese and explicitly asks whether the setting should become default-on in a future release. + +--- + +### Task 0: Prepare and verify the isolated baseline + +**Files:** +- Read: `AGENTS.md` +- Read: `package.json` +- No source changes + +**Interfaces:** +- Consumes: canonical checkout `/data00/home/wangqiyilang/playground/botmux` +- Produces: verified dependency link and a known-green starting commit + +- [ ] **Step 1: Confirm isolation and exact dependency manifests** + +Run: + +```bash +git rev-parse --git-dir +git rev-parse --git-common-dir +git branch --show-current +cmp package.json /data00/home/wangqiyilang/playground/botmux/package.json +cmp bun.lock /data00/home/wangqiyilang/playground/botmux/bun.lock +``` + +Expected: Git dir differs from common dir, branch is `feat/pin-streaming-card`, and both `cmp` commands exit 0. If either manifest differs, do not share dependencies and do not run `bun install` in this worktree; stop and resolve dependency provisioning first. + +- [ ] **Step 2: Link the canonical dependencies without installing** + +Run only when both comparisons passed: + +```bash +ln -s /data00/home/wangqiyilang/playground/botmux/node_modules node_modules +test -d node_modules +``` + +Expected: `node_modules` resolves to the canonical checkout. If the canonical checkout has no dependencies, provision them outside the worktree or use CI; never run `bun install` through this symlink. + +- [ ] **Step 3: Verify the baseline** + +Run: + +```bash +bun run test +bun run build +git status --short --branch +``` + +Expected: unit tests and build pass, and the worktree is clean with the committed design and implementation-plan documents in branch history. If baseline tests fail, record the exact failures and stop before feature edits. + +--- + +### Task 1: Add the per-bot configuration contract and operator surfaces + +**Files:** +- Modify: `src/bot-registry.ts` +- Modify: `src/services/card-prefs-store.ts` +- Modify: `src/services/bot-config-store.ts` +- Modify: `src/core/dashboard-ipc-server.ts` +- Modify: `src/dashboard/bot-payload.ts` +- Modify: `src/dashboard/web/bot-defaults.ts` +- Modify: `src/dashboard/web/bot-defaults-page.tsx` +- Modify: `src/dashboard/web/i18n.ts` +- Modify: `src/i18n/en.ts` +- Modify: `src/i18n/zh.ts` +- Test: `test/bot-registry-grant.test.ts` +- Test: `test/card-prefs-auto-start.test.ts` +- Test: `test/bot-config-store.test.ts` +- Test: `test/dashboard-bot-payload.test.ts` +- Test: `test/dashboard-ipc.test.ts` +- Test: `test/dashboard-bot-defaults-cliid.test.ts` + +**Interfaces:** +- Consumes: existing default-false boolean persistence and card-preference routes +- Produces: `BotConfig.pinStreamingCard?: boolean`, `BotCardPrefs.pinStreamingCard: boolean`, Dashboard JSON field `pinStreamingCard: boolean`, and `/botconfig set pinStreamingCard on|off` + +- [ ] **Step 1: Write the failing strict-normalization test** + +Add to `test/bot-registry-grant.test.ts`: + +```ts +it('parses pinStreamingCard only as strict boolean true', () => { + expect(parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'pin1', larkAppSecret: 's', pinStreamingCard: true }, + ]))[0].pinStreamingCard).toBe(true); + + for (const bad of [undefined, false, 'true', 1, null]) { + const [cfg] = parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'pin2', larkAppSecret: 's', pinStreamingCard: bad }, + ])); + expect(cfg.pinStreamingCard).toBeUndefined(); + } +}); +``` + +- [ ] **Step 2: Run the registry test and verify RED** + +Run: + +```bash +bun run test -- test/bot-registry-grant.test.ts +``` + +Expected: FAIL because `BotConfig`/normalization has no `pinStreamingCard`. + +- [ ] **Step 3: Implement the normalized BotConfig field** + +In `BotConfig`, beside `disableStreamingCard`, add: + +```ts +/** Pin the current public streaming card. Default false; best-effort only. */ +pinStreamingCard?: boolean; +``` + +In `parseBotConfigsFromText`, normalize it beside other default-off card booleans: + +```ts +pinStreamingCard: entry.pinStreamingCard === true || undefined, +``` + +Re-run the focused registry test and expect PASS. + +- [ ] **Step 4: Write failing card-preference round-trip tests** + +Extend `test/card-prefs-auto-start.test.ts` to assert: + +```ts +expect(store.getBotCardPrefs('app_default').pinStreamingCard).toBe(false); + +const on = await store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); +expect(on.ok && on.prefs.pinStreamingCard).toBe(true); +expect(readConfig().pinStreamingCard).toBe(true); +expect(registry.getBot('app_default').config.pinStreamingCard).toBe(true); + +const off = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); +expect(off.ok && off.prefs.pinStreamingCard).toBe(false); +expect(readConfig().pinStreamingCard).toBeUndefined(); +expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); +``` + +Also seed `pinStreamingCard: true`, patch an unrelated preference, and assert the Pin preference remains true on disk, in memory, and in the returned full prefs. + +- [ ] **Step 5: Run card-preference tests and verify RED** + +Run: + +```bash +bun run test -- test/card-prefs-auto-start.test.ts +``` + +Expected: FAIL because resolved preferences and persistence omit the new field. + +- [ ] **Step 6: Implement card-preference persistence** + +In `src/services/card-prefs-store.ts`: + +```ts +export interface BotCardPrefs { + // existing fields... + pinStreamingCard: boolean; +} +``` + +Add the field to both resolved return objects as `c.pinStreamingCard === true` / `false`, call: + +```ts +apply(entry, 'pinStreamingCard', patch.pinStreamingCard); +``` + +include `pinStreamingCard: entry.pinStreamingCard === true` in the RMW result, synchronize live memory after success: + +```ts +if (patch.pinStreamingCard !== undefined) { + bot.config.pinStreamingCard = patch.pinStreamingCard || undefined; +} +``` + +and include the resolved value in the existing sanitized log line. Re-run the focused test and expect PASS. + +- [ ] **Step 7: Write failing `/botconfig` tests** + +In `test/bot-config-store.test.ts`, extend the case-insensitive lookup and add: + +```ts +it('pinStreamingCard is an immediate default-off boolean', async () => { + const { registry, store } = await loaded(); + const spec = store.findConfigField('PINSTREAMINGCARD')!; + expect(spec).toMatchObject({ + configKey: 'pinStreamingCard', + kind: 'boolean', + effect: 'immediate', + clearable: false, + }); + + const on = await store.applyConfigField('app_default', spec, true); + expect(on).toMatchObject({ ok: true, oldText: 'off', newText: 'on' }); + expect(readConfig().pinStreamingCard).toBe(true); + expect(registry.getBot('app_default').config.pinStreamingCard).toBe(true); + + const off = await store.applyConfigField('app_default', spec, false); + expect(off).toMatchObject({ ok: true, oldText: 'on', newText: 'off' }); + expect(readConfig().pinStreamingCard).toBeUndefined(); + expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); +}); +``` + +Extend snapshot/card-data assertions so the new boolean appears and has the effective off/on value. + +- [ ] **Step 8: Run `/botconfig` tests and verify RED** + +Run: + +```bash +bun run test -- test/bot-config-store.test.ts +``` + +Expected: FAIL because `findConfigField('pinStreamingCard')` returns undefined. + +- [ ] **Step 9: Register the generic `/botconfig` field and labels** + +Add to `CONFIG_FIELDS` beside the other card booleans: + +```ts +{ + key: 'pinStreamingCard', + configKey: 'pinStreamingCard', + kind: 'boolean', + effect: 'immediate', + clearable: false, + hint: '置顶当前公开实时卡片 on|off(失败不影响会话)', +}, +``` + +Add `config.label.pinStreamingCard` to `src/i18n/zh.ts` and `src/i18n/en.ts`. Do not special-case the command handler; the registry-driven path already implements help, coercion, snapshots, config card toggles, and persistence. Re-run the focused test and expect PASS. + +- [ ] **Step 10: Write failing Dashboard backend tests** + +In `test/dashboard-bot-payload.test.ts`, add `pinStreamingCard` to the exact editable/private payload keys and assert absent/malformed values become false while literal true remains true. + +In the card-preference GET/PUT block in `test/dashboard-ipc.test.ts`, assert: + +```ts +expect(initial.pinStreamingCard).toBe(false); +// PUT { pinStreamingCard: true } returns true and writes/syncs true. +// An unrelated partial patch preserves true. +// PUT { pinStreamingCard: false } returns false and removes disk/live keys. +// PUT { pinStreamingCard: 'true' } alone returns no_valid_fields. +``` + +- [ ] **Step 11: Run Dashboard backend tests and verify RED** + +Run: + +```bash +bun run test -- test/dashboard-bot-payload.test.ts test/dashboard-ipc.test.ts +``` + +Expected: FAIL because the payload/IPC route omits the field. + +- [ ] **Step 12: Implement Dashboard backend plumbing** + +Add `pinStreamingCard: j?.pinStreamingCard === true` to `botDefaultsPayload`. Include `cardPrefs.pinStreamingCard` in the private daemon response. Add the unknown request field, typed patch field, and validator: + +```ts +if (typeof body.pinStreamingCard === 'boolean') { + patch.pinStreamingCard = body.pinStreamingCard; +} +``` + +Re-run the backend tests and expect PASS. + +- [ ] **Step 13: Write failing Dashboard UI tests** + +In `test/dashboard-bot-defaults-cliid.test.ts`, freeze the selector `data-action="toggle-pin-streaming-card"` and add cases proving: + +- absent payload renders unchecked; +- toggling on calls `putCardPref({ pinStreamingCard: true })`; +- request failure restores the prior state and surfaces `write_failed`; +- the new control joins the existing single-flight disabled-control loop. + +- [ ] **Step 14: Run the Dashboard UI test and verify RED** + +Run: + +```bash +bun run test -- test/dashboard-bot-defaults-cliid.test.ts +``` + +Expected: FAIL because the toggle does not exist. + +- [ ] **Step 15: Implement Dashboard type, toggle, and bilingual copy** + +Add `pinStreamingCard?: boolean` to `BotDefaultsRow`, add it to `patchCardPrefsFromBody`, and add state/effect wiring in `CardBehaviorSection`. Render a `ToggleRow` in the task-feedback group: + +```tsx + { + const previous = pinStreamingCard; + setPinStreamingCard(checked); + void savePatch( + { pinStreamingCard: checked }, + 'pin-streaming', + () => setPinStreamingCard(previous), + ); + }} +/> +``` + +Add the three keys to both locale maps in `src/dashboard/web/i18n.ts`. Copy must say that only the current public live-status card participates, the default is off, and failures do not interrupt sessions. No CSS change is expected. + +- [ ] **Step 16: Verify, commit, and push Unit 1** + +Run: + +```bash +bun run test -- test/bot-registry-grant.test.ts test/card-prefs-auto-start.test.ts test/bot-config-store.test.ts +bun run test -- test/dashboard-bot-payload.test.ts test/dashboard-ipc.test.ts test/dashboard-bot-defaults-cliid.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. Then: + +```bash +git add src/bot-registry.ts src/services/card-prefs-store.ts src/services/bot-config-store.ts \ + src/core/dashboard-ipc-server.ts src/dashboard/bot-payload.ts \ + src/dashboard/web/bot-defaults.ts src/dashboard/web/bot-defaults-page.tsx \ + src/dashboard/web/i18n.ts src/i18n/en.ts src/i18n/zh.ts \ + test/bot-registry-grant.test.ts test/card-prefs-auto-start.test.ts \ + test/bot-config-store.test.ts test/dashboard-bot-payload.test.ts \ + test/dashboard-ipc.test.ts test/dashboard-bot-defaults-cliid.test.ts +git commit -m "feat(card): 添加实时卡片置顶配置入口" +git push fork feat/pin-streaming-card +``` + +--- + +### Task 2: Add fail-open Lark Pin and Unpin primitives + +**Files:** +- Modify: `src/im/lark/client.ts` +- Create: `test/lark-pin-message.test.ts` +- Modify: `test/lark-transport-boundary.test.ts` + +**Interfaces:** +- Consumes: `getBotClient`, `assertLarkTransport`, `formatLarkError`, and the SDK `im.v1.pin` resource +- Produces: `pinMessage(larkAppId, messageId): Promise` and `unpinMessage(larkAppId, messageId): Promise` + +- [ ] **Step 1: Write the failing transport tests** + +Create `test/lark-pin-message.test.ts` following `test/delete-message.test.ts`. Register a bot and inject: + +```ts +getBot(appId).client = { + im: { v1: { pin: { create: pinCreateMock, delete: pinDeleteMock } } }, +} as any; +``` + +Test both wrappers for: + +- exact create payload `{ data: { message_id: 'om_pin' } }`; +- exact delete payload `{ path: { message_id: 'om_pin' } }`; +- `code: 0` returns true; +- non-zero and missing `code` return false; +- SDK throw returns false and the sanitized warning excludes a fake authorization token; +- two successful Unpin calls both return true, proving the local wrapper is idempotent/stateless. + +Extend `test/lark-transport-boundary.test.ts` with Pin spies and assertions that both wrappers reject with `LarkTransportDisabledError` for `apiOnly` and make zero SDK calls. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +bun run test -- test/lark-pin-message.test.ts test/lark-transport-boundary.test.ts +``` + +Expected: FAIL on missing exports, not on malformed mocks. + +- [ ] **Step 3: Implement the minimal wrappers** + +Add immediately before `deleteMessage` in `client.ts`: + +```ts +export async function pinMessage(larkAppId: string, messageId: string): Promise { + assertLarkTransport(larkAppId, 'pinMessage'); + const client = getBotClient(larkAppId); + try { + const res: any = await client.im.v1.pin.create({ data: { message_id: messageId } }); + if (res?.code !== 0) { + logger.warn(`[pin:${larkAppId}] failed message=${messageId} code=${res?.code ?? 'missing'}`); + return false; + } + return true; + } catch (err) { + logger.warn(`[pin:${larkAppId}] failed message=${messageId}: ${formatLarkError(err) ?? (err instanceof Error ? err.message : 'unknown error')}`); + return false; + } +} +``` + +Implement `unpinMessage` symmetrically with `client.im.v1.pin.delete`. Keep `assertLarkTransport` outside `try`, so transport-disabled misuse stays a typed rejection rather than being converted to false. Do not change generic send/reply helpers. + +- [ ] **Step 4: Verify GREEN, refactor only local duplication, then verify the unit** + +Run: + +```bash +bun run test -- test/lark-pin-message.test.ts test/lark-transport-boundary.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. A private helper inside `client.ts` may remove duplicated logging, but no new module or behavior belongs in this commit. + +- [ ] **Step 5: Commit and push Unit 2** + +```bash +git add src/im/lark/client.ts test/lark-pin-message.test.ts test/lark-transport-boundary.test.ts +git commit -m "feat(lark): 添加消息 Pin 传输封装" +git push fork feat/pin-streaming-card +``` + +--- + +### Task 3: Centralize streaming-card Pin policy + +**Files:** +- Modify: `src/core/worker-pool.ts` +- Create: `test/streaming-card-pinning.test.ts` +- Modify: `test/recall-frozen-cards.test.ts` + +**Interfaces:** +- Consumes: Task 1 `BotConfig.pinStreamingCard`; Task 2 `pinMessage`/`unpinMessage`; existing `streamCardId`, `frozenCards`, active-session registry and `replyTargetKey` rules +- Produces: `pinStreamingCardIfEnabled`, `reconcileStreamingCardPins`, and `reconcileBotStreamingCardPins` + +- [ ] **Step 1: Write failing helper and reconciliation tests** + +Mock `pinMessage` and `unpinMessage` in a new `test/streaming-card-pinning.test.ts`. Add cases proving: + +```text +default/false config -> no calls, false +empty/sentinel ID -> no calls, false +inactive/transferring -> no calls, false +lost registry ownership -> no calls, false +changed current ID -> no calls, false +valid enabled session -> Pin current, true +state changes during Pin -> compensating Unpin(captured ID), false +Pin/compensation failure -> never throws +``` + +For `reconcileStreamingCardPins`, assert enable ordering `pin(current)` then `unpin(all frozen IDs)`, failed Pin skips frozen Unpin, disable Unpins current plus all deduplicated frozen IDs, and cross-topic frozen IDs are included. Extend `test/recall-frozen-cards.test.ts` to prove `recallFrozenCards` remains destination-sensitive after this new session-wide Unpin helper exists. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: + +```bash +bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts +``` + +Expected: FAIL because the exported policy helpers are missing. + +- [ ] **Step 3: Implement the policy helpers beside `parkStreamCard`** + +Use these exact public signatures: + +```ts +export async function pinStreamingCardIfEnabled( + ds: DaemonSession, + messageId: string, +): Promise; + +export async function reconcileStreamingCardPins( + ds: DaemonSession, + enabled: boolean, +): Promise; + +export function reconcileBotStreamingCardPins( + larkAppId: string, + enabled: boolean, +): void; +``` + +Add private helpers to validate real IDs, snapshot/deduplicate current and frozen IDs before awaiting, verify `session.status`, app ID, route key ownership, non-transfer/non-retirement state, session-object identity and current card identity, and compensate a stale successful Pin with Unpin. `reconcileBotStreamingCardPins` snapshots unique active sessions for the target bot and starts independent promises without awaiting them. Catch every failure inside the policy layer. + +- [ ] **Step 4: Verify GREEN and Unit 3A checkpoint** + +Run: + +```bash +bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. Do not commit yet; Tasks 3-5 form the single lifecycle unit. + +--- + +### Task 4: Wire publication, recovery, and resume paths + +**Files:** +- Modify: `src/core/worker-pool.ts` +- Modify: `src/im/lark/card-handler.ts` +- Modify: `test/recall-frozen-cards.test.ts` +- Modify: `test/worker-ready-display-mode.test.ts` +- Modify: `test/card-integration.test.ts` + +**Interfaces:** +- Consumes: Task 3 policy helpers +- Produces: fenced Pin behavior at every path that commits or reuses a real `streamCardId` + +- [ ] **Step 1: Write failing publication-order and failure-isolation tests** + +Extend the existing harnesses to cover all of these paths: + +```text +postTurnStartingCard +postFreshStreamingCard (/card) +worker-ready persisted-card reuse +worker-ready fresh POST +screen_update fallback POST +card-button resume repost +``` + +For each fresh POST, assert `POST → commit/persist → Pin(new) → Unpin(frozen) → existing recall`. Add deferred POST/Pin cases proving a closed, transferred, retired, registry-replaced, or newer-card session deletes the stale result, never Pins it, and never overwrites its successor. Assert Pin false/rejection keeps the new `streamCardId`, preserves the path's success result, and does not suppress existing recall. Default-off must produce zero Pin/Unpin calls. Persisted-card reuse must perform idempotent reconciliation without posting a new card. + +- [ ] **Step 2: Run the focused suites and verify RED** + +Run: + +```bash +bun run test -- test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts +``` + +Expected: new ordering/call assertions fail. + +- [ ] **Step 3: Wire the existing fully fenced turn-start path** + +In `postTurnStartingCard`, after the real message ID is committed and persisted, await `pinStreamingCardIfEnabled(ds, messageId)`. If it returns true, run best-effort session-wide frozen Unpin. Then call the unchanged `recallFrozenCards(ds)` regardless of Pin outcome. Preserve existing pending-generation scheduling. + +- [ ] **Step 4: Add missing publication fences before adding Pin side effects** + +For `postFreshStreamingCard`, worker-ready fresh POST, and screen-update fresh POST, capture the session object, app ID, anchor/registry key, nonce/generation and prior identity before awaiting. After POST, require the same active route owner, non-transfer/non-retirement state and sentinel/nonce ownership before committing. Delete stale results and restore prior identity only when that invocation still owns the sentinel. Then persist, Pin, conditionally Unpin frozen IDs, and run normal recall. + +For worker-ready persisted-card reuse, recheck ownership after PATCH, then reconcile the existing ID before recall. + +- [ ] **Step 5: Fence and wire the resume-button repost** + +In `card-handler.ts`, capture the resumed session object, app ID, route key and current-card identity before POST. After POST, recheck active status, registry ownership, route/transfer state and unchanged prior card identity before assigning the returned ID. Delete stale results with no Pin. For a valid result, persist it, invoke `pinStreamingCardIfEnabled`, then keep the existing best-effort deletion of the clicked closed card and delivery of the resume receipt. Pin failure must not change resume success or suppress deletion/receipt. + +- [ ] **Step 6: Verify the publication and resume matrix** + +Run: + +```bash +bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts \ + test/worker-ready-display-mode.test.ts test/card-integration.test.ts \ + test/card-handler-resume-receipt.test.ts +bun run build +git diff --check +``` + +Expected: all commands exit 0. + +--- + +### Task 5: Clean streaming-card Pins on transfer and close + +**Files:** +- Modify: `src/core/worker-pool.ts` +- Modify: `test/transfer-session.test.ts` +- Modify: `test/session-delete-close-barrier.test.ts` +- Modify: `test/mojo-explicit-close.test.ts` +- Modify: `test/close-stream-card-untouched.test.ts` + +**Interfaces:** +- Consumes: Task 3 capture/dedup and best-effort Unpin helpers +- Produces: post-commit source cleanup for transfer and post-close cleanup for every authoritative close consumer + +- [ ] **Step 1: Write failing transfer cleanup tests** + +Assert that transfer snapshots current plus all cross-topic frozen streaming-card IDs, makes no Unpin call on pre-commit refusal, and after a successful routing commit starts Unpin for each captured ID. Verify Unpin rejection does not alter `{ ok: true }`, and source cleanup never targets a newly published target card. + +- [ ] **Step 2: Write failing close cleanup tests** + +Create live and workerless stored-row fixtures whose current card and frozen sidecar IDs are known. Assert: + +- normal close and `closed_with_residual` start Unpin after durable close; +- teardown refusal and durable save failure make no Unpin call; +- slow/rejected Unpin does not delay `awaitWorkerExit: false` or change `CloseSessionResult`; +- `closeSession` still never recalls/deletes the live message, preserving `test/close-stream-card-untouched.test.ts`. + +- [ ] **Step 3: Run focused tests and verify RED** + +Run: + +```bash +bun run test -- test/transfer-session.test.ts test/session-delete-close-barrier.test.ts \ + test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts +``` + +Expected: new Unpin assertions fail. + +- [ ] **Step 4: Implement transfer cleanup at the committed boundary** + +Before clearing source-bound state, snapshot/deduplicate the real source `streamCardId` and every frozen message ID. After the durable route and registry ownership commit succeeds, launch best-effort Unpins using only those captured IDs. Preserve the existing inert source-card PATCH and target fork behavior. + +- [ ] **Step 5: Implement close cleanup after successful durable close** + +Before `sessionStore.closeSession` removes the frozen sidecar, snapshot live/stored real `streamCardId` and load/capture frozen IDs. After durable close succeeds, launch best-effort Unpins regardless of the current setting. Do not await them in the close result path. Do not clean on refusal or save failure. + +- [ ] **Step 6: Verify, commit, and push the lifecycle unit** + +Run: + +```bash +bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts \ + test/worker-ready-display-mode.test.ts test/card-integration.test.ts \ + test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts \ + test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts \ + test/close-stream-card-untouched.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. Then: + +```bash +git add src/core/worker-pool.ts src/im/lark/card-handler.ts \ + test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts \ + test/worker-ready-display-mode.test.ts test/card-integration.test.ts \ + test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts \ + test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts \ + test/close-stream-card-untouched.test.ts +git commit -m "feat(card): 接入实时卡片 Pin 生命周期" +git push fork feat/pin-streaming-card +``` + +--- + +### Task 6: Reconcile active sessions after hot setting changes + +**Files:** +- Create: `src/services/pin-streaming-card-change.ts` +- Modify: `src/services/bot-config-store.ts` +- Modify: `src/services/card-prefs-store.ts` +- Modify: `src/daemon.ts` +- Create: `test/pin-streaming-card-change.test.ts` +- Modify: `test/bot-config-store.test.ts` +- Modify: `test/card-prefs-auto-start.test.ts` +- Modify: `test/command-handler.test.ts` +- Modify: `test/dashboard-ipc.test.ts` + +**Interfaces:** +- Consumes: Task 3 `reconcileBotStreamingCardPins(larkAppId, enabled): void` +- Produces: one post-write notification seam shared by Dashboard and `/botconfig` + +- [ ] **Step 1: Write failing notification seam tests** + +Create `test/pin-streaming-card-change.test.ts` for: + +```ts +export type PinStreamingCardChangeHandler = + (larkAppId: string, enabled: boolean) => void; + +export function registerPinStreamingCardChangeHandler( + handler: PinStreamingCardChangeHandler, +): () => void; + +export function notifyPinStreamingCardChanged( + larkAppId: string, + enabled: boolean, +): void; +``` + +Test registration, disposal, replacement/duplicate registration policy, and that a throwing handler is swallowed and logged. + +- [ ] **Step 2: Run the seam test and verify RED** + +Run: + +```bash +bun run test -- test/pin-streaming-card-change.test.ts +``` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Implement the callback module** + +Implement one process-local handler with an unregister closure. `notifyPinStreamingCardChanged` catches handler errors and never throws. Keep the module independent of worker-pool to avoid a services-to-core cycle. + +- [ ] **Step 4: Write failing store-timing and operator-path tests** + +Extend both config stores so tests register a handler and assert it observes the already-updated disk and live config. Failed writes must emit nothing. Add `/botconfig` and Dashboard endpoint integration tests proving the visible mutation response completes even when reconciliation throws or returns pending work. + +- [ ] **Step 5: Run focused tests and verify RED** + +Run: + +```bash +bun run test -- test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts \ + test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts +``` + +Expected: callback observations are absent. + +- [ ] **Step 6: Notify only after successful Pin-setting writes** + +In `applyConfigField`, after disk and live memory are synchronized, call the notifier only when `spec.configKey === 'pinStreamingCard'`, with effective `value === true`. In `updateBotCardPrefs`, notify only when `patch.pinStreamingCard !== undefined`, after memory synchronization. Do not notify on unrelated partial patches or failed writes. + +- [ ] **Step 7: Register reconciliation after active-session registry setup** + +In daemon startup, immediately after `setActiveSessionsRegistry(activeSessions)`, register: + +```ts +registerPinStreamingCardChangeHandler(reconcileBotStreamingCardPins); +``` + +Do not await reconciliation from the notification path. Add a multi-session test showing it snapshots all active sessions for the matching bot, ignores other bots, and isolates one session's failure from the rest. + +- [ ] **Step 8: Verify the hot-toggle unit** + +Run: + +```bash +bun run test -- test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts \ + test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts \ + test/streaming-card-pinning.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. Do not commit until Task 7 adds the same unit's documentation. + +--- + +### Task 7: Document the setting and checkpoint hot reconciliation + +**Files:** +- Modify: `docs-site/docs/zh/bots-json.md` +- Modify: `docs-site/docs/en/bots-json.md` +- Modify: `docs-site/docs/zh/cards.md` +- Modify: `docs-site/docs/en/cards.md` +- Modify: `src/services/pin-streaming-card-change.ts` +- Modify: `src/services/bot-config-store.ts` +- Modify: `src/services/card-prefs-store.ts` +- Modify: `src/daemon.ts` +- Modify: `test/pin-streaming-card-change.test.ts` +- Modify: `test/bot-config-store.test.ts` +- Modify: `test/card-prefs-auto-start.test.ts` +- Modify: `test/command-handler.test.ts` +- Modify: `test/dashboard-ipc.test.ts` + +**Interfaces:** +- Consumes: final config and lifecycle semantics +- Produces: user-facing configuration/failure documentation and MR discussion text + +- [ ] **Step 1: Add the bilingual documentation** + +Document all of these exact points in both languages: + +- per-bot `pinStreamingCard`, opt-in, default off; +- only the current public live-status `streamCardId` participates; +- hot on/off reconciliation for existing active sessions; +- repo picker, private `/card`, final reply, CoT, closed, and other cards remain unpinned; +- failures never interrupt publication, transfer, resume, close, or configuration; +- temporary zero/multiple Pins are possible; +- there is no durable retry journal or full-chat Pin audit, so an exceptional crash can leave a stale Pin. + +- [ ] **Step 2: Run documentation and full verification** + +Run: + +```bash +bun run test -- test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts \ + test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts \ + test/streaming-card-pinning.test.ts +bun run test +bun run build +git diff --check +``` + +Expected: all commands exit 0. + +- [ ] **Step 3: Commit and push Unit 4** + +```bash +git add src/services/pin-streaming-card-change.ts src/services/bot-config-store.ts \ + src/services/card-prefs-store.ts src/daemon.ts \ + test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts \ + test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts \ + docs-site/docs/zh/bots-json.md docs-site/docs/en/bots-json.md \ + docs-site/docs/zh/cards.md docs-site/docs/en/cards.md +git commit -m "feat(card): 支持实时卡片 Pin 热重算" +git push fork feat/pin-streaming-card +``` + +--- + +### Task 8: Integration verification, live acceptance, and merge request + +**Files:** +- Verify: all changed files +- Create externally: GitHub pull request from `TWT233:feat/pin-streaming-card` to `deepcoldy:master` + +**Interfaces:** +- Consumes: the four verified implementation commits +- Produces: pushed integration branch and reviewable Chinese MR + +- [ ] **Step 1: Review the complete commit series and diff** + +Run: + +```bash +git log --oneline origin/master..HEAD +git diff --check origin/master...HEAD +git diff --stat origin/master...HEAD +git status --short --branch +``` + +Expected: design + four implementation commits, no whitespace errors, and a clean worktree. + +- [ ] **Step 2: Run fresh full verification** + +Run: + +```bash +bun run test +bun run build +``` + +Expected: both exit 0 in this step; do not reuse earlier output for the completion claim. + +- [ ] **Step 3: Deploy the feature checkout for manual Feishu acceptance** + +Run from this worktree: + +```bash +bun run switch:here +bun run daemon:restart +``` + +Verify on a dedicated test bot, not a production-sensitive bot: + +```text +1. Existing active session + switch on -> current streamCardId becomes pinned. +2. New turn -> successor becomes pinned; predecessor is no longer pinned. +3. Close -> Pin disappears and session still closes. +4. Switch off with an active session -> current/known frozen Pins disappear. +5. Setting absent on another bot -> no Pin API traffic and existing behavior is unchanged. +6. Cross-topic chat-scope session -> old topic card remains visible but is unpinned. +``` + +Capture a screenshot showing the pinned live card and a second screenshot after close/Unpin for the MR. Do not expose secrets or private conversation contents. + +- [ ] **Step 4: Restore the canonical checkout after live acceptance** + +Run from `/data00/home/wangqiyilang/playground/botmux`: + +```bash +bun run switch:here +bun run daemon:restart +``` + +Expected: global `botmux` and all daemons again run from the canonical checkout, so deleting the feature worktree later cannot break the fleet. + +- [ ] **Step 5: Push and create the Chinese merge request** + +Run: + +```bash +git push fork feat/pin-streaming-card +gh pr create \ + --repo deepcoldy/botmux \ + --head TWT233:feat/pin-streaming-card \ + --base master \ + --title "feat(card): 支持按 Bot 置顶当前实时卡片" \ + --body-file /tmp/botmux-pin-streaming-card-pr.md +``` + +The Chinese body must include: what changed, why, affected platforms/CLIs/session types, exact automated commands/results, live acceptance results/screenshots, fail-open and no-journal limitations, and this explicit discussion item: + +> 是否应在观察 API 流量与失败率后,将 `pinStreamingCard` 改为默认开启?本 MR 为保持兼容性,刻意采用按 Bot 显式开启、默认关闭。 + +- [ ] **Step 6: Verify remote state** + +Run: + +```bash +gh pr view --repo deepcoldy/botmux --json number,url,title,headRefName,baseRefName,state +git ls-remote --heads fork feat/pin-streaming-card +``` + +Expected: open MR targets `master`, head is the pushed feature branch, and remote SHA equals local `HEAD`. Keep the integration worktree until the MR is reviewed and merged. diff --git a/docs/superpowers/specs/2026-08-28-pin-streaming-card-design.md b/docs/superpowers/specs/2026-08-28-pin-streaming-card-design.md new file mode 100644 index 000000000..ba12eb39f --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-pin-streaming-card-design.md @@ -0,0 +1,375 @@ +# Per-Bot Streaming Card Pinning Design + +## Context + +Botmux already keeps one current public live-status card in +`DaemonSession.streamCardId`. The card contains the session controls, including +“Close session”, and is replaced as later turns create a new live card. Older +cards are tracked in `frozenCards`; cards in the same visible reply destination +are eventually recalled, while cards in other topics may remain visible. + +Feishu supports pinning and unpinning a message, and the Botmux app manifest +already requests `im:message.pins:read` and +`im:message.pins:write_only`. Botmux does not currently call those APIs. Users +therefore have to search the conversation for the current card before closing a +finished session. + +This feature is a quality-of-life aid. Pinning must never become part of the +correctness or availability boundary for creating, running, transferring, +resuming, or closing a session. + +## Product contract + +- Add a per-bot setting named `pinStreamingCard`. +- The setting defaults to `false`; only an explicit `true` enables it. +- When enabled, the desired steady state for each active session that has a real + `streamCardId` is exactly one pinned card: that current `streamCardId`. +- A session without a real `streamCardId` is outside this feature's scope. In + particular, repo-selection cards identified by `repoCardMessageId` are not + changed or pinned. +- Private `/card` snapshots, repo-selection cards, adoption-blocked cards, final + response cards, CoT messages, and other interactive cards are out of scope, + even when they contain a close action. +- A successful close makes the desired state zero pinned streaming cards for + that session. A refused close leaves the existing pin state alone. +- Pin and unpin operations are best-effort. Failures are logged but never change + the result of the primary operation. During Feishu API failures the actual pin + count may temporarily be zero or greater than one; later lifecycle events and + hot-setting reconciliation should converge toward the desired state. + +The merge request must explicitly ask maintainers whether this opt-in setting +should become default-on in a future release. This change itself keeps the +default off for compatibility and to avoid new API traffic for existing bots. + +## Configuration surface + +`pinStreamingCard?: boolean` is a normal per-bot card preference in `bots.json`. +It follows the repository's default-false persistence convention: + +- `true` is written to disk. +- `false` removes the key. +- An absent or malformed value resolves to `false`. +- The effective value is synchronized into the in-memory `BotConfig` without a + daemon restart. + +The setting is exposed through both existing operator surfaces: + +1. Dashboard: Bot Defaults → Cards, as a toggle describing that only the current + public live-status card is pinned and failures do not interrupt sessions. +2. Chat command: `/botconfig set pinStreamingCard on|off`, with `effect: immediate`. + +Both mutation paths trigger the same best-effort reconciliation after the +configuration write succeeds, but only when the effective `pinStreamingCard` +boolean actually changes: + +- Off → on: pin each existing real `streamCardId` owned by that bot, then unpin + its known frozen streaming cards only if pinning the current card succeeds. +- On → off: treat that explicit transition as authority to unpin each known + current and frozen streaming-card ID captured from the live session snapshot, + even if a daemon restart or test reset already discarded process-local Pin + provenance. +- False → false and true → true writes are no-op updates for this feature: they + persist/synchronize normally but do not trigger reconciliation. + +The configuration write succeeds even if any reconciliation request fails. + +## Components and interfaces + +### Lark transport + +`src/im/lark/client.ts` adds two narrow wrappers beside the existing message +update/delete helpers: + +```ts +pinMessage(larkAppId: string, messageId: string): Promise +unpinMessage(larkAppId: string, messageId: string): Promise +``` + +They call the SDK's singular `client.im.v1.pin` resource: + +```ts +client.im.v1.pin.create({ data: { message_id: messageId } }) +client.im.v1.pin.delete({ path: { message_id: messageId } }) +``` + +Both wrappers enforce the normal Lark transport boundary, explicitly inspect a +resolved response's `code`, and return `true` only for a successful operation. +SDK throws, transport failures, and non-zero response codes return `false` after +sanitized logging. Unpin is treated as idempotent: Feishu success for an already +unpinned or recalled message is still success. Generic `sendMessage` and +`replyMessage` remain unchanged so unrelated cards cannot be pinned by accident. + +### Streaming-card pin policy + +`src/core/worker-pool.ts` owns the policy because it already owns +`streamCardId`, `frozenCards`, publication fencing, replacement, transfer, and +close. It exposes focused helpers for the one exceptional resume-card path in +`card-handler.ts` and for configuration reconciliation: + +```ts +pinStreamingCardIfEnabled( + ds: DaemonSession, + messageId: string, +): Promise + +reconcileStreamingCardPins( + ds: DaemonSession, + enabled: boolean, +): Promise + +reconcileBotStreamingCardPins( + larkAppId: string, + enabled: boolean, +): void +``` + +`pinStreamingCardIfEnabled` is a no-op that returns `false` when the setting is +off, the ID is empty or is `CARD_POSTING_SENTINEL`, the session is no longer +active, or the captured ID is no longer the session's current `streamCardId`. It +also verifies that the same `DaemonSession` still owns its current registry key. +After a successful Pin API response it repeats those checks. If the preference +was disabled or ownership changed while the request was in flight, it +best-effort Unpins the captured ID as compensation and returns `false`. It never +throws. + +`reconcileStreamingCardPins` operates only on a captured real current ID and the +message IDs already present in `frozenCards`. When enabling, it pins the current +ID first and unpins frozen IDs only after that pin succeeds. Ordinary +per-session disable reconciliation remains ownership-based: without an explicit +bot-wide off transition it only unpins IDs recorded in process-local provenance, +so a default-off bot that never enabled stays zero-call and cannot disturb +manual Pins. The explicit bot-wide on → off transition passes a narrower +cleanup-known-ids mode into per-session reconciliation; that mode may unpin the +captured current and frozen IDs without first pinning anything. Frozen-card +unpin selection is session-wide, not filtered by `replyTargetKey`, because Pins +are chat-wide and the invariant is per session. Existing frozen-card recall +remains destination-sensitive and otherwise unchanged. +Close and transfer cleanup continue to use only recorded ownership once the +setting is already off. They may therefore miss a stale pre-restart Pin under +this design's approved no-journal/no-audit caveat; only the explicit on → off +toggle is authoritative for cleaning known current/frozen IDs after provenance +loss. + +`reconcileBotStreamingCardPins` snapshots active sessions for the target bot and +launches reconciliation with per-session error isolation. It is deliberately +fire-and-forget from configuration handlers so Feishu latency cannot delay a +Dashboard or `/botconfig` response. + +The config stores notify a small registered callback only after the local disk +and in-memory update succeeds and only when the effective boolean actually +changes. Registering the callback from daemon startup avoids a +services-to-worker-pool import cycle and makes both Dashboard card-preference +writes and generic `/botconfig` writes share the same hot-toggle behavior +without granting cleanup authority to redundant false writes. + +## Lifecycle and ordering + +### Publishing or restoring a current card + +Every code path that commits a real message ID as `streamCardId` participates: + +- immediate turn-start publication (`postTurnStartingCard`); +- explicit public `/card` refresh (`postFreshStreamingCard`); +- worker-ready publication; +- screen-update fallback publication; +- card-button resume repost; +- worker-ready reuse of a persisted card after daemon recovery. + +`postTurnStartingCard` already has the full fence described below. The other +publication paths do not all have equivalent fencing today; this feature must +add the missing checks before it can expose their completions to Pin side +effects. This is a targeted correctness prerequisite, not a general card +lifecycle refactor. + +For a newly posted card, the ordering is: + +1. Post the card through the existing reply path. +2. Re-run the existing ownership, generation, transfer, retirement, and active + session fences. A stale/orphan result is deleted without any Pin call. +3. Commit and persist the returned message ID as the current `streamCardId`. +4. Attempt to Pin that captured message ID. +5. If Pin succeeds, best-effort Unpin every known prior streaming-card ID in + `frozenCards`. +6. Run the existing destination-sensitive `recallFrozenCards` behavior. Recall + is not delayed or failed when Pin or Unpin fails. + +All asynchronous completions use captured message IDs and must not reread a +mutable `streamCardId` before performing an Unpin. This prevents an older turn's +completion from unpinning a newer card. + +When worker recovery reuses and patches an existing persisted `streamCardId`, it +runs idempotent reconciliation after the patch/reuse ownership checks. This +self-heals a missed Pin after a daemon restart without posting another card or +making recovery noisy in the conversation. + +### Replacement and old-card cleanup + +`parkStreamCard` continues to copy the old current card into durable +`frozenCards`; it does not perform network I/O. The new current card is always +posted and committed before any old-card Pin is removed. + +Cross-topic behavior is intentionally split: + +- All known frozen streaming cards are candidates for Unpin, because Feishu Pins + belong to the containing chat rather than a native thread. +- Only frozen cards selected by the existing `replyTargetKey` rules are recalled. + Other-topic history remains visible exactly as it does today, but is no longer + pinned once a successor Pin succeeds. + +If Pinning the successor fails, old-card Unpin is skipped. Existing recall still +runs, so same-destination cards may disappear as they do today. Cross-topic old +cards retain their prior Pin until a later reconciliation succeeds. + +### Close + +`closeSession` snapshots the current real `streamCardId` and all known frozen +streaming-card IDs before the durable close deletes the frozen-card sidecar. +Only after the logical close commits successfully does it launch best-effort +Unpin calls for those captured IDs. The calls are not awaited by the card action +ACK or other close consumers. + +- `closed` and `closed_with_residual` both clear Pins because the local session is + no longer active. +- A teardown refusal or durable-close failure does not clear Pins because the + session remains active. +- An idempotent re-close may retry Unpin for any IDs still available on the + stored row, but no durable cleanup journal is added in this feature. + +The existing behavior of patching a clicked live card into a closed card or +sending a separate closed card is unchanged. The closed card itself is never +pinned. + +### Resume and transfer + +Card-button resume captures the resumed session identity, posts a replacement, +and rechecks active status, route ownership, and current card identity before +committing the replacement `streamCardId`. A stale result is deleted and never +pinned. A valid result is persisted, pinned, and only then followed by the +existing best-effort withdrawal of the clicked closed card. CLI/Dashboard resume +relies on the normal worker-ready reuse or publication path. + +Transfer snapshots the source `streamCardId`, commits the new route using the +existing fencing, clears source-bound card identity as it does today, and then +best-effort Unpins recorded owned IDs for the source route. A fresh target +`streamCardId` is pinned through the normal publication path. Pin failure does +not change transfer success. If the daemon already lost provenance and the +setting was already off before transfer began, this path may miss a stale old +Pin; the design intentionally accepts that gap instead of adding a durable +journal or chat-wide audit. + +## Failure and race semantics + +- Pin API failure: keep the newly posted card as the authoritative + `streamCardId`; do not roll back, repost, or fail the turn. +- Unpin API failure: keep the primary lifecycle result; log enough app/session/ID + context to diagnose it without exposing credentials. +- Close during card POST: the existing ownership fence rejects the returned + orphan, deletes it best-effort, and never Pins it. +- Consecutive turns: only a card that still passes the current-ID and session + ownership checks before and after the API request may remain pinned; a stale + success is compensated with an Unpin. Cleanup always uses captured predecessor + IDs. +- Missing permission, admin-only Pin policy, unsupported message types, withdrawn + messages, rate limits, and transport errors are all non-fatal. +- `apiOnly` and other transport-disabled sessions make no Pin calls. + +The minimum-change design intentionally does not add a durable Pin-operation +journal or scan the chat's full Pin list. Consequently, a process crash between +a successful Feishu mutation and its next local lifecycle step, or a combined +Unpin-and-recall failure after the only local predecessor record is removed, can +leave a stale Pin that this version cannot discover. An explicit on → off toggle +can still clean known current/frozen IDs from the live session snapshot after +process-local provenance loss, but if the setting is already off and ownership +has already been forgotten, later close/transfer cleanup may still miss that +stale Pin. This is acceptable for the approved QoL/fail-open scope and must be +called out in the merge request. + +## Development units + +### Unit 1: Configuration contract and operator surfaces + +Add `pinStreamingCard` to `BotConfig`, card-preference persistence, bot config +normalization, `/botconfig`, Dashboard IPC/payload/types/UI, and bilingual Dashboard +copy. Verify default-off behavior, true/false round trips, malformed input, +partial-patch preservation, optimistic UI rollback, and immediate in-memory +visibility. Commit this unit independently. + +### Unit 2: Lark Pin transport + +Add and unit-test `pinMessage` and `unpinMessage`, including exact SDK payloads, +successful and non-zero responses, thrown transport errors, idempotent Unpin, and +the `apiOnly` boundary. This unit shares only the two function signatures above +and can be developed in parallel with Unit 1. Commit it independently. + +### Unit 3: Streaming-card lifecycle integration + +Add the worker-pool policy helpers and wire every real `streamCardId` publication, +reuse, resume, transfer, and close path. Verify ordering, stale POST suppression, +default-off behavior, Pin failure isolation, successful/residual/refused close, +and cross-topic Unpin versus recall behavior. Commit it independently after +Units 1 and 2 are available. + +### Unit 4: Hot-toggle reconciliation and documentation + +Register the preference-change callback, reconcile existing active sessions when +the setting changes, document the setting and failure semantics, and add the MR +discussion prompt about a possible future default-on policy. Verify both +Dashboard and `/botconfig` mutation paths. Commit it independently. + +## Dependency graph + +```text +Unit 1: config contract ───────┐ + ├─[true blocking]─> Unit 3: lifecycle integration +Unit 2: Lark transport ────────┘ + +Unit 1: config contract ───────┐ +Unit 2: Lark transport ────────┼─[true blocking]─> Unit 4: hot-toggle + docs +Unit 3: lifecycle integration ─┘ +``` + +Units 1 and 2 are independent. The Dashboard UI and `/botconfig` entry share the +same frozen field contract rather than a runtime dependency. Unit 3 depends on +both the resolved setting and the Pin transport. Unit 4 depends on all prior +interfaces because it reconciles already-active sessions. + +## Verification and acceptance + +Automated verification must include: + +- focused config store, Dashboard IPC/payload/UI, and `/botconfig` tests; +- focused Lark Pin wrapper tests; +- streaming-card publication/reuse/replacement tests that assert Pin-before- + Unpin ordering and no Pin for stale POSTs; +- close tests for success, local residual, refusal, and workerless stored rows; +- resume and transfer tests; +- cross-topic tests proving old cards remain visible while their Pins are + removed; +- tests proving Pin/Unpin errors do not alter the primary return value or card + identity; +- the full unit suite and `bun run build`. + +Live acceptance on a test bot with the setting enabled covers: + +1. An existing active session gains a Pin when the setting is turned on. +2. A new turn moves the Pin to the new `streamCardId`; only one current session + card remains in the chat's Pin area. +3. Closing the session removes its Pin while still completing the close. +4. Turning the setting off removes Pins from existing active sessions. +5. A bot with the setting absent continues today's behavior with no Pin API + traffic. + +The implementation is complete only after each unit has its own verified commit, +the integration branch passes the full checks, and every commit is pushed. + +## Out of scope + +- Pinning `repoCardMessageId` or adding Close to the repo picker. +- Pinning arbitrary cards based on their JSON contents or presence of a close + button. +- Pinning private/ephemeral cards, final answers, CoT messages, or closed cards. +- A global default or per-chat override. +- A durable retry journal, periodic retry worker, or full-chat Pin-list audit. +- Changing the existing destination-sensitive card recall policy. +- Requiring Pin permissions at daemon startup or failing a session when the + permission is missing. diff --git a/src/bot-registry.ts b/src/bot-registry.ts index e6c0d0fb5..ac4416ca8 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -1756,6 +1756,10 @@ export interface BotConfig { * (undefined) keeps the streaming card. For users who find the live card noisy. */ disableStreamingCard?: boolean; + /** + * Pin the current public streaming card. Default false; best-effort only. + */ + pinStreamingCard?: boolean; /** * Stream the model's thinking process (CoT) into a native Feishu CoT * message per turn: a fixed-height scrolling bubble showing thinking @@ -3207,6 +3211,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { ? undefined : normalizeUsageDisplay(entry), disableStreamingCard: entry.disableStreamingCard === true || undefined, + pinStreamingCard: entry.pinStreamingCard === true || undefined, // Default ON: only an explicit false is meaningful/persisted (undefined = on). thinkingCard: entry.thinkingCard === false ? false : undefined, // Default ON, same convention as thinkingCard: an absent key means the diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 9e3b67a4e..527a97136 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -4040,6 +4040,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { // that is always empty — the CLI has no resolvable transcript). usageSupported: cliSupportsNativeUsage(cliId), disableStreamingCard: cardPrefs.disableStreamingCard, + pinStreamingCard: cardPrefs.pinStreamingCard, silentTurnReactions: cardPrefs.silentTurnReactions, codexAppCleanInput: cardPrefs.codexAppCleanInput, writableTerminalLinkInCard: cardPrefs.writableTerminalLinkInCard, @@ -4092,7 +4093,7 @@ 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; thinkingCard?: unknown; + disableStreamingCard?: unknown; pinStreamingCard?: unknown; silentTurnReactions?: unknown; codexAppCleanInput?: unknown; writableTerminalLinkInCard?: unknown; privateCard?: unknown; thinkingCard?: unknown; botToBotSameDir?: unknown; autoStartOnGroupJoin?: unknown; autoStartOnGroupJoinPrompt?: unknown; autoStartOnNewTopic?: unknown; regularGroupReplyMode?: unknown; regularGroupMentionMode?: unknown; docSubscribeDefaultMode?: unknown; @@ -4104,7 +4105,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { const patch: { usageDisplay?: UsageDisplayMode; - disableStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; thinkingCard?: boolean; + disableStreamingCard?: boolean; pinStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; thinkingCard?: boolean; botToBotSameDir?: boolean; autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; regularGroupReplyMode?: ChatReplyMode; regularGroupMentionMode?: 'always' | 'topic' | 'never' | 'ambient'; @@ -4114,6 +4115,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { } = {}; 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.pinStreamingCard === 'boolean') patch.pinStreamingCard = body.pinStreamingCard; if (typeof body.botToBotSameDir === 'boolean') patch.botToBotSameDir = body.botToBotSameDir; if (typeof body.silentTurnReactions === 'boolean') patch.silentTurnReactions = body.silentTurnReactions; if (typeof body.codexAppCleanInput === 'boolean') patch.codexAppCleanInput = body.codexAppCleanInput; diff --git a/src/core/types.ts b/src/core/types.ts index 4cf161a74..bd0c634be 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -302,6 +302,11 @@ export interface DaemonSession { streamCardTurnGeneration?: number; /** Exact newest turn awaiting its own streaming card. In-memory only. */ streamCardPendingTurnId?: string; + /** Feature-owned public streaming-card ids. Only these ids may be cleaned up + * by detached Pin QoL continuations; manual/user-created Pins stay untouched. + * In-memory only, so restart self-heal must re-derive ownership conservatively + * from the current live card instead of replaying history. */ + featureOwnedStreamingCardIds?: Set; pendingLocalCliButtonRefresh?: boolean; // true when cli_session_id arrived while the streaming card POST was in flight pendingRiffUrlCardRefresh?: boolean; // true when riff_access_url arrived while the streaming card POST was in flight /** Set on sessions restored after a daemon restart: suppresses the automatic diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 04692b273..c1c454f1c 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -29,7 +29,7 @@ import { persistStreamCardState, rememberLastCliInput } from './session-manager. import { spawnWorker } from './self-spawn.js'; import { resolveSessionLaunchModel } from './session-model.js'; import { fallbackTurnId, frozenReplyContextForTurn, isSubstituteTurn, pickTurnReplyTarget, rehomeReplyTargetState, replyTargetKey } from './reply-target.js'; -import { updateMessage, deleteMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js'; +import { updateMessage, deleteMessage, pinMessage, unpinMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js'; import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js'; import { codexServiceTierBadge } from '../services/codex-service-tier.js'; import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId } from '../services/codex-reasoning-effort.js'; @@ -2032,6 +2032,400 @@ export function recallFrozenCards(ds: DaemonSession): void { logger.info(`[${tag(ds)}] Recalled ${targets.length} previous streaming card(s)`); } +/** A streaming-card id is only meaningful once a real Lark message id has + * replaced the in-flight posting sentinel. Keep this narrow: this policy is + * called solely from streamCardId lifecycle paths, never arbitrary cards. */ +function isRealStreamingCardId(messageId: string | undefined): messageId is string { + return typeof messageId === 'string' && messageId.length > 0 && messageId !== CARD_POSTING_SENTINEL; +} + +function snapshotStreamingCardIds(ds: DaemonSession): string[] { + if (!ds.frozenCards) { + try { ds.frozenCards = loadFrozenCards(ds.session.sessionId); } catch (err) { + logger.debug(`[${tag(ds)}] could not load frozen cards for Pin cleanup: ${err instanceof Error ? err.message : String(err)}`); + ds.frozenCards = new Map(); + } + } + const ids = new Set(); + if (isRealStreamingCardId(ds.streamCardId)) ids.add(ds.streamCardId); + for (const frozen of ds.frozenCards.values()) { + if (isRealStreamingCardId(frozen.messageId)) ids.add(frozen.messageId); + } + return [...ids]; +} + +function snapshotStreamingCardPredecessorIds( + ds: DaemonSession, + currentMessageId: string, +): string[] { + return snapshotStreamingCardIds(ds).filter(id => id !== currentMessageId); +} + +const ownedStreamingCardRegistry = new Map>(); +const streamingCardMutationQueues = new Map>(); +const pendingPinStreamingCardTasks = new Set>(); +// Test resets deliberately drop process-local provenance while old network work +// may still settle. The epoch prevents that retired work from forgetting an +// identically-named card recorded by the replacement test/session state. +let ownedStreamingCardRegistryEpoch = 0; + +function trackPinStreamingCardTask(task: Promise): void { + const tracked = task.finally(() => { + pendingPinStreamingCardTasks.delete(tracked); + }); + pendingPinStreamingCardTasks.add(tracked); +} + +type StreamingCardOwner = Pick & { + session?: Pick; + sessionId?: string; +}; + +function ownedStreamingCardRegistryKey(ds: StreamingCardOwner): string { + return `${ds.session?.sessionId ?? ds.sessionId ?? ''}:${ds.larkAppId}`; +} + +function messageMutationQueueKey(larkAppId: string, messageId: string): string { + return `${larkAppId}:${messageId}`; +} + +function rememberOwnedStreamingCard(ds: StreamingCardOwner, messageId: string): void { + if (!isRealStreamingCardId(messageId)) return; + const key = ownedStreamingCardRegistryKey(ds); + let ids = ownedStreamingCardRegistry.get(key); + if (!ids) { + ids = new Set(); + ownedStreamingCardRegistry.set(key, ids); + } + ids.add(messageId); +} + +function forgetOwnedStreamingCard(ds: StreamingCardOwner, messageId: string): void { + if (!isRealStreamingCardId(messageId)) return; + const key = ownedStreamingCardRegistryKey(ds); + const ids = ownedStreamingCardRegistry.get(key); + ids?.delete(messageId); + if (ids?.size === 0) ownedStreamingCardRegistry.delete(key); +} + +function ownedStreamingCardIds(ds: StreamingCardOwner): string[] { + const ids = ownedStreamingCardRegistry.get(ownedStreamingCardRegistryKey(ds)); + if (!ids || ids.size === 0) return []; + return [...ids].filter(isRealStreamingCardId); +} + +function queueStreamingCardMessageMutation( + larkAppId: string, + messageId: string, + task: () => Promise, +): Promise { + const key = messageMutationQueueKey(larkAppId, messageId); + const previous = streamingCardMutationQueues.get(key) ?? Promise.resolve(); + const operation = previous.catch(() => undefined).then(task); + const tail = operation.catch(() => undefined); + streamingCardMutationQueues.set(key, tail); + void tail.finally(() => { + if (streamingCardMutationQueues.get(key) === tail) { + streamingCardMutationQueues.delete(key); + } + }); + return operation; +} + +function retainsLarkStreamingCardTransport(ds: DaemonSession): boolean { + return retainsLarkStreamingCardTransportFor(ds.larkAppId, ds.chatId); +} + +function retainsLarkStreamingCardTransportFor(larkAppId: string, chatId: string): boolean { + try { + return larkTransportEnabled({ chatId, apiOnly: getBot(larkAppId).config.apiOnly }); + } catch { + return false; + } +} + +function ownsActiveStreamingCardRegistrySlot(ds: DaemonSession): boolean { + if (!activeSessionsRegistry) return false; + const key = sessionKey(sessionAnchorId(ds), ds.larkAppId); + return activeSessionsRegistry.get(key) === ds; +} + +/** Identity captured before posting a streaming card. A successful HTTP POST is + * not a lifecycle commit: the route can be replaced while it is in flight. */ +export type StreamingCardPublicationFence = { + session: DaemonSession['session']; + larkAppId: string; + anchorId: string; + /** The live id expected while this POST is in flight. For resume reposts this + * is the prior card id: it must remain current until the fresh card commits. */ + expectedPriorCardId: string | undefined; +}; + +/** Positive commit fence for a card whose POST has returned. This deliberately + * fails closed when the active registry is absent or empty: only the exact + * captured route may publish a card, delete its predecessor, or emit a + * receipt. */ +export function canCommitStreamingCardPublication( + ds: DaemonSession, + fence: StreamingCardPublicationFence, +): boolean { + if (ds.session !== fence.session || ds.session.status !== 'active') return false; + if (ds.larkAppId !== fence.larkAppId || sessionAnchorId(ds) !== fence.anchorId) return false; + if (ds.streamCardId !== fence.expectedPriorCardId || isSessionTransferring(ds)) return false; + if (remoteRetirementAdmissionPhase(ds) !== null || !retainsLarkStreamingCardTransport(ds)) return false; + return activeSessionsRegistry?.get(sessionKey(fence.anchorId, fence.larkAppId)) === ds; +} + +function ownsCurrentStreamingCard(ds: DaemonSession, messageId: string): boolean { + if (!isRealStreamingCardId(messageId)) return false; + if (ds.session.status !== 'active' || ds.streamCardId !== messageId || isSessionTransferring(ds)) return false; + if (remoteRetirementAdmissionPhase(ds) !== null || !retainsLarkStreamingCardTransport(ds)) return false; + return ownsActiveStreamingCardRegistrySlot(ds); +} + +function pinStreamingCardEnabled(ds: DaemonSession): boolean { + return pinStreamingCardEnabledFor(ds.larkAppId); +} + +function pinStreamingCardEnabledFor(larkAppId: string): boolean { + try { return getBot(larkAppId).config.pinStreamingCard === true; } catch { return false; } +} + +/** + * Pin provenance is intentionally process-local. A bot that never opted in + * must not alter an operator's manual Pin merely because a message happens to + * be a current/frozen streaming card. Once opted in, cards visible in the + * current lifecycle snapshot are safe to retire even if a prior process did + * not retain the in-memory provenance. + */ +function captureLifecycleStreamingCardCleanupIds( + larkAppId: string, + chatId: string, + owner: StreamingCardOwner, + knownIds: readonly string[], +): string[] { + if (!retainsLarkStreamingCardTransportFor(larkAppId, chatId)) return []; + const ids = new Set(ownedStreamingCardIds(owner)); + if (pinStreamingCardEnabledFor(larkAppId)) { + for (const id of knownIds) { + if (isRealStreamingCardId(id)) ids.add(id); + } + } + return [...ids]; +} + +/** Pin exactly the current public streaming card. Pin is deliberately outside + * the publication success boundary: every failure is swallowed and a late + * success is compensated with an Unpin of the captured id. */ +export async function pinStreamingCardIfEnabled( + ds: DaemonSession, + messageId: string, +): Promise { + if (!pinStreamingCardEnabled(ds) || !ownsCurrentStreamingCard(ds, messageId)) return false; + const appId = ds.larkAppId; + const operation = queueStreamingCardMessageMutation(appId, messageId, async () => { + if (!pinStreamingCardEnabled(ds) || !ownsCurrentStreamingCard(ds, messageId)) return false; + try { + const pinned = await pinMessage(appId, messageId); + if (!pinned) return false; + if (pinStreamingCardEnabled(ds) && ownsCurrentStreamingCard(ds, messageId)) { + rememberOwnedStreamingCard(ds, messageId); + return true; + } + try { + const unpinned = await unpinMessage(appId, messageId); + if (unpinned) forgetOwnedStreamingCard(ds, messageId); + } catch { + /* stale Pin compensation is best-effort */ + } + } catch (err) { + logger.debug(`[${tag(ds)}] streaming-card Pin failed: ${err instanceof Error ? err.message : String(err)}`); + } + return false; + }); + trackPinStreamingCardTask(operation.then(() => undefined)); + return operation; +} + +async function unpinStreamingCardIds( + larkAppId: string, + ids: readonly string[], + owner?: StreamingCardOwner, +): Promise { + const succeeded: string[] = []; + for (const messageId of ids) { + const ownershipEpoch = ownedStreamingCardRegistryEpoch; + const operation = queueStreamingCardMessageMutation(larkAppId, messageId, async () => { + try { + const unpinned = await unpinMessage(larkAppId, messageId); + if (unpinned) { + if (owner && ownershipEpoch === ownedStreamingCardRegistryEpoch) { + forgetOwnedStreamingCard(owner, messageId); + } + return messageId; + } + } catch (err) { + logger.debug(`[${larkAppId}] streaming-card Unpin failed: ${err instanceof Error ? err.message : String(err)}`); + } + return undefined; + }); + trackPinStreamingCardTask(operation.then(() => undefined)); + const unpinnedId = await operation; + if (unpinnedId) succeeded.push(unpinnedId); + } + return succeeded; +} + +/** Fire-and-forget Pin QoL chain. Primary publication effects (recall, + * readiness flushes, timers, successor scheduling) must NOT be delayed by + * this best-effort API work. */ +/** + * Continue the best-effort Pin work for an already committed streaming-card + * publication. Callers must not await this: publication-side effects such as + * predecessor recall and user receipts are deliberately independent from the + * Lark Pin API. + */ +export function continuePublishedStreamingCardPinChain( + ds: DaemonSession, + messageId: string, + predecessorIds: readonly string[] = snapshotStreamingCardPredecessorIds(ds, messageId), +): void { + if (!pinStreamingCardEnabled(ds) || !ownsCurrentStreamingCard(ds, messageId)) return; + trackPinStreamingCardTask((async () => { + if (await pinStreamingCardIfEnabled(ds, messageId) && ownsCurrentStreamingCard(ds, messageId)) { + await unpinStreamingCardIds(ds.larkAppId, predecessorIds, ds); + } + })().catch((err) => { + logger.debug(`[${tag(ds)}] streaming-card Pin chain failed: ${err instanceof Error ? err.message : String(err)}`); + })); +} + +type ReconcileStreamingCardPinMode = + | { enabled: true } + | { enabled: false; cleanupKnownIds?: boolean }; + +/** Reconcile one session after an opt-in setting transition. Frozen cards are + * session-wide here (Pins are chat-wide); recallFrozenCards remains topic-aware. */ +export async function reconcileStreamingCardPins( + ds: DaemonSession, + enabledOrMode: boolean | ReconcileStreamingCardPinMode, +): Promise { + if (!retainsLarkStreamingCardTransport(ds)) return; + const mode: ReconcileStreamingCardPinMode = typeof enabledOrMode === 'boolean' + ? { enabled: enabledOrMode } + : enabledOrMode; + const { enabled } = mode; + const ids = ownedStreamingCardIds(ds); + const cleanupIds = mode.enabled + ? ids + : mode.cleanupKnownIds === true + ? [...new Set([...ids, ...snapshotStreamingCardIds(ds)])] + : ids; + const currentId = isRealStreamingCardId(ds.streamCardId) ? ds.streamCardId : undefined; + const frozenIds = enabled + ? snapshotStreamingCardIds(ds).filter(id => id !== currentId) + : cleanupIds.filter(id => id !== currentId); + try { + if (enabled) { + if (currentId && await pinStreamingCardIfEnabled(ds, currentId)) { + await unpinStreamingCardIds(ds.larkAppId, frozenIds, ds); + } + return; + } + if (cleanupIds.length === 0) return; + await unpinStreamingCardIds(ds.larkAppId, cleanupIds, ds); + } catch (err) { + logger.debug(`[${tag(ds)}] streaming-card Pin reconciliation failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +type PendingBotStreamingCardReconcile = { + desiredVersion: number; + desiredEnabled: boolean; + running: boolean; +}; + +const pendingBotStreamingCardReconciles = new Map(); + +function snapshotBotStreamingCardReconcileSessions(larkAppId: string): DaemonSession[] { + if (!activeSessionsRegistry) return []; + const sessions = new Map(); + for (const ds of activeSessionsRegistry.values()) { + if (ds.larkAppId !== larkAppId) continue; + if (ds.session.status !== 'active') continue; + if (isSessionTransferring(ds)) continue; + const key = sessionKey(sessionAnchorId(ds), ds.larkAppId); + if (activeSessionsRegistry.get(key) !== ds) continue; + sessions.set(ds.session.sessionId, ds); + } + return [...sessions.values()]; +} + +async function drainBotStreamingCardReconcileQueue(larkAppId: string): Promise { + const state = pendingBotStreamingCardReconciles.get(larkAppId); + if (!state || state.running) return; + state.running = true; + let settledVersion = state.desiredVersion; + try { + while (true) { + const enabled = state.desiredEnabled; + const desiredVersion = state.desiredVersion; + settledVersion = desiredVersion; + const sessions = snapshotBotStreamingCardReconcileSessions(larkAppId); + await Promise.allSettled( + sessions.map(async (ds) => { + try { + await reconcileStreamingCardPins(ds, enabled ? { enabled: true } : { enabled: false, cleanupKnownIds: true }); + } catch { + /* per-session reconciliation remains fail-open */ + } + }), + ); + if (state.desiredVersion === desiredVersion) break; + } + } finally { + state.running = false; + // No await occurs between the loop's break condition and this branch, so a + // newly-arrived toggle cannot interleave until after `settledVersion` is + // compared here. If the desired version is unchanged, this queue instance + // fully absorbed the latest bot-wide state and can be removed. + if (pendingBotStreamingCardReconciles.get(larkAppId) !== state) return; + if (state.desiredVersion === settledVersion) { + pendingBotStreamingCardReconciles.delete(larkAppId); + return; + } + void drainBotStreamingCardReconcileQueue(larkAppId); + } +} + +/** Fire-and-forget bot-wide reconciliation so configuration mutation remains + * responsive even when Lark Pin APIs are slow or unavailable. */ +export function reconcileBotStreamingCardPins(larkAppId: string, enabled: boolean): void { + const state = pendingBotStreamingCardReconciles.get(larkAppId); + if (state) { + state.desiredEnabled = enabled; + state.desiredVersion += 1; + if (!state.running) trackPinStreamingCardTask(drainBotStreamingCardReconcileQueue(larkAppId)); + return; + } + pendingBotStreamingCardReconciles.set(larkAppId, { desiredEnabled: enabled, desiredVersion: 1, running: false }); + trackPinStreamingCardTask(drainBotStreamingCardReconcileQueue(larkAppId)); +} + +export function __testOnly_resetPinStreamingCardReconcileQueue(): void { + pendingBotStreamingCardReconciles.clear(); + ownedStreamingCardRegistryEpoch += 1; + ownedStreamingCardRegistry.clear(); + streamingCardMutationQueues.clear(); + pendingPinStreamingCardTasks.clear(); +} + +export async function __testOnly_waitForPinStreamingCardIdle(): Promise { + while (pendingPinStreamingCardTasks.size > 0) { + await Promise.allSettled([...pendingPinStreamingCardTasks]); + } +} + /** The first visible state for a newly accepted turn. * * `starting` is a process/session lifecycle state. A live Grok worker that @@ -2160,7 +2554,7 @@ export async function postTurnStartingCard( && !isSessionTransferring(ds) && ds.streamCardId === CARD_POSTING_SENTINEL && ds.streamCardNonce === nonce - && (!activeSessionsRegistry || activeSessionsRegistry.get(registryKeyAtPost) === ds); + && activeSessionsRegistry?.get(registryKeyAtPost) === ds; const stillOwnsPost = (): boolean => ownsPostIdentity() && remoteRetirementAdmissionPhase(ds) === null; const restorePrePostIdentityForRetirement = (): boolean => { @@ -2189,6 +2583,7 @@ export async function postTurnStartingCard( ds.streamCardPending = false; ds.streamCardPendingTurnId = undefined; } + const predecessorIds = snapshotStreamingCardPredecessorIds(ds, messageId); persistStreamCardState(ds); recallFrozenCards(ds); flushPendingLocalCliOpenReadinessPatch(ds); @@ -2198,7 +2593,8 @@ export async function postTurnStartingCard( syncUsageRefreshTimer(ds); reconcilePostedStartingCard(ds, turnId, statusRevisionAtPost); logger.info(`[${tag(ds)}] Posted starting card for turn ${turnId.substring(0, 12)}`); - if (superseded && ds.streamCardPendingTurnId) { + continuePublishedStreamingCardPinChain(ds, messageId, predecessorIds); + if ((ds.streamCardTurnGeneration ?? 0) !== generation && ds.streamCardPendingTurnId) { void postTurnStartingCard(ds, sessionReply, ds.streamCardPendingTurnId); } return true; @@ -2238,6 +2634,8 @@ export async function postFreshStreamingCard( ): Promise { if (isDocNativeSession(ds)) return false; if (!workerHasInitialized(ds)) return false; + if (remoteRetirementAdmissionPhase(ds)) return false; + if (!retainsLarkStreamingCardTransport(ds)) return false; const botCfg = getBot(ds.larkAppId).config; const effectiveCliId = sessionCliId(ds, botCfg); const readUrl = readableTerminalUrlFor(ds); @@ -2254,9 +2652,14 @@ export async function postFreshStreamingCard( const prevNonce = ds.streamCardNonce; const prevReplyTargetKey = ds.streamCardReplyTargetKey; const prevPending = ds.streamCardPending; + const sessionAtPost = ds.session; + const appIdAtPost = ds.larkAppId; + const anchorAtPost = sessionAnchorId(ds); + const registryKeyAtPost = sessionKey(anchorAtPost, appIdAtPost); const cardReplyTarget = captureStreamingCardReplyTarget(ds); - ds.streamCardNonce = randomBytes(4).toString('hex'); + const postingNonce = randomBytes(4).toString('hex'); + ds.streamCardNonce = postingNonce; const cardJson = buildStreamingCard( ds.session.sessionId, sessionAnchorId(ds), @@ -2280,10 +2683,38 @@ export async function postFreshStreamingCard( silentIdleCardFlag(ds), ); ds.streamCardId = CARD_POSTING_SENTINEL; + const ownsPost = (): boolean => + ds.session === sessionAtPost + && ds.session.status === 'active' + && ds.larkAppId === appIdAtPost + && sessionAnchorId(ds) === anchorAtPost + && !isSessionTransferring(ds) + && ds.streamCardId === CARD_POSTING_SENTINEL + && ds.streamCardNonce === postingNonce + && activeSessionsRegistry?.get(registryKeyAtPost) === ds; + const restorePrePostIdentityForRetirement = (): boolean => { + if (remoteRetirementAdmissionPhase(ds) === null || !ownsPost()) return false; + ds.streamCardId = prevCardId; + ds.streamCardNonce = prevNonce; + ds.streamCardReplyTargetKey = prevReplyTargetKey; + ds.streamCardPending = prevPending; + persistStreamCardState(ds); + return true; + }; + const stillOwnsPost = (): boolean => + ownsPost() + && remoteRetirementAdmissionPhase(ds) === null + && retainsLarkStreamingCardTransport(ds); try { - ds.streamCardId = await sessionReply( - sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId, cardReplyTarget.turnId, + const messageId = await sessionReply( + anchorAtPost, cardJson, 'interactive', appIdAtPost, cardReplyTarget.turnId, ); + if (!stillOwnsPost()) { + void deleteMessage(appIdAtPost, messageId).catch(() => { /* stale result */ }); + restorePrePostIdentityForRetirement(); + return false; + } + ds.streamCardId = messageId; ds.streamCardReplyTargetKey = cardReplyTarget.replyTargetKey; // This card is now the live one for the current turn. Clear the new-turn // pending flag so the next screen_update PATCHes it instead of POSTing a @@ -2291,6 +2722,7 @@ export async function postFreshStreamingCard( // /card forces them on, so a stale pending flag would otherwise re-POST). ds.streamCardPending = false; ds.parkedStreamCardNonce = undefined; + const predecessorIds = snapshotStreamingCardPredecessorIds(ds, messageId); persistStreamCardState(ds); recallFrozenCards(ds); flushPendingLocalCliOpenReadinessPatch(ds); @@ -2302,12 +2734,17 @@ export async function postFreshStreamingCard( // usage refresh here, now that the real id is committed and pending cleared. syncUsageRefreshTimer(ds); logger.info(`[${tag(ds)}] Posted streaming card via /card`); + continuePublishedStreamingCardPinChain(ds, messageId, predecessorIds); return true; } catch (err) { - ds.streamCardId = prevCardId; - ds.streamCardNonce = prevNonce; - ds.streamCardReplyTargetKey = prevReplyTargetKey; - ds.streamCardPending = prevPending; + if (stillOwnsPost()) { + ds.streamCardId = prevCardId; + ds.streamCardNonce = prevNonce; + ds.streamCardReplyTargetKey = prevReplyTargetKey; + ds.streamCardPending = prevPending; + } else { + restorePrePostIdentityForRetirement(); + } flushPendingLocalCliOpenReadinessPatch(ds); flushPendingRiffUrlPatch(ds); flushPendingActiveRuntimePatch(ds); @@ -5548,6 +5985,39 @@ export async function closeSession( // sessionStore commonly holds the very same Session reference as `ds`. const known = !!ds || !!stored; const wasOpen = !!stored && stored.status !== 'closed'; + // Frozen sidecars are deleted by the successful store close. Capture all + // public streaming-card ids before that transaction, never await their + // cleanup on the close path. + const closeAppId = ds?.larkAppId ?? stored?.larkAppId; + let closeStoredOwner: StreamingCardOwner | undefined; + let closePinnedStreamingIds: string[] = []; + if (closeAppId) { + if (ds) { + closePinnedStreamingIds = captureLifecycleStreamingCardCleanupIds( + closeAppId, + ds.chatId, + ds, + snapshotStreamingCardIds(ds), + ); + } else if (stored) { + const ids = new Set(); + if (isRealStreamingCardId(stored.streamCardId)) ids.add(stored.streamCardId); + try { + for (const frozen of loadFrozenCards(stored.sessionId).values()) { + if (isRealStreamingCardId(frozen.messageId)) ids.add(frozen.messageId); + } + } catch (err) { + logger.debug(`[${sessionId.slice(0, 8)}] could not load frozen cards for close Pin cleanup: ${err instanceof Error ? err.message : String(err)}`); + } + closeStoredOwner = { sessionId: stored.sessionId, larkAppId: closeAppId }; + closePinnedStreamingIds = captureLifecycleStreamingCardCleanupIds( + closeAppId, + stored.chatId, + closeStoredOwner, + [...ids], + ); + } + } // P1-13:关闭必须显式广播 `preview: null`。sessionStore.closeSession 会把字段从磁盘 // 上抹掉,但没有事件——浏览器侧只会收到 `session.exited`,会话卡片上的预览入口就 // 那么留着,Dashboard 的 preview SSE/WS 也拿不到断流信号。 @@ -5673,6 +6143,10 @@ export async function closeSession( } } if (hadPreviewTarget) publishSessionPreviewCleared(sessionId); + const closeStreamingOwner = ds ?? closeStoredOwner; + if (closeAppId && closeStreamingOwner && closePinnedStreamingIds.length > 0) { + void unpinStreamingCardIds(closeAppId, closePinnedStreamingIds, closeStreamingOwner); + } } if (ds) { @@ -7052,6 +7526,12 @@ export async function transferSession( const oldAnchor = sessionAnchorId(ds); const oldChatId = ds.chatId; const oldStreamCardId = ds.streamCardId; + const sourcePinnedStreamingIds = captureLifecycleStreamingCardCleanupIds( + ds.larkAppId, + ds.chatId, + ds, + snapshotStreamingCardIds(ds), + ); const oldCurrentImageKey = ds.currentImageKey; // Scratch/store cleanup above awaited. A fresh source turn may have been @@ -7154,6 +7634,12 @@ export async function transferSession( } } routingCommitted = true; + // The route is now durable and the source card identity has been cleared. + // Unpin exactly the pre-commit capture: a target publication racing after + // this point must never be selected by source cleanup. + if (sourcePinnedStreamingIds.length > 0) { + void unpinStreamingCardIds(ds.larkAppId, sourcePinnedStreamingIds, ds); + } dashboardEventBus.publish({ type: 'session.update', @@ -10510,6 +10996,10 @@ function setupWorkerHandlers( // (if any) is left untouched. The next real user turn clears this flag // (rememberLastCliInput) and the normal card flow resumes. if (ds.suppressRecoveryCard) { + const currentStreamCardId = ds.streamCardId; + if (currentStreamCardId && ownsCurrentStreamingCard(ds, currentStreamCardId)) { + continuePublishedStreamingCardPinChain(ds, currentStreamCardId); + } logger.info(`[${t}] Restored session — suppressing recovery streaming card (silent restart)`); break; } @@ -10523,6 +11013,18 @@ function setupWorkerHandlers( ? ds.streamCardId : undefined; if (restoredCardId) { + const restoredSession = ds.session; + const restoredAppId = ds.larkAppId; + const restoredAnchor = sessionAnchorId(ds); + const restoredRegistryKey = sessionKey(restoredAnchor, restoredAppId); + const ownsRestoredCard = (): boolean => + ds.session === restoredSession + && ds.session.status === 'active' + && ds.larkAppId === restoredAppId + && sessionAnchorId(ds) === restoredAnchor + && !isSessionTransferring(ds) + && ds.streamCardId === restoredCardId + && activeSessionsRegistry?.get(restoredRegistryKey) === ds; try { const initTitle = ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg); // Reuse persisted nonce so existing card buttons (toggle/etc) keep working. @@ -10570,10 +11072,8 @@ function setupWorkerHandlers( if (ds.codexServiceTier !== codexTierAtBuild) { scheduleCodexServiceTierPatch(ds); } + const predecessorIds = snapshotStreamingCardPredecessorIds(ds, restoredCardId); persistStreamCardState(ds); - // The restored card is now the active one — withdraw any cards - // frozen before the daemon went down so they don't pile up in the - // thread on each restart. recallFrozenCards(ds); logger.info(`[${t}] Reused existing streaming card ${restoredCardId.substring(0, 12)} after worker (re)start`); // Auto-restart recovery: if the reused card is a still-`working` @@ -10581,9 +11081,11 @@ function setupWorkerHandlers( // post-restart screen_update is typically working→working // (statusChanged=false) and would break before the arm choke point. syncUsageRefreshTimer(ds); + continuePublishedStreamingCardPinChain(ds, restoredCardId, predecessorIds); + if (!ownsRestoredCard()) break; break; } catch (err) { - if (!ownsLifecycleMutation()) break; + if (!ownsLifecycleMutation() || !ownsRestoredCard()) break; // PATCH failed (withdrawn, expired, etc.) — fall through to POST a fresh card. logger.info(`[${t}] Failed to reuse existing streaming card (${err instanceof Error ? err.message : err}), posting new one`); ds.streamCardId = undefined; @@ -10603,9 +11105,36 @@ function setupWorkerHandlers( const postingGeneration = ds.streamCardTurnGeneration ?? 0; const cardReplyTarget = captureStreamingCardReplyTarget(ds, msg.turnId); const statusRevisionAtPost = ds.streamCardStatusRevision ?? 0; + const postingSession = ds.session; + const postingAppId = ds.larkAppId; + const postingAnchor = sessionAnchorId(ds); + const postingRegistryKey = sessionKey(postingAnchor, postingAppId); ds.streamCardId = CARD_POSTING_SENTINEL; + let ownsFreshReadyPost = (): boolean => false; + let restoreFreshReadyPrePostIdentityForRetirement = (): boolean => false; + let stillOwnsFreshReadyPost = (): boolean => false; try { ds.streamCardNonce = randomBytes(4).toString('hex'); + const postingNonce = ds.streamCardNonce; + ownsFreshReadyPost = (): boolean => + ds.session === postingSession + && ds.session.status === 'active' + && ds.larkAppId === postingAppId + && sessionAnchorId(ds) === postingAnchor + && !isSessionTransferring(ds) + && ds.streamCardId === CARD_POSTING_SENTINEL + && ds.streamCardNonce === postingNonce + && activeSessionsRegistry?.get(postingRegistryKey) === ds; + restoreFreshReadyPrePostIdentityForRetirement = (): boolean => { + if (remoteRetirementAdmissionPhase(ds) === null || !ownsFreshReadyPost()) return false; + ds.streamCardId = undefined; + persistStreamCardState(ds); + return true; + }; + stillOwnsFreshReadyPost = (): boolean => + ownsFreshReadyPost() + && remoteRetirementAdmissionPhase(ds) === null + && retainsLarkStreamingCardTransport(ds); const initTitle = ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg); // See PATCH-branch comment above re: lastScreenStatus preference. // For relay (kill+fork with surviving tmux/CLI), this avoids the @@ -10642,8 +11171,9 @@ function setupWorkerHandlers( const postedCardId = await scopedReply( streamCardJson, 'interactive', cardReplyTarget.turnId, ); - if (!ownsLifecycleMutation()) { - void deleteMessage(ds.larkAppId, postedCardId).catch(() => { /* best-effort stale-card cleanup */ }); + if (!ownsLifecycleMutation() || !stillOwnsFreshReadyPost()) { + void deleteMessage(postingAppId, postedCardId).catch(() => { /* best-effort stale-card cleanup */ }); + restoreFreshReadyPrePostIdentityForRetirement(); break; } ds.streamCardId = postedCardId; @@ -10662,6 +11192,7 @@ function setupWorkerHandlers( ds.streamCardPendingTurnId = undefined; } ds.parkedStreamCardNonce = undefined; + const predecessorIds = snapshotStreamingCardPredecessorIds(ds, postedCardId); persistStreamCardState(ds); // New card is live — recall any cards frozen by previous turns. // Done after `streamCardId` is committed so we never delete the old @@ -10678,11 +11209,15 @@ function setupWorkerHandlers( if (!superseded) { reconcilePostedStartingCard(ds, cardReplyTarget.turnId, statusRevisionAtPost); } - if (superseded && ds.streamCardPendingTurnId) { + continuePublishedStreamingCardPinChain(ds, postedCardId, predecessorIds); + if ((ds.streamCardTurnGeneration ?? 0) !== postingGeneration && ds.streamCardPendingTurnId) { void postTurnStartingCard(ds, cb.sessionReply, ds.streamCardPendingTurnId); } } catch (err) { - if (!ownsLifecycleMutation()) break; + if (!ownsLifecycleMutation() || !stillOwnsFreshReadyPost()) { + restoreFreshReadyPrePostIdentityForRetirement(); + break; + } if (err instanceof MessageWithdrawnError) { await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating worker-ready card'); break; @@ -11118,11 +11653,36 @@ function setupWorkerHandlers( // not POSTed as duplicate cards. ds.streamCardPending = false; ds.streamCardId = CARD_POSTING_SENTINEL; + const postingSession = ds.session; + const postingAppId = ds.larkAppId; + const postingAnchor = sessionAnchorId(ds); + const postingRegistryKey = sessionKey(postingAnchor, postingAppId); + const postingNonce = ds.streamCardNonce; + const ownsFreshScreenPost = (): boolean => + ds.session === postingSession + && ds.session.status === 'active' + && ds.larkAppId === postingAppId + && sessionAnchorId(ds) === postingAnchor + && !isSessionTransferring(ds) + && ds.streamCardId === CARD_POSTING_SENTINEL + && ds.streamCardNonce === postingNonce + && activeSessionsRegistry?.get(postingRegistryKey) === ds; + const restoreFreshScreenPrePostIdentityForRetirement = (): boolean => { + if (remoteRetirementAdmissionPhase(ds) === null || !ownsFreshScreenPost()) return false; + ds.streamCardId = undefined; + persistStreamCardState(ds); + return true; + }; + const stillOwnsFreshScreenPost = (): boolean => + ownsFreshScreenPost() + && remoteRetirementAdmissionPhase(ds) === null + && retainsLarkStreamingCardTransport(ds); const cardReplyTarget = captureStreamingCardReplyTarget(ds, msg.turnId); scopedReply(cardJson, 'interactive', cardReplyTarget.turnId) - .then(msgId => { - if (!ownsLifecycleMutation()) { - void deleteMessage(ds.larkAppId, msgId).catch(() => { /* best-effort stale-card cleanup */ }); + .then(async msgId => { + if (!ownsLifecycleMutation() || !stillOwnsFreshScreenPost()) { + void deleteMessage(postingAppId, msgId).catch(() => { /* best-effort stale-card cleanup */ }); + restoreFreshScreenPrePostIdentityForRetirement(); return; } ds.streamCardId = msgId; @@ -11130,6 +11690,7 @@ function setupWorkerHandlers( const superseded = (ds.streamCardTurnGeneration ?? 0) !== postingGeneration; if (!superseded) ds.streamCardPendingTurnId = undefined; ds.parkedStreamCardNonce = undefined; + const predecessorIds = snapshotStreamingCardPredecessorIds(ds, msgId); persistStreamCardState(ds); // New card live — recall any cards parked by previous turns // (user message, bot @mention, adopt-bridge new turn, etc.). @@ -11146,12 +11707,16 @@ function setupWorkerHandlers( // periodic usage refresh here (once the real card id exists, not // the POSTING sentinel). syncUsageRefreshTimer re-checks state. syncUsageRefreshTimer(ds); - if (superseded && ds.streamCardPendingTurnId) { + continuePublishedStreamingCardPinChain(ds, msgId, predecessorIds); + if ((ds.streamCardTurnGeneration ?? 0) !== postingGeneration && ds.streamCardPendingTurnId) { void postTurnStartingCard(ds, cb.sessionReply, ds.streamCardPendingTurnId); } }) .catch(async err => { - if (!ownsLifecycleMutation()) return; + if (!ownsLifecycleMutation() || !stillOwnsFreshScreenPost()) { + restoreFreshScreenPrePostIdentityForRetirement(); + return; + } if (err instanceof MessageWithdrawnError) { await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating streaming card'); return; diff --git a/src/daemon.ts b/src/daemon.ts index 406b10b15..b296c7d9b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -83,6 +83,7 @@ import { type VcMeetingConsumerProfileConfig, } from './bot-registry.js'; import { setDisplayNameRefresher, findConfigField, applyConfigField } from './services/bot-config-store.js'; +import { registerPinStreamingCardChangeHandler } from './services/pin-streaming-card-change.js'; import { getSkillFeedbackStore } from './services/skill-feedback-store.js'; import { enqueueTurnTerminal, drainTurnTerminalQueue } from './services/turn-completion-events.js'; import { FeedbackWebhookSecretStore, startFeedbackWebhookDispatcher } from './services/feedback-webhook-dispatcher.js'; @@ -196,6 +197,7 @@ import { getDaemonBootId, getDaemonStreamingCardUsageSnapshot, postTurnStartingCard, + reconcileBotStreamingCardPins, isSessionTransferring, snapshotCodexAppFinalSettlements, codexAppFinalSettlementCount, @@ -21953,6 +21955,7 @@ export async function startDaemon(botIndex?: number): Promise { // Expose the activeSessions Map (owned by daemon) to worker-pool readers, // so dashboard IPC and other consumers can list/lookup live sessions. setActiveSessionsRegistry(activeSessions); + registerPinStreamingCardChangeHandler(reconcileBotStreamingCardPins); // Idempotency boot reconcile — MUST run before startIpcServer binds (a normal // fleet has no core-only readiness gate, so a live /api/trigger could otherwise diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 1fa47873a..ddea3a3ab 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -97,6 +97,7 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: usageDisplay: normalizeUsageDisplay(j ?? {}), usageSupported: j?.usageSupported === true, disableStreamingCard: j?.disableStreamingCard === true, + pinStreamingCard: j?.pinStreamingCard === true, silentTurnReactions: j?.silentTurnReactions === true, codexAppCleanInput: j?.codexAppCleanInput === true, writableTerminalLinkInCard: j?.writableTerminalLinkInCard === true, diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index d1faa7ecb..ce3558c03 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -693,6 +693,7 @@ function patchCardPrefsFromBody(bot: BotDefaultsRow, body: any): BotDefaultsRow ...bot, usageDisplay: body.usageDisplay, disableStreamingCard: body.disableStreamingCard, + pinStreamingCard: body.pinStreamingCard, silentTurnReactions: body.silentTurnReactions, codexAppCleanInput: body.codexAppCleanInput, writableTerminalLinkInCard: body.writableTerminalLinkInCard, @@ -3524,6 +3525,7 @@ export function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(pa const { bot, putCardPref } = props; const [usageDisplay, setUsageDisplay] = useState<'streaming' | 'footer' | 'off'>(bot.usageDisplay ?? 'streaming'); const [disableStreaming, setDisableStreaming] = useState(bot.disableStreamingCard === true); + const [pinStreamingCard, setPinStreamingCard] = useState(bot.pinStreamingCard === true); const [silentReactions, setSilentReactions] = useState(bot.silentTurnReactions === true); const [writableLink, setWritableLink] = useState(bot.writableTerminalLinkInCard === true); const [privateCard, setPrivateCard] = useState(bot.privateCard === true); @@ -3534,11 +3536,12 @@ export function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(pa useEffect(() => { setUsageDisplay(bot.usageDisplay ?? 'streaming'); setDisableStreaming(bot.disableStreamingCard === true); + setPinStreamingCard(bot.pinStreamingCard === true); setSilentReactions(bot.silentTurnReactions === true); setWritableLink(bot.writableTerminalLinkInCard === true); setPrivateCard(bot.privateCard === true); setThinkingCard(bot.thinkingCard !== false); - }, [bot.disableStreamingCard, bot.privateCard, bot.thinkingCard, bot.usageDisplay, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); + }, [bot.disableStreamingCard, bot.pinStreamingCard, bot.privateCard, bot.thinkingCard, bot.usageDisplay, bot.silentTurnReactions, bot.writableTerminalLinkInCard]); async function savePatch(patch: CardPrefPatch, key: string, rollback?: () => void): Promise { setBusy(key); @@ -3615,6 +3618,23 @@ export function CardBehaviorSection(props: { bot: BotDefaultsRow; putCardPref(pa void savePatch({ thinkingCard: checked }, 'thinking', () => setThinkingCard(previous)); }} /> + { + const previous = pinStreamingCard; + setPinStreamingCard(checked); + void savePatch( + { pinStreamingCard: checked }, + 'pin-streaming', + () => setPinStreamingCard(previous), + ); + }} + />
diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index 51198b815..555c7774d 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -84,6 +84,7 @@ export type BotDefaultsRow = { usageDisplay?: 'streaming' | 'footer' | 'off'; usageSupported?: boolean; disableStreamingCard?: boolean; + pinStreamingCard?: boolean; silentTurnReactions?: boolean; codexAppCleanInput?: boolean; writableTerminalLinkInCard?: boolean; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 93afe1f3f..cd8cb9c0c 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -2139,6 +2139,9 @@ const zh = { 'botDefaults.usageDisplayStreaming': '任务状态卡片内(默认)', 'botDefaults.usageDisplayFooter': '最终结果底部', 'botDefaults.usageDisplayOff': '不显示', + 'botDefaults.pinStreamingCard': '置顶当前实时卡片', + 'botDefaults.pinStreamingCardDescription': '只置顶当前公开的实时状态卡片;默认关闭。', + 'botDefaults.pinStreamingCardHelp': '仅当前公开实时状态卡片参与置顶,默认关闭;失败不会中断会话,其他类型卡片不参与。', 'botDefaults.autoStreaming': '任务执行时显示状态卡片', 'botDefaults.autoStreamingDescription': '默认开启;任务进行中持续更新执行状态。', 'botDefaults.autoStreamingHelp': '开启后,每次任务执行期间都会显示并持续更新状态卡片;关闭后只发送最终结果。', @@ -4707,6 +4710,9 @@ const en: Record = { 'botDefaults.usageDisplayStreaming': 'Task status card (default)', 'botDefaults.usageDisplayFooter': 'Final-result footer', 'botDefaults.usageDisplayOff': 'Off', + 'botDefaults.pinStreamingCard': 'Pin the current live card', + 'botDefaults.pinStreamingCardDescription': 'Pins only the current public live-status card. Off by default.', + 'botDefaults.pinStreamingCardHelp': 'Only the current public live-status card participates, the default is off, and failures never interrupt sessions.', 'botDefaults.autoStreaming': 'Show a status card while tasks run', 'botDefaults.autoStreamingDescription': 'On by default; continuously updates while a task is running.', 'botDefaults.autoStreamingHelp': 'When enabled, every task shows a status card that keeps updating while it runs. When disabled, only the final result is sent.', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b527f2a62..8572534dd 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -493,6 +493,7 @@ export const messages: Record = { 'card.config.p2p.chat': '💬 chat (continuous session, default)', 'card.config.p2p.group': '👥 group (a dedicated session group per DM)', 'config.label.disableStreamingCard': 'Disable live card', + 'config.label.pinStreamingCard': 'Pin live card', 'config.label.usageDisplay': 'Usage display', 'config.label.silentTurnReactions': 'Disable status reactions', 'config.label.writableTerminalLinkInCard': 'Writable terminal in card', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9d0092534..f11643126 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -494,6 +494,7 @@ export const messages: Record = { 'card.config.p2p.chat': '💬 chat(连续单聊会话,默认)', 'card.config.p2p.group': '👥 group(每条 DM 自动建专属会话群)', 'config.label.disableStreamingCard': '关闭实时卡片', + 'config.label.pinStreamingCard': '置顶实时卡片', 'config.label.usageDisplay': '用量显示位置', 'config.label.silentTurnReactions': '关闭状态 reaction', 'config.label.writableTerminalLinkInCard': '卡内嵌可写终端', diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 3820a4c19..a2d8a070a 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -90,7 +90,7 @@ import { logger } from '../../utils/logger.js'; import * as sessionStore from '../../services/session-store.js'; import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js'; import { resumeStartsFresh } from '../../services/resume-fresh-policy.js'; -import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, buildStreamingCardJson, silentIdleCardFlag, type WorkerSessionReplyOptions } from '../../core/worker-pool.js'; +import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, buildStreamingCardJson, canCommitStreamingCardPublication, continuePublishedStreamingCardPinChain, silentIdleCardFlag, type WorkerSessionReplyOptions } 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, publishClosedSessionPatch, announcePendingRepoSession } from '../../core/session-activity.js'; @@ -2690,11 +2690,29 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe if (cardMessageId && value?.visibility !== 'private' && !botCfgResume.privateCard) { const staleCardId = cardMessageId; const resumedDs = result.ds; + const resumedSession = resumedDs.session; + const resumedAppId = resumedDs.larkAppId; + const priorCardId = resumedDs.streamCardId; + const resumePostFence = { + session: resumedSession, + larkAppId: resumedAppId, + anchorId: sessionAnchorId(resumedDs), + expectedPriorCardId: priorCardId, + }; void (async () => { try { const freshCardId = await sessionReply(rootId, buildStreamingCardJson(resumedDs), 'interactive'); + if (!canCommitStreamingCardPublication(resumedDs, resumePostFence)) { + void deleteMessage(resumedAppId, freshCardId).catch(() => { /* stale repost */ }); + return; + } resumedDs.streamCardId = freshCardId; persistStreamCardState(resumedDs); + // Pin is a QoL side effect, never a resume-commit barrier. Its + // detached chain re-checks ownership and compensates a late + // Pin; the committed card may immediately withdraw its sole + // predecessor and emit the user receipt. + continuePublishedStreamingCardPinChain(resumedDs, freshCardId, priorCardId ? [priorCardId] : []); await deleteMessage(resumedDs.larkAppId, staleCardId).catch(() => { /* already withdrawn/expired */ }); // Also send the "✅ 会话已恢复…" text follow-up (the original resume // behavior). Both are wanted: the live streaming card AND the text diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index a038e9406..98ce877d9 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -914,6 +914,48 @@ export async function deleteMessage(larkAppId: string, messageId: string): Promi } } +/** + * Pin a message in a chat (best-effort QoL). Returns `true` only when Lark + * explicitly confirms success (`code === 0`); any other outcome returns `false` + * and must not affect session behavior. + */ +export async function pinMessage(larkAppId: string, messageId: string): Promise { + assertLarkTransport(larkAppId, 'pinMessage'); + const c = getBotClient(larkAppId); + try { + const res: any = await c.im.v1.pin.create({ data: { message_id: messageId } }); + if (res?.code !== 0) { + logger.warn(`[pin:${larkAppId}] failed message=${messageId} code=${res?.code ?? 'missing'}`); + return false; + } + return true; + } catch (err) { + logger.warn(`[pin:${larkAppId}] failed message=${messageId}: ${formatLarkError(err) ?? (err instanceof Error ? err.message : 'unknown error')}`); + return false; + } +} + +/** + * Unpin a message in a chat (best-effort QoL). Returns `true` only when Lark + * explicitly confirms success (`code === 0`); any other outcome returns `false` + * and must not affect session behavior. + */ +export async function unpinMessage(larkAppId: string, messageId: string): Promise { + assertLarkTransport(larkAppId, 'unpinMessage'); + const c = getBotClient(larkAppId); + try { + const res: any = await c.im.v1.pin.delete({ path: { message_id: messageId } }); + if (res?.code !== 0) { + logger.warn(`[unpin:${larkAppId}] failed message=${messageId} code=${res?.code ?? 'missing'}`); + return false; + } + return true; + } catch (err) { + logger.warn(`[unpin:${larkAppId}] failed message=${messageId}: ${formatLarkError(err) ?? (err instanceof Error ? err.message : 'unknown error')}`); + return false; + } +} + /** Error code Feishu returns from `ephemeral/v1/send` when the target chat is a * topic / thread chat. Ephemeral cards only work in plain `group` chats (see * /tmp design notes: empirically code 18053 `chat can not be thread`). */ diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index 0ed0bd5d0..c4c4717a8 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -28,6 +28,10 @@ import { parseStartupCommandsInput } from '../core/startup-commands.js'; import { isReservedPerBotEnvKey, sanitizePerBotEnv } from '../core/per-bot-env.js'; import { normalizeFeedbackPolicy } from './feedback-policy.js'; import { normalizeFeedbackPolicyLayer, type FeedbackPolicyLayer } from './feedback-policy-resolver.js'; +import { + notifyPinStreamingCardChanged, + serializePinStreamingCardConfigChange, +} from './pin-streaming-card-change.js'; import { cliModelSupportsReasoningEffort, isCodexReasoningEffort, @@ -89,6 +93,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'skills', configKey: 'skills', kind: 'json', effect: 'next-session', clearable: true, hint: 'bot 级 skill policy JSON;unset 回底层 CLI 默认行为' }, { key: 'feedback', configKey: 'feedback', kind: 'json', effect: 'immediate', clearable: true, hint: '最终回答反馈 JSON;默认关闭,enabled=true 后按本 bot 启用;unset 关闭' }, { key: 'disableStreamingCard', configKey: 'disableStreamingCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '关闭实时流式卡片 on|off' }, + { key: 'pinStreamingCard', configKey: 'pinStreamingCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '置顶当前公开实时卡片 on|off(失败不影响会话)' }, { key: 'thinkingCard', configKey: 'thinkingCard', kind: 'boolean', effect: 'immediate', clearable: false, defaultOn: true, hint: '思考过程消息 on|off(默认 on):turn 进行中把模型思考过程以飞书原生 CoT 消息(message_cot)流式展示(客户端需 PC ≥7.70 / 移动端 ≥7.74;当前支持 claude-code / codex)。这是 bot 级总开关,单个群可用 /cot off 关闭' }, { key: 'silentTurnReactions', configKey: 'silentTurnReactions', kind: 'boolean', effect: 'immediate', clearable: false, hint: '关闭无卡片模式下的 GoGoGo/DONE 消息 reaction on|off' }, { key: 'writableTerminalLinkInCard', configKey: 'writableTerminalLinkInCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '卡片内嵌可写终端链接 on|off' }, @@ -227,10 +232,27 @@ export async function applyConfigField( larkAppId: string, spec: ConfigFieldSpec, value: unknown, +): Promise { + if (spec.configKey === 'pinStreamingCard') { + return serializePinStreamingCardConfigChange( + larkAppId, + () => applyConfigFieldInternal(larkAppId, spec, value), + ); + } + return applyConfigFieldInternal(larkAppId, spec, value); +} + +async function applyConfigFieldInternal( + larkAppId: string, + spec: ConfigFieldSpec, + value: unknown, ): Promise { if (spec.kind === 'allowedUsers') return { ok: false, reason: 'use_setBotAllowedUsers' }; let bot; try { bot = getBot(larkAppId); } catch { return { ok: false, reason: 'bot_not_registered' }; } + const previousPinStreamingCard = spec.configKey === 'pinStreamingCard' + ? bot.config.pinStreamingCard === true + : undefined; const oldText = formatFieldValue(spec, (bot.config as any)[spec.configKey]); // 空数组(stringList 全被过滤)等价清除,bots.json 保持干净。 @@ -324,6 +346,12 @@ export async function applyConfigField( if (spec.configKey === 'displayName') { try { displayNameRefresher?.(); } catch { /* best effort */ } } + if (spec.configKey === 'pinStreamingCard' && previousPinStreamingCard !== undefined) { + const nextPinStreamingCard = bot.config.pinStreamingCard === true; + if (previousPinStreamingCard !== nextPinStreamingCard) { + notifyPinStreamingCardChanged(larkAppId, nextPinStreamingCard); + } + } logger.info(`[config:${larkAppId}] set ${spec.key}: ${oldText} -> ${newText}`); return { ok: true, oldText, newText, effect: spec.effect }; } diff --git a/src/services/card-prefs-store.ts b/src/services/card-prefs-store.ts index 4c8906238..610360c61 100644 --- a/src/services/card-prefs-store.ts +++ b/src/services/card-prefs-store.ts @@ -38,6 +38,10 @@ import { type UsageDisplayMode, } from '../bot-registry.js'; import { logger } from '../utils/logger.js'; +import { + notifyPinStreamingCardChanged, + serializePinStreamingCardConfigChange, +} from './pin-streaming-card-change.js'; export interface BotCardPrefs { /** Where to show native Context / Token usage: @@ -45,6 +49,7 @@ export interface BotCardPrefs { * reply-card footer, 'off' = nowhere. */ usageDisplay: UsageDisplayMode; disableStreamingCard: boolean; + pinStreamingCard: boolean; silentTurnReactions: boolean; /** Experimental Codex App presentation mode. Default false preserves the * legacy full-prompt UserMessage; true moves Botmux metadata to hidden @@ -95,6 +100,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { return { usageDisplay: normalizeUsageDisplay(c), disableStreamingCard: c.disableStreamingCard === true, + pinStreamingCard: c.pinStreamingCard === true, silentTurnReactions: c.silentTurnReactions === true, codexAppCleanInput: c.codexAppCleanInput === true, writableTerminalLinkInCard: c.writableTerminalLinkInCard === true, @@ -117,6 +123,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { return { usageDisplay: DEFAULT_USAGE_DISPLAY, disableStreamingCard: false, + pinStreamingCard: false, silentTurnReactions: false, codexAppCleanInput: false, writableTerminalLinkInCard: false, @@ -145,9 +152,23 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { export async function updateBotCardPrefs( larkAppId: string, patch: Partial, +): Promise<{ ok: true; prefs: BotCardPrefs } | { ok: false; reason: string }> { + if (patch.pinStreamingCard !== undefined) { + return serializePinStreamingCardConfigChange( + larkAppId, + () => updateBotCardPrefsInternal(larkAppId, patch), + ); + } + return updateBotCardPrefsInternal(larkAppId, patch); +} + +async function updateBotCardPrefsInternal( + larkAppId: string, + patch: Partial, ): Promise<{ ok: true; prefs: BotCardPrefs } | { ok: false; reason: string }> { let bot; try { bot = getBot(larkAppId); } catch { return { ok: false, reason: 'bot_not_registered' }; } + const previousPinStreamingCard = bot.config.pinStreamingCard === true; const apply = (entry: any, key: keyof BotCardPrefs, val: boolean | undefined) => { if (val === undefined) return; @@ -199,6 +220,7 @@ export async function updateBotCardPrefs( const r = await rmwBotEntry(larkAppId, (entry) => { applyUsageDisplay(entry, 'usageDisplay', patch.usageDisplay); apply(entry, 'disableStreamingCard', patch.disableStreamingCard); + apply(entry, 'pinStreamingCard', patch.pinStreamingCard); apply(entry, 'silentTurnReactions', patch.silentTurnReactions); apply(entry, 'codexAppCleanInput', patch.codexAppCleanInput); apply(entry, 'writableTerminalLinkInCard', patch.writableTerminalLinkInCard); @@ -220,6 +242,7 @@ export async function updateBotCardPrefs( result: { usageDisplay: normalizeUsageDisplay(entry), disableStreamingCard: entry.disableStreamingCard === true, + pinStreamingCard: entry.pinStreamingCard === true, silentTurnReactions: entry.silentTurnReactions === true, codexAppCleanInput: entry.codexAppCleanInput === true, writableTerminalLinkInCard: entry.writableTerminalLinkInCard === true, @@ -255,6 +278,9 @@ export async function updateBotCardPrefs( if (patch.disableStreamingCard !== undefined) { bot.config.disableStreamingCard = patch.disableStreamingCard || undefined; } + if (patch.pinStreamingCard !== undefined) { + bot.config.pinStreamingCard = patch.pinStreamingCard || undefined; + } if (patch.silentTurnReactions !== undefined) { bot.config.silentTurnReactions = patch.silentTurnReactions || undefined; } @@ -310,9 +336,14 @@ export async function updateBotCardPrefs( if (patch.summaryMemoryPath !== undefined) { bot.config.summaryMemoryPath = patch.summaryMemoryPath.trim() ? patch.summaryMemoryPath.trim() : undefined; } + const nextPinStreamingCard = bot.config.pinStreamingCard === true; + if (patch.pinStreamingCard !== undefined && previousPinStreamingCard !== nextPinStreamingCard) { + notifyPinStreamingCardChanged(larkAppId, nextPinStreamingCard); + } logger.info( `[card-prefs:${larkAppId}] usageDisplay=${r.result.usageDisplay} ` + `disableStreamingCard=${r.result.disableStreamingCard} ` + + `pinStreamingCard=${r.result.pinStreamingCard} ` + `silentTurnReactions=${r.result.silentTurnReactions} ` + `codexAppCleanInput=${r.result.codexAppCleanInput} ` + `writableTerminalLinkInCard=${r.result.writableTerminalLinkInCard} privateCard=${r.result.privateCard} ` + diff --git a/src/services/pin-streaming-card-change.ts b/src/services/pin-streaming-card-change.ts new file mode 100644 index 000000000..4193b5b78 --- /dev/null +++ b/src/services/pin-streaming-card-change.ts @@ -0,0 +1,67 @@ +import { logger } from '../utils/logger.js'; + +export type PinStreamingCardChangeHandler = + (larkAppId: string, enabled: boolean) => void | PromiseLike; + +let currentHandler: PinStreamingCardChangeHandler | null = null; +const configChangeQueues = new Map>(); + +export function registerPinStreamingCardChangeHandler( + handler: PinStreamingCardChangeHandler, +): () => void { + currentHandler = handler; + return () => { + if (currentHandler === handler) currentHandler = null; + }; +} + +/** + * Serialize pinStreamingCard config mutations per bot across every write entry + * point. The queue covers the whole write -> live-sync -> notify scheduling + * critical section, but does not wait for the asynchronous reconciliation work + * behind the notification handler itself. + */ +export async function serializePinStreamingCardConfigChange( + larkAppId: string, + operation: () => T | PromiseLike, +): Promise { + const previous = configChangeQueues.get(larkAppId) ?? Promise.resolve(); + const ready = previous.catch(() => undefined); + let release!: () => void; + const tail = ready.then(() => new Promise((resolve) => { + release = resolve; + })); + configChangeQueues.set(larkAppId, tail); + void tail.finally(() => { + if (configChangeQueues.get(larkAppId) === tail) configChangeQueues.delete(larkAppId); + }); + + await ready; + try { + return await operation(); + } finally { + release(); + } +} + +export function notifyPinStreamingCardChanged( + larkAppId: string, + enabled: boolean, +): void { + if (!currentHandler) return; + try { + Promise.resolve(currentHandler(larkAppId, enabled)).catch((error) => { + logger.warn( + `[pin-streaming-card] pinStreamingCard change handler failed ` + + `app=${larkAppId} enabled=${enabled}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + }); + } catch (error) { + logger.warn( + `[pin-streaming-card] pinStreamingCard change handler failed ` + + `app=${larkAppId} enabled=${enabled}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/test/bot-config-store.test.ts b/test/bot-config-store.test.ts index 032b23265..92a7d4afb 100644 --- a/test/bot-config-store.test.ts +++ b/test/bot-config-store.test.ts @@ -42,7 +42,8 @@ async function freshModules() { vi.resetModules(); const registry = await import('../src/bot-registry.js'); const store = await import('../src/services/bot-config-store.js'); - return { registry, store }; + const pinStreamingCardChange = await import('../src/services/pin-streaming-card-change.js'); + return { registry, store, pinStreamingCardChange }; } describe('bot-config store', () => { @@ -72,9 +73,9 @@ describe('bot-config store', () => { } async function loaded(entry: Record = {}) { writeConfig(entry); - const { registry, store } = await freshModules(); + const { registry, store, pinStreamingCardChange } = await freshModules(); registry.loadBotConfigs().forEach((c: any) => registry.registerBot(c)); - return { registry, store }; + return { registry, store, pinStreamingCardChange }; } it('CONFIG_FIELDS have unique keys and include allowedUsers', async () => { @@ -135,6 +136,7 @@ describe('bot-config store', () => { const { store } = await freshModules(); expect(store.findConfigField('MODEL')?.configKey).toBe('model'); expect(store.findConfigField('disablestreamingcard')?.configKey).toBe('disableStreamingCard'); + expect(store.findConfigField('PINSTREAMINGCARD')?.configKey).toBe('pinStreamingCard'); expect(store.findConfigField('nope')).toBeUndefined(); }); @@ -509,6 +511,108 @@ describe('bot-config store', () => { expect(registry.getBot('app_default').config.silentTurnReactions).toBeUndefined(); }); + it('pinStreamingCard is an immediate default-off boolean', async () => { + const { registry, store } = await loaded(); + const spec = store.findConfigField('PINSTREAMINGCARD')!; + expect(spec).toMatchObject({ + configKey: 'pinStreamingCard', + kind: 'boolean', + effect: 'immediate', + clearable: false, + }); + + const on = await store.applyConfigField('app_default', spec, true); + expect(on).toMatchObject({ ok: true, oldText: 'off', newText: 'on' }); + expect(readConfig().pinStreamingCard).toBe(true); + expect(registry.getBot('app_default').config.pinStreamingCard).toBe(true); + + const off = await store.applyConfigField('app_default', spec, false); + expect(off).toMatchObject({ ok: true, oldText: 'on', newText: 'off' }); + expect(readConfig().pinStreamingCard).toBeUndefined(); + expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); + }); + + it('notifies pinStreamingCard changes only after disk and live memory are synchronized', async () => { + const { registry, store, pinStreamingCardChange } = await loaded(); + const spec = store.findConfigField('PINSTREAMINGCARD')!; + const observed: Array<{ enabled: boolean; disk: unknown; memory: unknown }> = []; + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler((appId, enabled) => { + observed.push({ + enabled, + disk: readConfig().pinStreamingCard, + memory: registry.getBot(appId).config.pinStreamingCard, + }); + }); + + try { + const on = await store.applyConfigField('app_default', spec, true); + expect(on.ok).toBe(true); + + const off = await store.applyConfigField('app_default', spec, false); + expect(off.ok).toBe(true); + } finally { + dispose(); + } + + expect(observed).toEqual([ + { enabled: true, disk: true, memory: true }, + { enabled: false, disk: undefined, memory: undefined }, + ]); + }); + + it('does not notify pinStreamingCard no-op writes when the effective boolean is unchanged', async () => { + const { registry, store, pinStreamingCardChange } = await loaded(); + const spec = store.findConfigField('PINSTREAMINGCARD')!; + const observed: Array<{ enabled: boolean; disk: unknown; memory: unknown }> = []; + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler((appId, enabled) => { + observed.push({ + enabled, + disk: readConfig().pinStreamingCard, + memory: registry.getBot(appId).config.pinStreamingCard, + }); + }); + + try { + const offNoop = await store.applyConfigField('app_default', spec, false); + expect(offNoop.ok).toBe(true); + + const on = await store.applyConfigField('app_default', spec, true); + expect(on.ok).toBe(true); + + const onNoop = await store.applyConfigField('app_default', spec, true); + expect(onNoop.ok).toBe(true); + + const off = await store.applyConfigField('app_default', spec, false); + expect(off.ok).toBe(true); + + const offNoopAgain = await store.applyConfigField('app_default', spec, false); + expect(offNoopAgain.ok).toBe(true); + } finally { + dispose(); + } + + expect(observed).toEqual([ + { enabled: true, disk: true, memory: true }, + { enabled: false, disk: undefined, memory: undefined }, + ]); + }); + + it('does not notify pinStreamingCard changes when the write fails', async () => { + const { store, pinStreamingCardChange } = await loaded(); + const spec = store.findConfigField('PINSTREAMINGCARD')!; + const seen = vi.fn(); + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler(seen); + + try { + const result = await store.applyConfigField('app_missing', spec, true); + expect(result).toMatchObject({ ok: false, reason: 'bot_not_registered' }); + } finally { + dispose(); + } + + expect(seen).not.toHaveBeenCalled(); + }); + it('number field (maxLiveWorkers) round-trips and clears on null', async () => { const { registry, store } = await loaded(); const spec = store.findConfigField('maxLiveWorkers')!; @@ -695,7 +799,7 @@ describe('bot-config store', () => { }); it('getConfigSnapshot reports current values + info', async () => { - const { store } = await loaded({ model: 'sonnet', disableStreamingCard: true }); + const { store } = await loaded({ model: 'sonnet', disableStreamingCard: true, pinStreamingCard: true }); const snap = store.getConfigSnapshot('app_default'); expect(snap.ok).toBe(true); if (snap.ok) { @@ -705,6 +809,8 @@ describe('bot-config store', () => { expect(model?.value).toBe('sonnet'); const card = snap.rows.find(r => r.key === 'disableStreamingCard'); expect(card?.value).toBe('on'); + const pin = snap.rows.find(r => r.key === 'pinStreamingCard'); + expect(pin?.value).toBe('on'); } }); @@ -768,7 +874,7 @@ describe('bot-config store', () => { }); it('getConfigCardData returns the card view (booleans + cli options + model choices)', async () => { - const { store } = await loaded({ model: 'opus', disableStreamingCard: true }); + const { store } = await loaded({ model: 'opus', disableStreamingCard: true, pinStreamingCard: true }); const data = store.getConfigCardData('app_default', ['opus', 'sonnet']); expect(data).not.toBeNull(); expect(data!.cliId).toBe('claude-code'); @@ -776,6 +882,9 @@ describe('bot-config store', () => { expect(data!.modelChoices).toEqual(['opus', 'sonnet']); expect(data!.cliOptions.length).toBeGreaterThan(0); expect(data!.booleans.find(b => b.key === 'disableStreamingCard')?.on).toBe(true); + expect(data!.booleans.find(b => b.key === 'pinStreamingCard')?.on).toBe(true); + const { store: store2 } = await loaded({ model: 'opus' }); + expect(store2.getConfigCardData('app_default', ['opus'])!.booleans.find(b => b.key === 'pinStreamingCard')?.on).toBe(false); expect(store.getConfigCardData('app_missing')).toBeNull(); }); diff --git a/test/bot-registry-grant.test.ts b/test/bot-registry-grant.test.ts index 09117edb9..b42943841 100644 --- a/test/bot-registry-grant.test.ts +++ b/test/bot-registry-grant.test.ts @@ -90,6 +90,19 @@ describe('bot-registry grant additions', () => { expect((cfgs[5] as any).showUsageInCardFooter).toBeUndefined(); }); + it('parses pinStreamingCard only as strict boolean true', () => { + expect(parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'pin1', larkAppSecret: 's', pinStreamingCard: true }, + ]))[0].pinStreamingCard).toBe(true); + + for (const bad of [undefined, false, 'true', 1, null]) { + const [cfg] = parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'pin2', larkAppSecret: 's', pinStreamingCard: bad }, + ])); + expect(cfg.pinStreamingCard).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/card-handler-resume-receipt.test.ts b/test/card-handler-resume-receipt.test.ts index 8cc2a903c..00388f458 100644 --- a/test/card-handler-resume-receipt.test.ts +++ b/test/card-handler-resume-receipt.test.ts @@ -14,6 +14,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { DaemonSession } from '../src/core/types.js'; +import { activeSessionKey } from '../src/core/types.js'; + +const { continuePublishedStreamingCardPinChainMock, deleteMessageMock } = vi.hoisted(() => ({ + continuePublishedStreamingCardPinChainMock: vi.fn(), + deleteMessageMock: vi.fn(async () => {}), +})); vi.mock('@larksuiteoapi/node-sdk', () => { class FakeClient { constructor(public opts: Record) {} } @@ -30,6 +36,19 @@ vi.mock('../src/core/session-manager.js', async (importOriginal) => { }; }); +vi.mock('../src/core/worker-pool.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + continuePublishedStreamingCardPinChain: continuePublishedStreamingCardPinChainMock, + }; +}); + +vi.mock('../src/im/lark/client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, deleteMessage: deleteMessageMock }; +}); + import { handleCardAction } from '../src/im/lark/card-handler.js'; const APP_ID = 'h1'; @@ -80,8 +99,17 @@ async function fresh() { const registry = await import('../src/bot-registry.js'); const handler = await import('../src/im/lark/card-handler.js'); const sessionManager = await import('../src/core/session-manager.js'); + const workerPool = await import('../src/core/worker-pool.js'); registry.loadBotConfigs().forEach(c => registry.registerBot(c)); - return { handler, resumeSession: sessionManager.resumeSession as unknown as ReturnType }; + return { handler, workerPool, resumeSession: sessionManager.resumeSession as unknown as ReturnType }; +} + +function activeDeps(ds: DaemonSession, sessionReply: ReturnType) { + return { + activeSessions: new Map([[activeSessionKey(ds), ds]]), + sessionReply, + lastRepoScan: new Map(), + } as any; } beforeEach(() => { @@ -100,16 +128,24 @@ beforeEach(() => { afterEach(() => { delete process.env.BOTS_CONFIG; vi.restoreAllMocks(); + continuePublishedStreamingCardPinChainMock.mockReset(); + deleteMessageMock.mockClear(); }); describe('card-handler resume receipt', () => { // The resume flow reposts the live streaming card FIRST (sessionReply with an // interactive card body) and then sends the "会话已恢复 / 新起干净会话" text - // receipt from a background task. So the receipt is no longer sessionReply - // call[0] — it is the TEXT call among the sessionReply calls. Extract it by - // content-type/shape rather than position, after letting the background task - // (a fire-and-forget async IIFE) flush. - const flushBackground = () => new Promise(resolve => setTimeout(resolve, 50)); + // receipt from a background task. The tests use an exact receipt barrier, + // rather than sleeping for a fire-and-forget task. + function replyWithReceiptBarrier() { + let receiptArrived!: () => void; + const receipt = new Promise(resolve => { receiptArrived = resolve; }); + const sessionReply = vi.fn(async (_rootId: string, content: string) => { + if (!content.trimStart().startsWith('{')) receiptArrived(); + return 'om_reply'; + }); + return { sessionReply, receipt }; + } const textReceipt = (sessionReply: ReturnType): string => { const textCalls = sessionReply.mock.calls .map(c => String(c[1] ?? '')) @@ -120,51 +156,168 @@ describe('card-handler resume receipt', () => { sessionReply.mock.calls.filter(c => String(c[1] ?? '').trimStart().startsWith('{')).length; it('copilot session without a cliSessionId: receipt says the next message starts a fresh session', async () => { - const { handler, resumeSession: mockedResume } = await fresh(); - mockedResume.mockResolvedValue({ ok: true, ds: makeDs('copilot') }); - const sessionReply = vi.fn(async () => 'om_reply'); - const deps = { activeSessions: new Map(), sessionReply, lastRepoScan: new Map() } as any; + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('copilot'); + mockedResume.mockResolvedValue({ ok: true, ds }); + const { sessionReply, receipt: receiptDone } = replyWithReceiptBarrier(); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); await handler.handleCardAction(resumeAction(), deps, APP_ID); - await flushBackground(); + await receiptDone; expect(mockedResume).toHaveBeenCalledWith('sess-resume-1', deps.activeSessions); // The live streaming card is reposted before the text receipt. expect(repostedCardCount(sessionReply)).toBe(1); - const receipt = textReceipt(sessionReply); - expect(receipt).toContain('话题路由已重新激活'); - expect(receipt).toContain('新起干净会话'); + const receiptText = textReceipt(sessionReply); + expect(receiptText).toContain('话题路由已重新激活'); + expect(receiptText).toContain('新起干净会话'); // Must NOT claim the history session is back. - expect(receipt).not.toContain('会话已恢复'); + expect(receiptText).not.toContain('会话已恢复'); }); it('copilot session WITH a cliSessionId: normal "session resumed" receipt', async () => { - const { handler, resumeSession: mockedResume } = await fresh(); - mockedResume.mockResolvedValue({ ok: true, ds: makeDs('copilot', 'cli-sess-9') }); - const sessionReply = vi.fn(async () => 'om_reply'); - const deps = { activeSessions: new Map(), sessionReply, lastRepoScan: new Map() } as any; + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('copilot', 'cli-sess-9'); + mockedResume.mockResolvedValue({ ok: true, ds }); + const { sessionReply, receipt: receiptDone } = replyWithReceiptBarrier(); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); await handler.handleCardAction(resumeAction(), deps, APP_ID); - await flushBackground(); + await receiptDone; expect(repostedCardCount(sessionReply)).toBe(1); - const receipt = textReceipt(sessionReply); - expect(receipt).toContain('会话已恢复'); - expect(receipt).not.toContain('新起干净会话'); + const receiptText = textReceipt(sessionReply); + expect(receiptText).toContain('会话已恢复'); + expect(receiptText).not.toContain('新起干净会话'); }); it('claude-code session (always resumable via botmux sessionId): normal receipt', async () => { - const { handler, resumeSession: mockedResume } = await fresh(); - mockedResume.mockResolvedValue({ ok: true, ds: makeDs('claude-code') }); - const sessionReply = vi.fn(async () => 'om_reply'); - const deps = { activeSessions: new Map(), sessionReply, lastRepoScan: new Map() } as any; + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('claude-code'); + mockedResume.mockResolvedValue({ ok: true, ds }); + const { sessionReply, receipt: receiptDone } = replyWithReceiptBarrier(); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); await handler.handleCardAction(resumeAction(), deps, APP_ID); - await flushBackground(); + await receiptDone; expect(repostedCardCount(sessionReply)).toBe(1); - const receipt = textReceipt(sessionReply); - expect(receipt).toContain('会话已恢复'); - expect(receipt).not.toContain('新起干净会话'); + const receiptText = textReceipt(sessionReply); + expect(receiptText).toContain('会话已恢复'); + expect(receiptText).not.toContain('新起干净会话'); + }); + + it('commits, withdraws predecessor, and sends receipt without waiting for the detached Pin chain', async () => { + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('claude-code'); + ds.streamCardId = 'om_prior_stream'; + mockedResume.mockResolvedValue({ ok: true, ds }); + // A hung Pin API must not hold the resume publication boundary. The real + // helper owns rejection handling and stale-Pin compensation; this seam + // verifies card-handler never awaits that detached chain. + continuePublishedStreamingCardPinChainMock.mockImplementation(() => new Promise(() => {})); + let releasePost!: (messageId: string) => void; + let markReceipt!: () => void; + let markPostStarted!: () => void; + const postStarted = new Promise(resolve => { markPostStarted = resolve; }); + const receiptDelivered = new Promise(resolve => { markReceipt = resolve; }); + const sessionReply = vi.fn((_rootId: string, content: string) => { + if (content.trimStart().startsWith('{')) { + markPostStarted(); + return new Promise(postResolve => { releasePost = postResolve; }); + } + markReceipt(); + return Promise.resolve('om_receipt'); + }); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); + + const action = handler.handleCardAction(resumeAction(), deps, APP_ID); + await postStarted; + releasePost('om_fresh_stream'); + await action; + await receiptDelivered; + + expect(continuePublishedStreamingCardPinChainMock).toHaveBeenCalledWith(ds, 'om_fresh_stream', ['om_prior_stream']); + expect(deleteMessageMock).toHaveBeenCalledWith(APP_ID, 'om_card'); + expect(textReceipt(sessionReply)).toContain('会话已恢复'); + }); + + it.each([ + ['registry absent', (pool: any, ds: DaemonSession) => pool.setActiveSessionsRegistry(undefined)], + ['registry empty', (pool: any) => pool.setActiveSessionsRegistry(new Map())], + ['registry displaced', (pool: any, ds: DaemonSession) => pool.setActiveSessionsRegistry(new Map([[activeSessionKey(ds), { ...ds }]]))], + ['route changed', (_pool: any, ds: DaemonSession) => { ds.session.rootMessageId = 'om_other_route'; }], + ['retirement started', (_pool: any, ds: DaemonSession) => { ds.remoteCloseState = { phase: 'preparing', requestId: 'close-resume' } as any; }], + ])('deletes a stale repost without Pin, predecessor delete, or receipt when %s', async (_name, invalidate) => { + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('claude-code'); + ds.streamCardId = 'om_prior_stream'; + mockedResume.mockResolvedValue({ ok: true, ds }); + let releasePost!: (messageId: string) => void; + let markPostStarted!: () => void; + let markStaleDelete!: () => void; + const postStarted = new Promise(resolve => { markPostStarted = resolve; }); + const staleDeleted = new Promise(resolve => { markStaleDelete = resolve; }); + const sessionReply = vi.fn((_rootId: string, content: string) => { + if (content.trimStart().startsWith('{')) { + markPostStarted(); + return new Promise(resolve => { releasePost = resolve; }); + } + return Promise.resolve('om_receipt'); + }); + deleteMessageMock.mockImplementation(async (_appId, messageId) => { + if (messageId === 'om_stale_repost') markStaleDelete(); + }); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); + + await handler.handleCardAction(resumeAction(), deps, APP_ID); + await postStarted; + invalidate(workerPool, ds); + releasePost('om_stale_repost'); + await staleDeleted; + + expect(ds.streamCardId).toBe('om_prior_stream'); + expect(deleteMessageMock.mock.calls).toEqual([[APP_ID, 'om_stale_repost']]); + expect(continuePublishedStreamingCardPinChainMock).not.toHaveBeenCalled(); + expect(textReceipt(sessionReply)).toBe(''); + }); + + it('deletes a stale repost without follow-ups when Lark transport becomes disabled', async () => { + const { handler, workerPool, resumeSession: mockedResume } = await fresh(); + const ds = makeDs('claude-code'); + ds.streamCardId = 'om_prior_stream'; + mockedResume.mockResolvedValue({ ok: true, ds }); + let releasePost!: (messageId: string) => void; + let markPostStarted!: () => void; + let markStaleDelete!: () => void; + const postStarted = new Promise(resolve => { markPostStarted = resolve; }); + const staleDeleted = new Promise(resolve => { markStaleDelete = resolve; }); + const sessionReply = vi.fn((_rootId: string, content: string) => { + if (content.trimStart().startsWith('{')) { + markPostStarted(); + return new Promise(resolve => { releasePost = resolve; }); + } + return Promise.resolve('om_receipt'); + }); + deleteMessageMock.mockImplementation(async (_appId, messageId) => { + if (messageId === 'om_stale_transport') markStaleDelete(); + }); + const deps = activeDeps(ds, sessionReply); + workerPool.setActiveSessionsRegistry(deps.activeSessions); + + await handler.handleCardAction(resumeAction(), deps, APP_ID); + await postStarted; + ds.chatId = 'http_async_resume_transport_lost'; + releasePost('om_stale_transport'); + await staleDeleted; + + expect(deleteMessageMock.mock.calls).toEqual([[APP_ID, 'om_stale_transport']]); + expect(continuePublishedStreamingCardPinChainMock).not.toHaveBeenCalled(); + expect(textReceipt(sessionReply)).toBe(''); }); }); diff --git a/test/card-prefs-auto-start.test.ts b/test/card-prefs-auto-start.test.ts index edf584d5a..c9864972a 100644 --- a/test/card-prefs-auto-start.test.ts +++ b/test/card-prefs-auto-start.test.ts @@ -22,9 +22,39 @@ vi.mock('@larksuiteoapi/node-sdk', () => { async function freshModules() { vi.resetModules(); + vi.doUnmock('../src/services/config-store.js'); const registry = await import('../src/bot-registry.js'); + const botConfigStore = await import('../src/services/bot-config-store.js'); const store = await import('../src/services/card-prefs-store.js'); - return { registry, store }; + const pinStreamingCardChange = await import('../src/services/pin-streaming-card-change.js'); + return { registry, botConfigStore, store, pinStreamingCardChange }; +} + +async function freshModulesWithConfigStoreMock( + mockFactory: ( + actual: typeof import('../src/services/config-store.js'), + ) => Promise | typeof import('../src/services/config-store.js'), +) { + vi.resetModules(); + vi.doMock('../src/services/config-store.js', async () => { + const actual = await vi.importActual('../src/services/config-store.js'); + return mockFactory(actual); + }); + const registry = await import('../src/bot-registry.js'); + const botConfigStore = await import('../src/services/bot-config-store.js'); + const store = await import('../src/services/card-prefs-store.js'); + const pinStreamingCardChange = await import('../src/services/pin-streaming-card-change.js'); + return { registry, botConfigStore, store, pinStreamingCardChange }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; } describe('card-prefs store — 主动开工 fields', () => { @@ -38,6 +68,7 @@ describe('card-prefs store — 主动开工 fields', () => { afterEach(() => { delete process.env.BOTS_CONFIG; + vi.doUnmock('../src/services/config-store.js'); }); function writeConfig(entry: Record = {}) { @@ -59,6 +90,7 @@ describe('card-prefs store — 主动开工 fields', () => { registry.loadBotConfigs().forEach(c => registry.registerBot(c)); const prefs = store.getBotCardPrefs('app_default'); + expect(prefs.pinStreamingCard).toBe(false); expect(prefs.autoStartOnGroupJoin).toBe(false); expect(prefs.autoStartOnNewTopic).toBe(false); expect(prefs.codexAppCleanInput).toBe(false); @@ -144,6 +176,110 @@ describe('card-prefs store — 主动开工 fields', () => { expect(registry.getBot('app_default').config.codexAppCleanInput).toBeUndefined(); }); + it('pinStreamingCard is default-off and round-trips without a restart', async () => { + writeConfig(); + const { registry, store } = await freshModules(); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + + expect(store.getBotCardPrefs('app_default').pinStreamingCard).toBe(false); + + const on = await store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); + expect(on.ok && on.prefs.pinStreamingCard).toBe(true); + expect(readConfig().pinStreamingCard).toBe(true); + expect(registry.getBot('app_default').config.pinStreamingCard).toBe(true); + + const off = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); + expect(off.ok && off.prefs.pinStreamingCard).toBe(false); + expect(readConfig().pinStreamingCard).toBeUndefined(); + expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); + }); + + it('notifies pinStreamingCard patches only after disk and live memory are synchronized', async () => { + writeConfig(); + const { registry, store, pinStreamingCardChange } = await freshModules(); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + const observed: Array<{ enabled: boolean; disk: unknown; memory: unknown }> = []; + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler((appId, enabled) => { + observed.push({ + enabled, + disk: readConfig().pinStreamingCard, + memory: registry.getBot(appId).config.pinStreamingCard, + }); + }); + + try { + const on = await store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); + expect(on.ok).toBe(true); + + const off = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); + expect(off.ok).toBe(true); + + const unrelated = await store.updateBotCardPrefs('app_default', { autoStartOnNewTopic: true }); + expect(unrelated.ok).toBe(true); + } finally { + dispose(); + } + + expect(observed).toEqual([ + { enabled: true, disk: true, memory: true }, + { enabled: false, disk: undefined, memory: undefined }, + ]); + }); + + it('does not notify pinStreamingCard no-op writes when the effective boolean is unchanged', async () => { + writeConfig(); + const { registry, store, pinStreamingCardChange } = await freshModules(); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + const observed: Array<{ enabled: boolean; disk: unknown; memory: unknown }> = []; + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler((appId, enabled) => { + observed.push({ + enabled, + disk: readConfig().pinStreamingCard, + memory: registry.getBot(appId).config.pinStreamingCard, + }); + }); + + try { + const offNoop = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); + expect(offNoop.ok).toBe(true); + + const on = await store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); + expect(on.ok).toBe(true); + + const onNoop = await store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); + expect(onNoop.ok).toBe(true); + + const off = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); + expect(off.ok).toBe(true); + + const offNoopAgain = await store.updateBotCardPrefs('app_default', { pinStreamingCard: false }); + expect(offNoopAgain.ok).toBe(true); + } finally { + dispose(); + } + + expect(observed).toEqual([ + { enabled: true, disk: true, memory: true }, + { enabled: false, disk: undefined, memory: undefined }, + ]); + }); + + it('does not notify pinStreamingCard changes when the write fails', async () => { + writeConfig(); + const { store, pinStreamingCardChange } = await freshModules(); + const seen = vi.fn(); + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler(seen); + + try { + const result = await store.updateBotCardPrefs('app_missing', { pinStreamingCard: true }); + expect(result).toMatchObject({ ok: false, reason: 'bot_not_registered' }); + } finally { + dispose(); + } + + expect(seen).not.toHaveBeenCalled(); + }); + it('botToBotSameDir is default-TRUE: persists only explicit false, clears on true', async () => { writeConfig(); const { registry, store } = await freshModules(); @@ -209,4 +345,73 @@ describe('card-prefs store — 主动开工 fields', () => { expect(disk.autoStartOnNewTopic).toBe(true); expect(disk.regularGroupReplyMode).toBe('new-topic'); }); + + it('partial patch preserves existing pinStreamingCard on disk, in memory, and in returned prefs', async () => { + writeConfig({ pinStreamingCard: true, autoStartOnNewTopic: true }); + const { registry, store } = await freshModules(); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + + const result = await store.updateBotCardPrefs('app_default', { autoStartOnGroupJoin: true }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prefs.pinStreamingCard).toBe(true); + expect(result.prefs.autoStartOnGroupJoin).toBe(true); + } + expect(readConfig().pinStreamingCard).toBe(true); + expect(registry.getBot('app_default').config.pinStreamingCard).toBe(true); + }); + + it('serializes pinStreamingCard writes across dashboard and /botconfig by invocation order', async () => { + writeConfig(); + const firstAfterDisk = deferred(); + const releaseFirst = deferred(); + let rmwCalls = 0; + const { registry, botConfigStore, store, pinStreamingCardChange } = await freshModulesWithConfigStoreMock(async (actual) => ({ + ...actual, + async rmwBotEntry(larkAppId: string, mutate: Parameters>[1]) { + rmwCalls++; + const result = await actual.rmwBotEntry(larkAppId, mutate); + if (rmwCalls === 1) { + firstAfterDisk.resolve(); + await releaseFirst.promise; + } + return result; + }, + })); + registry.loadBotConfigs().forEach(c => registry.registerBot(c)); + const spec = botConfigStore.findConfigField('PINSTREAMINGCARD')!; + const observed: Array<{ enabled: boolean; disk: unknown; memory: unknown }> = []; + const dispose = pinStreamingCardChange.registerPinStreamingCardChangeHandler((appId, enabled) => { + observed.push({ + enabled, + disk: readConfig().pinStreamingCard, + memory: registry.getBot(appId).config.pinStreamingCard, + }); + }); + + try { + const dashboardWrite = store.updateBotCardPrefs('app_default', { pinStreamingCard: true }); + await firstAfterDisk.promise; + expect(readConfig().pinStreamingCard).toBe(true); + expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); + + const commandWrite = botConfigStore.applyConfigField('app_default', spec, false); + expect(rmwCalls).toBe(1); + + releaseFirst.resolve(); + const [firstResult, secondResult] = await Promise.all([dashboardWrite, commandWrite]); + expect(firstResult.ok).toBe(true); + expect(secondResult.ok).toBe(true); + } finally { + dispose(); + } + + expect(readConfig().pinStreamingCard).toBeUndefined(); + expect(registry.getBot('app_default').config.pinStreamingCard).toBeUndefined(); + expect(observed).toEqual([ + { enabled: true, disk: true, memory: true }, + { enabled: false, disk: undefined, memory: undefined }, + ]); + }); }); diff --git a/test/close-stream-card-untouched.test.ts b/test/close-stream-card-untouched.test.ts index 14e22831f..32bdb77f3 100644 --- a/test/close-stream-card-untouched.test.ts +++ b/test/close-stream-card-untouched.test.ts @@ -5,15 +5,24 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the Lark client so we can observe deleteMessage without real API calls. -const { deleteMessage } = vi.hoisted(() => ({ deleteMessage: vi.fn(async () => undefined) })); +const { deleteMessage, unpinMessage } = vi.hoisted(() => ({ + deleteMessage: vi.fn(async () => undefined), + unpinMessage: vi.fn(async () => true), +})); +const getBotMock = vi.hoisted(() => vi.fn()); vi.mock('../src/im/lark/client.js', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, deleteMessage }; + return { ...actual, deleteMessage, unpinMessage }; +}); +vi.mock('../src/bot-registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getBot: (...args: any[]) => getBotMock(...args) }; }); import { config } from '../src/config.js'; import * as workerPool from '../src/core/worker-pool.js'; import { activeSessionKey } from '../src/core/types.js'; +import { saveFrozenCards } from '../src/services/frozen-card-store.js'; import * as sessionStore from '../src/services/session-store.js'; const tempDirs: string[] = []; @@ -41,9 +50,18 @@ function makeDs(sessionId: string, appId: string, streamCardId: string) { } as any; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + describe('closeSession leaves the streaming card alone', () => { beforeEach(() => { deleteMessage.mockClear(); + unpinMessage.mockClear(); + getBotMock.mockReturnValue({ config: { pinStreamingCard: false } }); }); afterEach(() => { workerPool.setActiveSessionsRegistry(new Map()); @@ -51,10 +69,10 @@ describe('closeSession leaves the streaming card alone', () => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); - // Close is not a card-cleanup step: the streaming card (and its buttons) is a - // resume-time concern. The Lark card close button patches the clicked card in - // place into the "会话已关闭" card; closeSession itself must never delete it. - it('does NOT delete the streaming card on close', async () => { + // Close is not a card-cleanup step for an opt-out bot: the streaming card + // (and any manual Pin on it) remains a resume-time concern. The Lark card + // close button patches the clicked card in place into the "会话已关闭" card. + it('does NOT delete or unpin the streaming card when Pin was never enabled', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-')); tempDirs.push(dataDir); const prev = config.session.dataDir; @@ -70,7 +88,143 @@ describe('closeSession leaves the streaming card alone', () => { await workerPool.closeSession(s.sessionId, { awaitWorkerExit: false }); expect(deleteMessage).not.toHaveBeenCalledWith('app-close-card', 'om_stream_card'); + expect(unpinMessage).not.toHaveBeenCalled(); + expect(sessionStore.getSession(s.sessionId)?.status).toBe('closed'); + } finally { + config.session.dataDir = prev; + } + }); + + it('returns close success before a slow enabled-card Unpin settles', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-enabled-')); + tempDirs.push(dataDir); + const prev = config.session.dataDir; + config.session.dataDir = dataDir; + sessionStore.init('app-close-card'); + try { + const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group'); + s.larkAppId = 'app-close-card'; + sessionStore.updateSession(s); + const ds = makeDs(s.sessionId, 'app-close-card', 'om_stream_card'); + workerPool.setActiveSessionsRegistry(new Map([[activeSessionKey(ds), ds]])); + getBotMock.mockReturnValue({ config: { pinStreamingCard: true } }); + const unpinStarted = deferred(); + const releaseUnpin = deferred(); + unpinMessage.mockImplementationOnce(() => { + unpinStarted.resolve(); + return releaseUnpin.promise; + }); + + await expect(workerPool.closeSession(s.sessionId, { awaitWorkerExit: false })).resolves.toEqual({ + ok: true, outcome: 'closed', alreadyClosed: false, known: true, + }); + await unpinStarted.promise; + expect(unpinMessage).toHaveBeenCalledWith('app-close-card', 'om_stream_card'); expect(sessionStore.getSession(s.sessionId)?.status).toBe('closed'); + + releaseUnpin.resolve(false); + await workerPool.__testOnly_waitForPinStreamingCardIdle(); + } finally { + config.session.dataDir = prev; + } + }); + + it('cleans current and frozen cards from a workerless persisted row when enabled', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-workerless-')); + tempDirs.push(dataDir); + const prev = config.session.dataDir; + config.session.dataDir = dataDir; + sessionStore.init('app-close-card'); + try { + const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group'); + s.larkAppId = 'app-close-card'; + s.streamCardId = 'om_stored_current'; + sessionStore.updateSession(s); + saveFrozenCards(s.sessionId, new Map([ + ['old', { messageId: 'om_stored_frozen', content: '', title: '', displayMode: 'hidden' }], + ])); + workerPool.setActiveSessionsRegistry(new Map()); + getBotMock.mockReturnValue({ config: { pinStreamingCard: true } }); + + await expect(workerPool.closeSession(s.sessionId)).resolves.toEqual({ + ok: true, outcome: 'closed', alreadyClosed: false, known: true, + }); + await workerPool.__testOnly_waitForPinStreamingCardIdle(); + + expect(new Set(unpinMessage.mock.calls.map(([, messageId]) => messageId))).toEqual( + new Set(['om_stored_current', 'om_stored_frozen']), + ); + } finally { + config.session.dataDir = prev; + } + }); + + it('leaves a workerless persisted row untouched when Pin was never enabled', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-workerless-off-')); + tempDirs.push(dataDir); + const prev = config.session.dataDir; + config.session.dataDir = dataDir; + sessionStore.init('app-close-card'); + try { + const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group'); + s.larkAppId = 'app-close-card'; + s.streamCardId = 'om_stored_current'; + sessionStore.updateSession(s); + saveFrozenCards(s.sessionId, new Map([ + ['old', { messageId: 'om_stored_frozen', content: '', title: '', displayMode: 'hidden' }], + ])); + workerPool.setActiveSessionsRegistry(new Map()); + + await workerPool.closeSession(s.sessionId); + await workerPool.__testOnly_waitForPinStreamingCardIdle(); + + expect(unpinMessage).not.toHaveBeenCalled(); + } finally { + config.session.dataDir = prev; + } + }); + + it('does not start cleanup when the durable close save fails', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-save-fail-')); + tempDirs.push(dataDir); + const prev = config.session.dataDir; + config.session.dataDir = dataDir; + sessionStore.init('app-close-card'); + try { + const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group'); + s.larkAppId = 'app-close-card'; + s.streamCardId = 'om_stored_current'; + sessionStore.updateSession(s); + getBotMock.mockReturnValue({ config: { pinStreamingCard: true } }); + const failingClose = vi.spyOn(sessionStore, 'closeSession') + .mockImplementationOnce(() => { throw new Error('disk full'); }); + + await expect(workerPool.closeSession(s.sessionId)).rejects.toThrow('disk full'); + await workerPool.__testOnly_waitForPinStreamingCardIdle(); + expect(unpinMessage).not.toHaveBeenCalled(); + failingClose.mockRestore(); + } finally { + config.session.dataDir = prev; + } + }); + + it('makes no Lark call for an enabled close on an apiOnly transport', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-api-only-')); + tempDirs.push(dataDir); + const prev = config.session.dataDir; + config.session.dataDir = dataDir; + sessionStore.init('app-close-card'); + try { + const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group'); + s.larkAppId = 'app-close-card'; + s.streamCardId = 'om_stream_card'; + sessionStore.updateSession(s); + getBotMock.mockReturnValue({ config: { pinStreamingCard: true, apiOnly: true } }); + + await workerPool.closeSession(s.sessionId); + await workerPool.__testOnly_waitForPinStreamingCardIdle(); + + expect(unpinMessage).not.toHaveBeenCalled(); } finally { config.session.dataDir = prev; } diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index c02adfa92..02f05d43d 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -339,6 +339,7 @@ vi.mock('../src/core/worker-pool.js', () => ({ postFreshStreamingCard: vi.fn(async () => false), postPrivateSnapshotCard: vi.fn(async () => ({ notReady: false, sent: 1, total: 1 })), resolvePrivateCardAudience: vi.fn(() => ['ou_owner']), + reconcileBotStreamingCardPins: vi.fn(), })); vi.mock('../src/utils/daemon-discovery.js', () => ({ @@ -518,7 +519,7 @@ import { sessionKey } from '../src/core/types.js'; import { setTerminalProxyPort } from '../src/core/terminal-url.js'; import type { DaemonSession } from '../src/core/types.js'; import type { LarkMessage, Session } from '../src/types.js'; -import { type CloseSessionResult, closeSession, closeSession as closeWorkerPoolSession, killWorker, teardownAuthoritativePersistentBackingBeforeClose, suspendWorker, forkWorker, forkAdoptWorker, forkSession, isForkCapableSession, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart, withActiveSessionKeyLock, postFreshStreamingCard } from '../src/core/worker-pool.js'; +import { type CloseSessionResult, closeSession, closeSession as closeWorkerPoolSession, killWorker, teardownAuthoritativePersistentBackingBeforeClose, suspendWorker, forkWorker, forkAdoptWorker, forkSession, isForkCapableSession, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart, withActiveSessionKeyLock, postFreshStreamingCard, reconcileBotStreamingCardPins } from '../src/core/worker-pool.js'; import { dashboardEventBus, type DashboardEvent } from '../src/core/dashboard-events.js'; import { publishClosedSessionPatch } from '../src/core/session-activity.js'; import { getOwnerOpenId } from '../src/bot-registry.js'; @@ -1164,6 +1165,58 @@ describe('/botconfig set p2pOpen (私聊对话全开) via the real text command' }); describe('/botconfig string field goes through coerceConfigValue (maxLen)', () => { + it('persists pinStreamingCard and returns promptly even when hot reconciliation throws or hangs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-botconfig-pinstreaming-')); + const configPath = join(dir, 'bots.json'); + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: 'app-1', + larkAppSecret: 'secret-1', + cliId: 'codex', + allowedUsers: ['ou_sender'], + }])); + const bot = { + botName: 'Codex', + config: { + larkAppId: 'app-1', + larkAppSecret: 'secret-1', + cliId: 'codex' as const, + allowedUsers: ['ou_sender'], + workingDir: '~/projects', + workingDirs: ['~/projects'], + }, + resolvedAllowedUsers: ['ou_sender'], + }; + vi.mocked(getBot).mockReturnValue(bot as any); + const run = (text: string) => handleCommand('/botconfig', ROOT_ID, makeLarkMessage(text, { senderId: 'ou_sender' }), makeDeps(), 'app-1'); + const stored = () => JSON.parse(readFileSync(configPath, 'utf-8'))[0]; + const change = await import('../src/services/pin-streaming-card-change.js'); + + try { + const disposeThrow = change.registerPinStreamingCardChangeHandler(() => { + throw new Error('reconcile failed'); + }); + await expect(run('/botconfig set pinStreamingCard on')).resolves.toBeUndefined(); + disposeThrow(); + expect(stored().pinStreamingCard).toBe(true); + expect((bot.config as any).pinStreamingCard).toBe(true); + + let release!: () => void; + const disposePending = change.registerPinStreamingCardChangeHandler(() => { + void new Promise((resolve) => { release = resolve; }); + }); + await expect(run('/botconfig set pinStreamingCard off')).resolves.toBeUndefined(); + disposePending(); + expect('pinStreamingCard' in stored()).toBe(false); + expect((bot.config as any).pinStreamingCard).toBeUndefined(); + release(); + } finally { + delete process.env.BOTS_CONFIG; + rmSync(dir, { recursive: true, force: true }); + vi.mocked(getBot).mockImplementation(defaultGetBot as any); + } + }); + it('rejects an over-long displayName and persists a valid one', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-botconfig-displayname-')); const configPath = join(dir, 'bots.json'); diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index a9153e0d1..52f227b2e 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -1313,6 +1313,8 @@ describe('card behavior defaults', () => { expect(cardOff.root.findByProps({ 'data-card-off-options': true }).props.hidden).toBe(false); expect(cardOff.root.findByProps({ 'data-action': 'toggle-silent-reactions' }).props.checked).toBe(true); expect(cardOff.root.findByProps({ 'data-action': 'toggle-silent-reactions' }).props.disabled).toBe(false); + expect(cardOff.root.findByProps({ 'data-action': 'toggle-pin-streaming-card' }).props.checked).toBe(false); + expect(cardOff.root.findByProps({ 'data-action': 'toggle-pin-streaming-card' }).props.disabled).toBe(false); expect(cardOff.root.findByProps({ 'data-action': 'toggle-writable-link' }).props.disabled).toBe(false); expect(cardOff.root.findByProps({ 'data-card-pref-status': '' }).props).toMatchObject({ role: 'status', @@ -1320,6 +1322,43 @@ describe('card behavior defaults', () => { }); }); + it('pin streaming toggle defaults unchecked when the payload omits it', () => { + const putCardPref = vi.fn(async () => ({ ok: true, status: 200, body: { ok: true } })); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(CardBehaviorSection, { + bot: { larkAppId: 'cli_pin_absent' }, + putCardPref, + })); + }); + + expect(renderer.root.findByProps({ 'data-action': 'toggle-pin-streaming-card' }).props.checked).toBe(false); + }); + + it('toggling pin streaming on persists pinStreamingCard=true', 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_pin_toggle' }, + putCardPref, + })); + }); + + const toggle = renderer.root.findByProps({ 'data-action': 'toggle-pin-streaming-card' }); + await act(async () => { + toggle.props.onChange({ currentTarget: { checked: true } }); + await Promise.resolve(); + }); + + expect(putCardPref).toHaveBeenCalledWith({ pinStreamingCard: true }); + expect(renderer.root.findByProps({ 'data-action': 'toggle-pin-streaming-card' }).props.checked).toBe(true); + }); + it('enabling automatic cards persists disableStreamingCard=false', async () => { const putCardPref = vi.fn(async (patch: Record) => ({ ok: true, @@ -1383,7 +1422,7 @@ describe('card behavior defaults', () => { })); }); - for (const action of ['toggle-disable-streaming', 'toggle-silent-reactions', 'toggle-writable-link', 'toggle-private-card']) { + for (const action of ['toggle-disable-streaming', 'toggle-silent-reactions', 'toggle-pin-streaming-card', 'toggle-writable-link', 'toggle-private-card']) { const before = renderer.root.findByProps({ 'data-action': action }).props.checked; await act(async () => { renderer.root.findByProps({ 'data-action': action }).props.onChange({ currentTarget: { checked: !before } }); @@ -1410,7 +1449,7 @@ describe('card behavior defaults', () => { renderer.root.findByProps({ 'data-action': 'toggle-disable-streaming' }).props.onChange({ currentTarget: { checked: true } }); }); - for (const action of ['toggle-disable-streaming', 'toggle-silent-reactions', 'toggle-writable-link', 'toggle-private-card']) { + for (const action of ['toggle-disable-streaming', 'toggle-silent-reactions', 'toggle-pin-streaming-card', 'toggle-writable-link', 'toggle-private-card']) { expect(renderer.root.findByProps({ 'data-action': action }).props.disabled).toBe(true); } expect(renderer.root.findByProps({ id: 'bd-menu-usageDisplay' }).props.disabled).toBe(true); diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts index 46299f02d..b23f4b180 100644 --- a/test/dashboard-bot-payload.test.ts +++ b/test/dashboard-bot-payload.test.ts @@ -14,20 +14,29 @@ describe('dashboard bot payload helpers', () => { {}, ); const editableFields = [ - 'agentSelectionKey', 'autoGrantRequestCards', 'autoStartOnGroupJoin', - 'autoStartOnGroupJoinPrompt', 'autoStartOnNewTopic', 'backendType', - 'botToBotSameDir', 'brandLabel', 'canTalkDaemonCommands', 'cliRuntime', 'codexAppCleanInput', 'codexAuthSync', - 'customPassthroughCommands', 'defaultOncall', 'defaultWorkingDir', - 'defaultWorkingDirAutoWorktree', 'disableStreamingCard', 'docSubscribeDefaultMode', - 'envelopeInjection', 'env', 'grantDefaultDurationMs', 'launchShell', 'maxLiveWorkers', 'messageQuotaDefaultLimit', 'model', - 'feedback', - 'overloadAlert', 'p2pMode', 'p2pOpen', 'privateCard', 'regularGroupMentionMode', - 'regularGroupReplyMode', 'restrictGrantCommands', 'riff', 'sandbox', 'sandboxPaths', - 'silentTurnReactions', 'skillInjection', 'startupCommands', 'substituteMode', - 'summaryMemory', 'summaryMemoryPath', 'summaryRange', 'senderTag', 'writableTerminalLinkInCard', + 'larkAppId', 'botName', 'cliId', 'cliRuntime', 'model', 'agentSelectionKey', 'online', + 'displayName', 'larkBotName', + 'defaultOncall', 'defaultWorkingDir', 'defaultWorkingDirAutoWorktree', + 'autoboundChatCount', 'brandLabel', + 'sandbox', 'sandboxPaths', 'readIsolationSupported', 'backendType', + 'usageDisplay', 'usageSupported', + 'disableStreamingCard', 'pinStreamingCard', 'silentTurnReactions', + 'codexAppCleanInput', 'writableTerminalLinkInCard', 'privateCard', + 'thinkingCard', 'senderTag', 'overloadAlert', 'botToBotSameDir', + 'autoStartOnGroupJoin', 'autoStartOnGroupJoinPrompt', 'autoStartOnNewTopic', + 'summaryRange', 'summaryMemory', 'summaryMemoryPath', + 'regularGroupReplyMode', 'regularGroupMentionMode', 'docSubscribeDefaultMode', + 'substituteMode', 'feedback', + 'restrictGrantCommands', 'autoGrantRequestCards', 'p2pOpen', + 'grantDefaultDurationMs', 'messageQuotaDefaultLimit', 'p2pMode', + 'envelopeInjection', 'codexAuthSync', + 'skillInjection', 'skillInjectionDefault', 'skillInjectionSupport', + 'maxLiveWorkers', 'logicalSessionCount', 'residentSessionCount', 'dormantSessionCount', 'sessionOwnerReminder', + 'startupCommands', 'customPassthroughCommands', 'canTalkDaemonCommands', 'launchShell', 'env', + 'riff', 'skills', ]; - expect(Object.keys(row)).toEqual(expect.arrayContaining(editableFields)); + expect(Object.keys(row)).toEqual(editableFields); }); it('normalizes the Codex auth policy to the upgrade-compatible shared default', () => { @@ -181,6 +190,21 @@ describe('dashboard bot payload helpers', () => { .toMatchObject({ codexAppCleanInput: true }); }); + it('projects pinStreamingCard as an explicit default-off boolean', () => { + const daemon = { larkAppId: 'app_pin', botName: 'Pin', cliId: 'codex' }; + expect(botDefaultsPayload(daemon, {})).toMatchObject({ pinStreamingCard: false }); + expect(botDefaultsPayload(daemon, { pinStreamingCard: true })) + .toMatchObject({ pinStreamingCard: true }); + expect(botDefaultsPayload(daemon, { pinStreamingCard: false })) + .toMatchObject({ pinStreamingCard: false }); + expect(botDefaultsPayload(daemon, { pinStreamingCard: 'true' })) + .toMatchObject({ pinStreamingCard: false }); + expect(botDefaultsPayload(daemon, { pinStreamingCard: 1 })) + .toMatchObject({ pinStreamingCard: false }); + expect(botDefaultsPayload(daemon, { pinStreamingCard: null })) + .toMatchObject({ pinStreamingCard: false }); + }); + it('projects hook envelope injection so the dashboard preserves it after refresh', () => { const daemon = { larkAppId: 'app_claude', botName: 'Claude', cliId: 'claude-code' }; expect(botDefaultsPayload(daemon, {})).toMatchObject({ envelopeInjection: 'off' }); diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 9fa6a5979..689c9c673 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -840,6 +840,132 @@ describe('PUT /api/bot-card-prefs — Codex App clean history', () => { }); }); +describe('PUT /api/bot-card-prefs — pin streaming card', () => { + it('is default-off, preserves unrelated partial patches, and rejects non-boolean writes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-pin-streaming-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-pin-streaming-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.pinStreamingCard).toBe(false); + + const on = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pinStreamingCard: true }), + }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ ok: true, pinStreamingCard: true }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].pinStreamingCard).toBe(true); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).pinStreamingCard).toBe(true); + + const unrelated = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ silentTurnReactions: true }), + }); + expect(unrelated.status).toBe(200); + expect(await unrelated.json()).toMatchObject({ + ok: true, + silentTurnReactions: true, + pinStreamingCard: true, + }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].pinStreamingCard).toBe(true); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).pinStreamingCard).toBe(true); + + const off = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pinStreamingCard: false }), + }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ ok: true, pinStreamingCard: false }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].pinStreamingCard).toBeUndefined(); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).pinStreamingCard).toBe(false); + + const bogus = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pinStreamingCard: 'true' }), + }); + expect(bogus.status).toBe(400); + expect(await bogus.json()).toMatchObject({ ok: false, error: 'no_valid_fields' }); + } 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 }); + } + }); + + it('returns promptly even when hot reconciliation throws or remains pending', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-pin-streaming-async-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-pin-streaming-async-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + const change = await import('../src/services/pin-streaming-card-change.js'); + 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 disposeThrow = change.registerPinStreamingCardChangeHandler(() => { + throw new Error('reconcile failed after write'); + }); + const throwRes = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pinStreamingCard: true }), + }); + disposeThrow(); + expect(throwRes.status).toBe(200); + expect(await throwRes.json()).toMatchObject({ ok: true, pinStreamingCard: true }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].pinStreamingCard).toBe(true); + + let release!: () => void; + const disposePending = change.registerPinStreamingCardChangeHandler(() => { + void new Promise((resolve) => { release = resolve; }); + }); + const pendingRes = await fetch(`${base}/api/bot-card-prefs`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pinStreamingCard: false }), + }); + disposePending(); + expect(pendingRes.status).toBe(200); + expect(await pendingRes.json()).toMatchObject({ ok: true, pinStreamingCard: false }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].pinStreamingCard).toBeUndefined(); + release(); + } 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('PUT /api/bot-card-prefs — summary memory', () => { it('surfaces the persisted memory toggle and path in the Bot Defaults refresh payload', async () => { const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-summary-memory-')); diff --git a/test/lark-pin-message.test.ts b/test/lark-pin-message.test.ts new file mode 100644 index 000000000..d74af9c37 --- /dev/null +++ b/test/lark-pin-message.test.ts @@ -0,0 +1,84 @@ +/** + * pinMessage / unpinMessage 的 boolean 契约:只有 Lark 明确 code===0 才返回 true; + * SDK 抛错或非 0 / missing code 返回 false(Pin 是 QoL,必须 fail-open)。 + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { logger } from '../src/utils/logger.js'; + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record) {} } + return { Client: FakeClient }; +}); + +import { registerBot, getBot } from '../src/bot-registry.js'; +import { pinMessage, unpinMessage } from '../src/im/lark/client.js'; + +function setPinImpl(appId: string, impl: (req: any) => Promise) { + registerBot({ larkAppId: appId, larkAppSecret: 's', cliId: 'claude-code' }); + getBot(appId).client = { im: { v1: { pin: { create: impl } } } } as any; +} + +function setUnpinImpl(appId: string, impl: (req: any) => Promise) { + registerBot({ larkAppId: appId, larkAppSecret: 's', cliId: 'claude-code' }); + getBot(appId).client = { im: { v1: { pin: { delete: impl } } } } as any; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('pinMessage/unpinMessage boolean contract', () => { + it('pin calls SDK with exact create payload', async () => { + const create = vi.fn(async () => ({ code: 0 })); + setPinImpl('p_payload', create); + await pinMessage('p_payload', 'om_pin'); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith({ data: { message_id: 'om_pin' } }); + }); + + it('unpin calls SDK with exact delete payload', async () => { + const del = vi.fn(async () => ({ code: 0 })); + setUnpinImpl('u_payload', del); + await unpinMessage('u_payload', 'om_pin'); + expect(del).toHaveBeenCalledTimes(1); + expect(del).toHaveBeenCalledWith({ path: { message_id: 'om_pin' } }); + }); + + it('returns true only when Lark confirms (code 0)', async () => { + setPinImpl('p_ok', async () => ({ code: 0, msg: 'success' })); + setUnpinImpl('u_ok', async () => ({ code: 0, msg: 'success' })); + await expect(pinMessage('p_ok', 'om_pin')).resolves.toBe(true); + await expect(unpinMessage('u_ok', 'om_pin')).resolves.toBe(true); + }); + + it('returns false on non-zero code', async () => { + setPinImpl('p_bad', async () => ({ code: 230001, msg: 'fail' })); + setUnpinImpl('u_bad', async () => ({ code: 230001, msg: 'fail' })); + await expect(pinMessage('p_bad', 'om_pin')).resolves.toBe(false); + await expect(unpinMessage('u_bad', 'om_pin')).resolves.toBe(false); + }); + + it('returns false when response has no code field (treated as failure)', async () => { + setPinImpl('p_missing', async () => ({})); + setUnpinImpl('u_missing', async () => ({})); + await expect(pinMessage('p_missing', 'om_pin')).resolves.toBe(false); + await expect(unpinMessage('u_missing', 'om_pin')).resolves.toBe(false); + }); + + it('returns false when the SDK throws and warning is sanitized (no auth token)', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const err: any = new Error('sdk failed'); + err.config = { headers: { Authorization: 'Bearer fake_authorization_token' } }; + setPinImpl('p_throw', async () => { throw err; }); + await expect(pinMessage('p_throw', 'om_pin')).resolves.toBe(false); + expect(warn).toHaveBeenCalled(); + const joined = warn.mock.calls.map((c) => c.join(' ')).join(' '); + const lowered = joined.toLowerCase(); + expect(lowered).not.toContain('fake_authorization_token'); + expect(lowered).not.toContain('authorization'); + }); + + it('two successful unpin calls both return true (wrapper is stateless)', async () => { + setUnpinImpl('u_idem', async () => ({ code: 0 })); + await expect(unpinMessage('u_idem', 'om_pin')).resolves.toBe(true); + await expect(unpinMessage('u_idem', 'om_pin')).resolves.toBe(true); + }); +}); diff --git a/test/lark-transport-boundary.test.ts b/test/lark-transport-boundary.test.ts index 28b78fe3a..69dcb36fc 100644 --- a/test/lark-transport-boundary.test.ts +++ b/test/lark-transport-boundary.test.ts @@ -18,6 +18,7 @@ const fakeClient = { im: { v1: { message: { create: vi.fn(async () => ({ code: 0, data: { message_id: 'om_x' } })), patch: vi.fn(async () => ({ code: 0 })) }, + pin: { create: vi.fn(async () => ({ code: 0 })), delete: vi.fn(async () => ({ code: 0 })) }, messageReaction: { create: vi.fn(async () => ({ code: 0, data: { reaction_id: 'r' } })), delete: vi.fn(async () => ({ code: 0 })) }, }, }, @@ -42,6 +43,7 @@ vi.mock('../src/bot-registry.js', async (importOriginal) => { import { sendMessage, replyMessage, updateMessage, deleteMessage, + pinMessage, unpinMessage, addReaction, removeReaction, sendUserMessage, sendEphemeralCard, deleteEphemeralCard, uploadImage, uploadFile, LarkTransportDisabledError, @@ -63,6 +65,10 @@ describe('assertLarkTransport — bot-level outbound gate', () => { await expect(replyMessage(APIONLY, 'om', 'hi')).rejects.toBeInstanceOf(LarkTransportDisabledError); await expect(updateMessage(APIONLY, 'om', '{}')).rejects.toBeInstanceOf(LarkTransportDisabledError); await expect(deleteMessage(APIONLY, 'om')).rejects.toBeInstanceOf(LarkTransportDisabledError); + await expect(pinMessage(APIONLY, 'om')).rejects.toBeInstanceOf(LarkTransportDisabledError); + await expect(unpinMessage(APIONLY, 'om')).rejects.toBeInstanceOf(LarkTransportDisabledError); + expect(fakeClient.im.v1.pin.create).not.toHaveBeenCalled(); + expect(fakeClient.im.v1.pin.delete).not.toHaveBeenCalled(); await expect(addReaction(APIONLY, 'om', 'THUMBSUP')).rejects.toBeInstanceOf(LarkTransportDisabledError); await expect(removeReaction(APIONLY, 'om', 'r')).rejects.toBeInstanceOf(LarkTransportDisabledError); await expect(sendUserMessage(APIONLY, 'ou', 'hi')).rejects.toBeInstanceOf(LarkTransportDisabledError); diff --git a/test/mojo-explicit-close.test.ts b/test/mojo-explicit-close.test.ts index 4697357d3..ad06ca83a 100644 --- a/test/mojo-explicit-close.test.ts +++ b/test/mojo-explicit-close.test.ts @@ -26,6 +26,7 @@ const { getBotMock, cancelMojoMock } = vi.hoisted(() => ({ getBotMock: vi.fn(), cancelMojoMock: vi.fn(async () => ({ kind: 'cancelled' as const })), })); +const unpinMessageMock = vi.hoisted(() => vi.fn(async () => true)); vi.mock('../src/bot-registry.js', () => ({ getBot: getBotMock, @@ -54,6 +55,7 @@ vi.mock('../src/im/lark/client.js', () => ({ addReaction: vi.fn(), removeReaction: vi.fn(), getMessageChatId: vi.fn(), + unpinMessage: (...args: any[]) => unpinMessageMock(...args), MessageWithdrawnError: class extends Error {}, })); @@ -70,6 +72,7 @@ vi.mock('../src/utils/logger.js', () => ({ import { config } from '../src/config.js'; import { __testOnly_setupWorkerHandlers, + __testOnly_waitForPinStreamingCardIdle, closeSession, closeSessionForBackgroundCleanup, forkWorker, @@ -215,6 +218,23 @@ afterEach(() => { }); describe('mojo explicit close', () => { + it('starts streaming-card cleanup after a durable closed_with_residual result', async () => { + const fixture = createFixture({ legacyUnfrozen: true }); + fixture.session.streamCardId = 'om_mojo_stream'; + fixture.ds.streamCardId = 'om_mojo_stream'; + getBotMock.mockReturnValue({ + resolvedAllowedUsers: [], + config: { mojo: { cloud: true }, pinStreamingCard: true }, + }); + + await expect(closeSession(fixture.session.sessionId)).resolves.toMatchObject({ + ok: true, outcome: 'closed_with_residual', + }); + await __testOnly_waitForPinStreamingCardIdle(); + + expect(unpinMessageMock).toHaveBeenCalledWith('app', 'om_mojo_stream'); + }); + it('does NOT roll back an uncertain close prepare', async () => { // The tri-state existed only inside the worker: the daemon saw a bare ok:false // and sent close_abort unconditionally, laundering `uncertain` straight back diff --git a/test/pin-streaming-card-change.test.ts b/test/pin-streaming-card-change.test.ts new file mode 100644 index 000000000..2d69cb28a --- /dev/null +++ b/test/pin-streaming-card-change.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/utils/logger.js', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { logger } from '../src/utils/logger.js'; +import { + notifyPinStreamingCardChanged, + registerPinStreamingCardChangeHandler, + serializePinStreamingCardConfigChange, +} from '../src/services/pin-streaming-card-change.js'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('pin-streaming-card change handler seam', () => { + beforeEach(() => { + vi.clearAllMocks(); + registerPinStreamingCardChangeHandler(null as any); + }); + + it('notifies the currently registered handler and allows disposal', () => { + const calls: Array<[string, boolean]> = []; + const dispose = registerPinStreamingCardChangeHandler((appId, enabled) => { + calls.push([appId, enabled]); + }); + + notifyPinStreamingCardChanged('app-one', true); + expect(calls).toEqual([['app-one', true]]); + + dispose(); + notifyPinStreamingCardChanged('app-one', false); + expect(calls).toEqual([['app-one', true]]); + }); + + it('replaces the previous handler and only clears the current one when disposed', () => { + const first = vi.fn(); + const second = vi.fn(); + + const disposeFirst = registerPinStreamingCardChangeHandler(first); + const disposeSecond = registerPinStreamingCardChangeHandler(second); + + notifyPinStreamingCardChanged('app-two', true); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('app-two', true); + + disposeFirst(); + notifyPinStreamingCardChanged('app-two', false); + expect(second).toHaveBeenNthCalledWith(2, 'app-two', false); + + disposeSecond(); + notifyPinStreamingCardChanged('app-two', true); + expect(second).toHaveBeenCalledTimes(2); + }); + + it('swallows handler throws and logs them', () => { + registerPinStreamingCardChangeHandler(() => { + throw new Error('boom'); + }); + + expect(() => notifyPinStreamingCardChanged('app-three', true)).not.toThrow(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pinStreamingCard change handler failed')); + }); + + it('does not block on async rejection and consumes the rejection with a warning', async () => { + let resolved = false; + const handler = vi.fn(async () => { + await Promise.resolve(); + throw new Error('async boom'); + }); + + registerPinStreamingCardChangeHandler(handler); + + expect(() => notifyPinStreamingCardChanged('app-four', false)).not.toThrow(); + expect(handler).toHaveBeenCalledWith('app-four', false); + expect(logger.warn).not.toHaveBeenCalled(); + + await Promise.resolve(); + await Promise.resolve(); + resolved = true; + + expect(resolved).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('async boom')); + }); + + it('releases the per-bot serializer after a failed operation so later writes still run', async () => { + const calls: string[] = []; + + await expect( + serializePinStreamingCardConfigChange('app-five', async () => { + calls.push('first'); + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + await expect( + serializePinStreamingCardConfigChange('app-five', async () => { + calls.push('second'); + }), + ).resolves.toBeUndefined(); + + expect(calls).toEqual(['first', 'second']); + }); + + it('allows different larkAppIds to proceed independently while one bot is blocked', async () => { + const appAStarted = deferred(); + const releaseAppA = deferred(); + const order: string[] = []; + + const blockedA = serializePinStreamingCardConfigChange('app-A', async () => { + order.push('A:start'); + appAStarted.resolve(); + await releaseAppA.promise; + order.push('A:end'); + }); + + await appAStarted.promise; + + await expect( + serializePinStreamingCardConfigChange('app-B', async () => { + order.push('B:start'); + order.push('B:end'); + }), + ).resolves.toBeUndefined(); + + expect(order).toEqual(['A:start', 'B:start', 'B:end']); + + releaseAppA.resolve(); + await expect(blockedA).resolves.toBeUndefined(); + expect(order).toEqual(['A:start', 'B:start', 'B:end', 'A:end']); + }); +}); diff --git a/test/recall-frozen-cards.test.ts b/test/recall-frozen-cards.test.ts index 32dd2b239..fd03ed6ec 100644 --- a/test/recall-frozen-cards.test.ts +++ b/test/recall-frozen-cards.test.ts @@ -9,13 +9,15 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { DaemonSession, FrozenCard } from '../src/core/types.js'; -import { sessionKey } from '../src/core/types.js'; +import { activeSessionKey } from '../src/core/types.js'; import { setTerminalProxyPort } from '../src/core/terminal-url.js'; // ─── Mocks ───────────────────────────────────────────────────────────────── const deleteMessageMock = vi.fn(async (_appId: string, _messageId: string) => {}); const updateMessageMock = vi.fn(async (_appId: string, _messageId: string, _json: string) => {}); +const pinMessageMock = vi.fn(async (_appId: string, _messageId: string) => true); +const unpinMessageMock = vi.fn(async (_appId: string, _messageId: string) => true); const saveFrozenCardsMock = vi.fn(); const loadFrozenCardsMock = vi.fn(() => new Map()); const persistStreamCardStateMock = vi.fn(); @@ -27,6 +29,8 @@ vi.mock('../src/im/lark/client.js', () => { return { updateMessage: (...args: any[]) => updateMessageMock(args[0], args[1], args[2]), deleteMessage: (...args: any[]) => deleteMessageMock(args[0], args[1]), + pinMessage: (...args: any[]) => pinMessageMock(args[0], args[1]), + unpinMessage: (...args: any[]) => unpinMessageMock(args[0], args[1]), MessageWithdrawnError, }; }); @@ -127,6 +131,10 @@ import { MessageWithdrawnError } from '../src/im/lark/client.js'; import { buildStreamingCard } from '../src/im/lark/card-builder.js'; import { getBot, resolveUsageDisplay } from '../src/bot-registry.js'; +const buildStreamingCardMock = buildStreamingCard as ReturnType; +const getBotMock = getBot as ReturnType; +const resolveUsageDisplayMock = resolveUsageDisplay as ReturnType; + // ─── Helpers ─────────────────────────────────────────────────────────────── const APP_ID = 'app_test'; @@ -169,24 +177,32 @@ function makeDs(frozenCards?: Map): DaemonSession { }; } +function activate(ds: DaemonSession): void { + setActiveSessionsRegistry(new Map([[activeSessionKey(ds), ds]])); +} + beforeEach(() => { deleteMessageMock.mockClear(); updateMessageMock.mockReset(); updateMessageMock.mockResolvedValue(undefined); + pinMessageMock.mockReset(); + pinMessageMock.mockResolvedValue(true); + unpinMessageMock.mockReset(); + unpinMessageMock.mockResolvedValue(true); saveFrozenCardsMock.mockClear(); loadFrozenCardsMock.mockReset(); loadFrozenCardsMock.mockReturnValue(new Map()); persistStreamCardStateMock.mockClear(); - vi.mocked(buildStreamingCard).mockClear(); - vi.mocked(getBot).mockReturnValue({ + buildStreamingCardMock.mockClear(); + getBotMock.mockReturnValue({ config: { larkAppId: APP_ID, cliId: 'claude-code' }, } as any); setTerminalProxyPort(8800); + setActiveSessionsRegistry(new Map()); }); afterEach(() => { setActiveSessionsRegistry(undefined as any); - vi.clearAllTimers(); vi.useRealTimers(); }); @@ -194,6 +210,11 @@ function flush(): Promise { return new Promise(resolve => setImmediate(resolve)); } +async function drainPinQueue(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + // ─── Tests ───────────────────────────────────────────────────────────────── describe('recallFrozenCards', () => { @@ -352,9 +373,8 @@ describe('recallFrozenCards', () => { describe('restoreUsageLimitRuntimeState', () => { it('marks restored limit sessions limited and re-arms the retry timer', () => { - const now = new Date('2026-05-22T10:00:00Z').getTime(); vi.useFakeTimers(); - vi.setSystemTime(now); + const now = Date.now(); const ds = makeDs(); ds.streamCardId = 'om_live_limit'; ds.streamCardNonce = 'nonce_limit'; @@ -410,9 +430,8 @@ describe('restoreUsageLimitRuntimeState', () => { }); it('marks already-expired restored limits retry-ready immediately', () => { - const now = new Date('2026-05-22T10:00:00Z').getTime(); vi.useFakeTimers(); - vi.setSystemTime(now); + const now = Date.now(); const ds = makeDs(); ds.usageLimit = { limited: true, @@ -431,9 +450,8 @@ describe('restoreUsageLimitRuntimeState', () => { }); it('Plan B: a meeting-agent session patches its Lark card on retry-ready like a normal session', () => { - const now = new Date('2026-05-22T10:00:00Z').getTime(); vi.useFakeTimers(); - vi.setSystemTime(now); + const now = Date.now(); const ds = makeDs(); // The vcMeetingReceiver marker is now pure delivery metadata — it no longer // suppresses the streaming card, so a meeting agent's usage-limit card patch @@ -474,6 +492,7 @@ describe('meeting-agent streaming card (Plan B)', () => { }; ds.workerPort = 4567; ds.workerReady = true; + activate(ds); const sessionReply = vi.fn(async () => 'om_card'); await expect(postFreshStreamingCard(ds, sessionReply)).resolves.toBe(true); @@ -482,6 +501,80 @@ describe('meeting-agent streaming card (Plan B)', () => { }); }); +describe('postFreshStreamingCard', () => { + it('completes /card publication before its deferred Pin chain settles', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { + resolvePin = resolve; + })); + getBotMock.mockReturnValue({ + config: { larkAppId: APP_ID, cliId: 'claude-code', pinStreamingCard: true }, + } as any); + const ds = makeDs(new Map()); + ds.workerReady = true; + ds.streamCardId = 'om_previous'; + ds.streamCardNonce = 'nonce_previous'; + ds.streamCardReplyTargetKey = 'thread:om_root'; + activate(ds); + const sessionReply = vi.fn(async () => 'om_fresh_card'); + let settled = false; + let result: boolean | undefined; + const pending = postFreshStreamingCard(ds, sessionReply).then(value => { + settled = true; + result = value; + return value; + }); + + await drainPinQueue(); + + expect(typeof resolvePin).toBe('function'); + expect(settled).toBe(true); + expect(result).toBe(true); + expect(ds.streamCardId).toBe('om_fresh_card'); + expect(deleteMessageMock).toHaveBeenCalledWith(APP_ID, 'om_previous'); + + resolvePin(true); + await expect(pending).resolves.toBe(true); + }); + + it('discards /card POST results once remote retirement starts waiting', async () => { + let resolvePost!: (messageId: string) => void; + const sessionReply = vi.fn(() => new Promise(resolve => { resolvePost = resolve; })); + const ds = makeDs(); + ds.workerReady = true; + ds.streamCardId = 'om_previous'; + ds.streamCardNonce = 'nonce_previous'; + activate(ds); + + const pending = postFreshStreamingCard(ds, sessionReply); + ds.remoteCloseState = { phase: 'preparing', requestId: 'close-fresh-card' }; + resolvePost('om_stale_fresh_card'); + + await expect(pending).resolves.toBe(false); + await flush(); + + expect(ds.streamCardId).toBe('om_previous'); + expect(ds.streamCardNonce).toBe('nonce_previous'); + expect(deleteMessageMock).toHaveBeenCalledWith(APP_ID, 'om_stale_fresh_card'); + expect(pinMessageMock).not.toHaveBeenCalledWith(APP_ID, 'om_stale_fresh_card'); + }); + + it('fails closed for apiOnly sessions and never attempts /card Pinning', async () => { + getBotMock.mockReturnValue({ + config: { larkAppId: APP_ID, cliId: 'claude-code', apiOnly: true, pinStreamingCard: true }, + } as any); + const ds = makeDs(); + ds.workerReady = true; + const sessionReply = vi.fn(async () => 'om_api_only_card'); + + await expect(postFreshStreamingCard(ds, sessionReply)).resolves.toBe(false); + + expect(sessionReply).not.toHaveBeenCalled(); + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); +}); + describe('postTurnStartingCard', () => { it('does not start a card POST while Riff retirement is already active', async () => { const ds = makeDs(); @@ -500,7 +593,7 @@ describe('postTurnStartingCard', () => { }); it('starts a live Grok turn as working instead of starting', async () => { - vi.mocked(getBot).mockReturnValue({ + getBotMock.mockReturnValue({ config: { larkAppId: APP_ID, cliId: 'grok' }, } as any); const ds = makeDs(); @@ -508,11 +601,12 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; + activate(ds); const sessionReply = vi.fn(async () => 'om_grok_card'); await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_1')).resolves.toBe(true); - expect(vi.mocked(buildStreamingCard).mock.calls[0]?.[5]).toBe('working'); + expect(buildStreamingCardMock.mock.calls[0]?.[5]).toBe('working'); expect(updateMessageMock).not.toHaveBeenCalled(); }); @@ -526,10 +620,11 @@ describe('postTurnStartingCard', () => { ds.streamCardPendingTurnId = 'om_turn_1'; ds.lastScreenStatus = 'idle'; ds.lastScreenContent = ''; + activate(ds); const post = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); expect(sessionReply).toHaveBeenCalledTimes(1); - expect(vi.mocked(buildStreamingCard).mock.calls[0]?.[5]).toBe('starting'); + expect(buildStreamingCardMock.mock.calls[0]?.[5]).toBe('starting'); ds.lastScreenStatus = 'working'; ds.lastScreenContent = 'Grok is thinking'; @@ -539,9 +634,9 @@ describe('postTurnStartingCard', () => { await expect(post).resolves.toBe(true); await flush(); - const statuses = vi.mocked(buildStreamingCard).mock.calls.map(call => call[5]); + const statuses = buildStreamingCardMock.mock.calls.map(call => call[5]); expect(statuses).toContain('working'); - expect(vi.mocked(buildStreamingCard).mock.calls.at(-1)?.[4]).toBe('Grok is thinking'); + expect(buildStreamingCardMock.mock.calls.at(-1)?.[4]).toBe('Grok is thinking'); expect(updateMessageMock).toHaveBeenCalledWith(APP_ID, 'om_turn_card_1', '{}'); }); @@ -551,6 +646,7 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; + activate(ds); const sessionReply = vi.fn(async () => 'om_turn_card_stable'); await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_1')).resolves.toBe(true); @@ -567,6 +663,7 @@ describe('postTurnStartingCard', () => { ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; ds.currentTurnTitle = 'first turn'; + activate(ds); const sessionReply = vi.fn(async () => 'om_turn_card_1'); await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_1')).resolves.toBe(true); @@ -589,6 +686,7 @@ describe('postTurnStartingCard', () => { ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_b1'; ds.currentTurnTitle = 'topic B'; + activate(ds); ds.session.turnReplyContexts = { om_turn_b1: { target: { mode: 'thread', rootMessageId: 'om_topic_b' } }, om_turn_a2: { target: { mode: 'thread', rootMessageId: 'om_topic_a' } }, @@ -630,6 +728,7 @@ describe('postTurnStartingCard', () => { ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; ds.currentTurnTitle = 'first turn'; + activate(ds); const firstPost = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); expect(sessionReply).toHaveBeenCalledTimes(1); @@ -661,6 +760,7 @@ describe('postTurnStartingCard', () => { ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; ds.currentTurnTitle = 'first turn'; + activate(ds); const sessionReply = vi.fn(async () => { throw new Error('network unavailable'); }); await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_1')).resolves.toBe(false); @@ -794,6 +894,7 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; + activate(ds); const post = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); ds.remoteCloseState = { phase: 'preparing', requestId: 'close-1' }; @@ -818,6 +919,7 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; + activate(ds); const post = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); ds.remoteShutdownState = { phase: 'preparing', requestId: 'shutdown-1' }; @@ -843,6 +945,7 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; + activate(ds); const firstPost = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); ds.remoteCloseState = { phase: 'preparing', requestId: 'close-abort' }; @@ -866,11 +969,12 @@ describe('postTurnStartingCard', () => { ds.streamCardPending = true; ds.streamCardTurnGeneration = 1; ds.streamCardPendingTurnId = 'om_turn_1'; - const registry = new Map([[sessionKey('om_root', APP_ID), ds]]); + const registryKey = activeSessionKey(ds); + const registry = new Map([[registryKey, ds]]); setActiveSessionsRegistry(registry); const post = postTurnStartingCard(ds, sessionReply, 'om_turn_1'); - registry.set(sessionKey('om_root', APP_ID), makeDs()); + registry.set(registryKey, makeDs()); resolvePost('om_displaced_registry_card'); await expect(post).resolves.toBe(false); @@ -879,6 +983,39 @@ describe('postTurnStartingCard', () => { expect(ds.streamCardId).not.toBe('om_displaced_registry_card'); expect(deleteMessageMock).toHaveBeenCalledWith(APP_ID, 'om_displaced_registry_card'); }); + + it('starts the successor turn before the older turn Pin chain settles', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: APP_ID, cliId: 'claude-code', pinStreamingCard: true }, + } as any); + const ds = makeDs(); + ds.workerReady = true; + ds.streamCardPending = true; + ds.streamCardTurnGeneration = 1; + ds.streamCardPendingTurnId = 'om_turn_old'; + activate(ds); + const sessionReply = vi.fn() + .mockResolvedValueOnce('om_turn_card_old') + .mockResolvedValueOnce('om_turn_card_successor'); + + await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_old')).resolves.toBe(true); + expect(sessionReply).toHaveBeenCalledTimes(1); + + ds.streamCardTurnGeneration = 2; + ds.streamCardPending = true; + ds.streamCardPendingTurnId = 'om_turn_successor'; + ds.streamCardId = undefined; + await expect(postTurnStartingCard(ds, sessionReply, 'om_turn_successor')).resolves.toBe(true); + + expect(sessionReply).toHaveBeenCalledTimes(2); + expect(ds.streamCardId).toBe('om_turn_card_successor'); + + resolvePin(true); + await flush(); + await flush(); + }); }); // ─── P3 helper: parkStreamCard ───────────────────────────────────────────── @@ -1208,11 +1345,11 @@ describe('usageRefreshShouldRun (arm/clear predicate)', () => { it('is false when usageDisplay is not streaming (footer / off)', () => { const ds = workingDs(); - vi.mocked(resolveUsageDisplay).mockReturnValue('footer' as any); + resolveUsageDisplayMock.mockReturnValue('footer' as any); expect(usageRefreshShouldRun(ds)).toBe(false); - vi.mocked(resolveUsageDisplay).mockReturnValue('off' as any); + resolveUsageDisplayMock.mockReturnValue('off' as any); expect(usageRefreshShouldRun(ds)).toBe(false); - vi.mocked(resolveUsageDisplay).mockReturnValue('streaming' as any); + resolveUsageDisplayMock.mockReturnValue('streaming' as any); }); }); @@ -1239,7 +1376,7 @@ describe('refreshStreamingCardUsage (interval tick)', () => { // asked for a fresh read (empty transcript here → concrete empty snapshot). const ds = workingDs(); refreshStreamingCardUsage(ds); - const call = vi.mocked(buildStreamingCard).mock.calls[0]!; + const call = buildStreamingCardMock.mock.calls[0]!; // Snapshot present (17th positional arg) and interval < throttle by design. expect(call[16]).toEqual({ context: null, tokens: null, turnTokens: null }); expect(USAGE_REFRESH_INTERVAL_MS).toBeLessThan(15_000); @@ -1251,7 +1388,7 @@ describe('refreshStreamingCardUsage (interval tick)', () => { const ds = workingDs(); expect(ds.workerPort ?? null).toBeNull(); refreshStreamingCardUsage(ds); - const call = vi.mocked(buildStreamingCard).mock.calls[0]!; + const call = buildStreamingCardMock.mock.calls[0]!; expect(call[2]).toBe(''); // 3rd positional arg = read-only terminal URL }); @@ -1259,7 +1396,7 @@ describe('refreshStreamingCardUsage (interval tick)', () => { const ds = workingDs(); ds.workerPort = 9101; // a real Web Terminal port refreshStreamingCardUsage(ds); - const call = vi.mocked(buildStreamingCard).mock.calls[0]!; + const call = buildStreamingCardMock.mock.calls[0]!; expect(typeof call[2]).toBe('string'); expect(call[2]).not.toBe(''); expect(call[2]).toContain(`/s/${SESSION_ID}`); @@ -1348,10 +1485,10 @@ describe('syncUsageRefreshTimer (state-boundary arm/clear)', () => { it('does not arm when usageDisplay is off', () => { vi.useFakeTimers(); const ds = workingDs(); - vi.mocked(resolveUsageDisplay).mockReturnValue('off' as any); + resolveUsageDisplayMock.mockReturnValue('off' as any); syncUsageRefreshTimer(ds); expect(ds.usageRefreshTimer).toBeUndefined(); - vi.mocked(resolveUsageDisplay).mockReturnValue('streaming' as any); + resolveUsageDisplayMock.mockReturnValue('streaming' as any); }); it('re-arms after a CLI auto-restart (working card survives, worker re-readies)', () => { @@ -1376,8 +1513,8 @@ describe('syncUsageRefreshTimer (state-boundary arm/clear)', () => { expect(ds.usageRefreshTimer).toBeDefined(); // Now ticks resume even without a status edge (working→working). - const before = vi.mocked(buildStreamingCard).mock.calls.length; + const before = buildStreamingCardMock.mock.calls.length; vi.advanceTimersByTime(USAGE_REFRESH_INTERVAL_MS); - expect(vi.mocked(buildStreamingCard).mock.calls.length).toBe(before + 1); + expect(buildStreamingCardMock.mock.calls.length).toBe(before + 1); }); }); diff --git a/test/streaming-card-pinning.test.ts b/test/streaming-card-pinning.test.ts new file mode 100644 index 000000000..9b397cf36 --- /dev/null +++ b/test/streaming-card-pinning.test.ts @@ -0,0 +1,380 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSession, FrozenCard } from '../src/core/types.js'; +import { activeSessionKey } from '../src/core/types.js'; + +const pinMessageMock = vi.fn(async () => true); +const unpinMessageMock = vi.fn(async () => true); + +vi.mock('../src/im/lark/client.js', () => ({ + pinMessage: (...args: any[]) => pinMessageMock(...args), + unpinMessage: (...args: any[]) => unpinMessageMock(...args), + deleteMessage: vi.fn(async () => {}), + updateMessage: vi.fn(async () => {}), + MessageWithdrawnError: class MessageWithdrawnError extends Error {}, +})); +vi.mock('../src/bot-registry.js', () => ({ + getBot: vi.fn(() => ({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true } })), + getAllBots: vi.fn(() => []), + resolveUsageDisplay: vi.fn(() => 'streaming'), +})); +vi.mock('../src/services/frozen-card-store.js', () => ({ loadFrozenCards: vi.fn(() => new Map()), saveFrozenCards: vi.fn() })); +vi.mock('../src/core/session-manager.js', () => ({ persistStreamCardState: vi.fn() })); +vi.mock('../src/utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() } })); +vi.mock('../src/config.js', () => ({ config: { web: { externalHost: 'localhost' }, session: { dataDir: '/tmp' } } })); +vi.mock('../src/global-config.js', () => ({ isRemoteAccessEnabled: vi.fn(() => false) })); +vi.mock('../src/platform/binding.js', () => ({ platformMachineBaseUrl: vi.fn(() => null), publicReverseProxyBaseUrl: vi.fn(() => null) })); +vi.mock('../src/services/session-store.js', () => ({ registerSessionBridgeSendMarkerCleanupFence: vi.fn(), cleanupSessionBridgeSendMarkers: vi.fn(), cleanupSessionBridgeSendMarkersNow: vi.fn(), closeSession: vi.fn(), updateSession: vi.fn() })); +vi.mock('../src/core/dashboard-events.js', () => ({ dashboardEventBus: { publish: vi.fn() } })); +vi.mock('../src/core/dashboard-rows.js', () => ({ composeRowFromActive: vi.fn() })); +vi.mock('../src/skills/installer.js', () => ({ ensureSkills: vi.fn() })); +vi.mock('../src/adapters/cli/registry.js', () => ({ createCliAdapterSync: vi.fn() })); +vi.mock('../src/adapters/cli/claude-code.js', () => ({ claudeJsonlPathForSession: vi.fn() })); +vi.mock('../src/adapters/backend/tmux-backend.js', () => ({ TmuxBackend: class {} })); +vi.mock('../src/im/lark/card-builder.js', () => ({ buildStreamingCard: vi.fn(() => '{}'), buildSessionCard: vi.fn(() => '{}'), buildTuiPromptCard: vi.fn(() => '{}'), buildTuiPromptResolvedCard: vi.fn(() => '{}'), getCliDisplayName: vi.fn(() => 'Claude') })); + +import { + __testOnly_resetPinStreamingCardReconcileQueue, + __testOnly_waitForPinStreamingCardIdle, + CARD_POSTING_SENTINEL, + pinStreamingCardIfEnabled, + reconcileBotStreamingCardPins, + reconcileStreamingCardPins, + setActiveSessionsRegistry, +} from '../src/core/worker-pool.js'; +import { getBot } from '../src/bot-registry.js'; + +const getBotMock = getBot as ReturnType; + +async function drainMicrotasks(times = 2): Promise { + for (let i = 0; i < times; i += 1) { + await Promise.resolve(); + } +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +function makeDs( + card = 'om_current', + frozenCards?: Map, + sessionId = 'pin-session', + rootMessageId = 'om_root', +): DaemonSession { + return { session: { sessionId, rootMessageId, chatId: 'oc_chat', title: 'pin', status: 'active', createdAt: Date.now(), updatedAt: Date.now(), pid: null, chatType: 'group' }, worker: null, workerPort: null, workerToken: null, larkAppId: 'app-pin', chatId: 'oc_chat', chatType: 'group', spawnedAt: Date.now(), cliVersion: 'test', lastMessageAt: Date.now(), hasHistory: true, scope: 'thread', streamCardId: card, frozenCards } as any; +} +function activate(ds: DaemonSession) { setActiveSessionsRegistry(new Map([[activeSessionKey(ds), ds]])); } + +describe('streaming-card pin policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + __testOnly_resetPinStreamingCardReconcileQueue(); + setActiveSessionsRegistry(new Map()); + pinMessageMock.mockResolvedValue(true); + unpinMessageMock.mockResolvedValue(true); + getBotMock.mockReturnValue({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true } } as any); + }); + it('does nothing when disabled, sentinel, inactive, displaced, or changed', async () => { + const ds = makeDs(); activate(ds); + getBotMock.mockReturnValue({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: false } } as any); + expect(await pinStreamingCardIfEnabled(ds, 'om_current')).toBe(false); + getBotMock.mockReturnValue({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true } } as any); + ds.streamCardId = CARD_POSTING_SENTINEL; expect(await pinStreamingCardIfEnabled(ds, CARD_POSTING_SENTINEL)).toBe(false); + ds.streamCardId = 'om_current'; ds.session.status = 'closed'; expect(await pinStreamingCardIfEnabled(ds, 'om_current')).toBe(false); + ds.session.status = 'active'; setActiveSessionsRegistry(new Map()); expect(await pinStreamingCardIfEnabled(ds, 'om_current')).toBe(false); + expect(pinMessageMock).not.toHaveBeenCalled(); + }); + + it('fails closed when the active session registry is unavailable', async () => { + const ds = makeDs(); + setActiveSessionsRegistry(undefined as any); + + await expect(pinStreamingCardIfEnabled(ds, 'om_current')).resolves.toBe(false); + + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + it('pins only the active owned current card and compensates a stale success', async () => { + const ds = makeDs(); activate(ds); + let resolvePin!: (value: boolean) => void; pinMessageMock.mockImplementation(() => new Promise(resolve => { resolvePin = resolve; })); + const pending = pinStreamingCardIfEnabled(ds, 'om_current'); + await drainMicrotasks(1); + ds.streamCardId = 'om_new'; resolvePin(true); + expect(await pending).toBe(false); + expect(pinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + expect(unpinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + }); + it('reconciles enabled in pin-then-session-wide-frozen-unpin order and disable unpins every unique real id', async () => { + const frozen = new Map([['a', { messageId: 'om_same_topic', content: '', title: '', displayMode: 'hidden', replyTargetKey: 'one' }], ['b', { messageId: 'om_other_topic', content: '', title: '', displayMode: 'hidden', replyTargetKey: 'two' }], ['c', { messageId: 'om_current', content: '', title: '', displayMode: 'hidden' }]]); + const ds = makeDs('om_current', frozen); activate(ds); + await reconcileStreamingCardPins(ds, true); + expect(pinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + expect(unpinMessageMock.mock.calls.map(c => c[1])).toEqual(['om_same_topic', 'om_other_topic']); + pinMessageMock.mockClear(); unpinMessageMock.mockClear(); + await reconcileStreamingCardPins(ds, false); + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(new Set(unpinMessageMock.mock.calls.map(c => c[1]))).toEqual(new Set(['om_current'])); + }); + + it('default-off with no feature-owned ids is zero-call and leaves manual pins untouched', async () => { + const ds = makeDs( + 'om_current', + new Map([['frozen', { messageId: 'om_frozen', content: '', title: '', displayMode: 'hidden' }]]), + ); + activate(ds); + + await reconcileStreamingCardPins(ds, false); + + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + + it('explicit on-to-off toggle cleans known current and frozen ids after provenance reset', async () => { + const ds = makeDs( + 'om_current', + new Map([['frozen', { messageId: 'om_frozen', content: '', title: '', displayMode: 'hidden' }]]), + ); + activate(ds); + let pinStreamingCard = true; + getBotMock.mockImplementation(() => ({ + config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard }, + } as any)); + + await expect(pinStreamingCardIfEnabled(ds, 'om_current')).resolves.toBe(true); + + __testOnly_resetPinStreamingCardReconcileQueue(); + activate(ds); + pinMessageMock.mockClear(); + unpinMessageMock.mockClear(); + + pinStreamingCard = false; + reconcileBotStreamingCardPins('app-pin', false); + await __testOnly_waitForPinStreamingCardIdle(); + + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(new Set(unpinMessageMock.mock.calls.map(call => call[1]))).toEqual(new Set([ + 'om_current', + 'om_frozen', + ])); + }); + + it('reconcile is a zero-call no-op for apiOnly and HTTP virtual transports', async () => { + const ds = makeDs(); + activate(ds); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true, apiOnly: true }, + } as any); + + await reconcileStreamingCardPins(ds, true); + await reconcileStreamingCardPins(ds, false); + + getBotMock.mockReturnValue({ + config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true }, + } as any); + ds.chatId = 'http_async_pin_reconcile'; + activate(ds); + await reconcileStreamingCardPins(ds, true); + await reconcileStreamingCardPins(ds, false); + + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + + it('forgets ownership only after a successful Unpin so a failed cleanup can retry', async () => { + const ds = makeDs(); + activate(ds); + await expect(pinStreamingCardIfEnabled(ds, 'om_current')).resolves.toBe(true); + unpinMessageMock.mockResolvedValueOnce(false); + + await reconcileStreamingCardPins(ds, false); + await reconcileStreamingCardPins(ds, false); + + expect(unpinMessageMock.mock.calls.map(call => call[1])).toEqual(['om_current', 'om_current']); + }); + + it('retains ownership after a thrown Unpin so a later cleanup retries', async () => { + const ds = makeDs(); + activate(ds); + await expect(pinStreamingCardIfEnabled(ds, 'om_current')).resolves.toBe(true); + unpinMessageMock.mockRejectedValueOnce(new Error('transport reset')); + + await reconcileStreamingCardPins(ds, false); + await reconcileStreamingCardPins(ds, false); + + expect(unpinMessageMock.mock.calls.map(call => call[1])).toEqual(['om_current', 'om_current']); + }); + + it('serializes a close cleanup Unpin before a same-card resume Pin', async () => { + const ds = makeDs(); + activate(ds); + await expect(pinStreamingCardIfEnabled(ds, 'om_current')).resolves.toBe(true); + const releaseUnpin = deferred(); + const unpinStarted = deferred(); + const calls: string[] = []; + unpinMessageMock.mockImplementationOnce(() => { + calls.push('unpin'); + unpinStarted.resolve(); + return releaseUnpin.promise; + }); + pinMessageMock.mockImplementation(() => { calls.push('pin'); return Promise.resolve(true); }); + + const closing = reconcileStreamingCardPins(ds, false); + // This explicit test barrier proves the queued Unpin has been issued + // before resuming; no timing-sensitive microtask or timer flushing. + await unpinStarted.promise; + expect(calls).toEqual(['unpin']); + const resuming = pinStreamingCardIfEnabled(ds, 'om_current'); + expect(calls).toEqual(['unpin']); + + releaseUnpin.resolve(true); + await closing; + await expect(resuming).resolves.toBe(true); + expect(calls).toEqual(['unpin', 'pin']); + }); + + it('does not let a pre-reset deferred Unpin forget replacement provenance', async () => { + const original = makeDs(); + activate(original); + await expect(pinStreamingCardIfEnabled(original, 'om_current')).resolves.toBe(true); + const unpinStarted = deferred(); + const releaseUnpin = deferred(); + unpinMessageMock.mockImplementationOnce(() => { + unpinStarted.resolve(); + return releaseUnpin.promise; + }); + const retiring = reconcileStreamingCardPins(original, false); + await unpinStarted.promise; + + __testOnly_resetPinStreamingCardReconcileQueue(); + const replacement = makeDs(); + activate(replacement); + await expect(pinStreamingCardIfEnabled(replacement, 'om_current')).resolves.toBe(true); + releaseUnpin.resolve(true); + await retiring; + + unpinMessageMock.mockClear(); + await reconcileStreamingCardPins(replacement, false); + expect(unpinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + }); + + it('reconciles all active sessions for the matching bot, ignores other bots, and isolates one session failure', async () => { + const first = makeDs('om_first', undefined, 'pin-session-1', 'om_root_1'); + const second = makeDs('om_second', undefined, 'pin-session-2', 'om_root_2'); + const otherBot = { ...makeDs('om_other', undefined, 'pin-session-3', 'om_root_3'), larkAppId: 'app-other' } as DaemonSession; + const inactive = { ...makeDs('om_inactive', undefined, 'pin-session-4', 'om_root_4'), session: { ...makeDs('om_inactive', undefined, 'pin-session-4', 'om_root_4').session, status: 'closed' } } as DaemonSession; + const displaced = makeDs('om_displaced', undefined, 'pin-session-5', 'om_root_shared'); + const winner = makeDs('om_winner', undefined, 'pin-session-6', 'om_root_shared'); + setActiveSessionsRegistry(new Map([ + [activeSessionKey(first), first], + [activeSessionKey(second), second], + [activeSessionKey(otherBot), otherBot], + [activeSessionKey(inactive), inactive], + [activeSessionKey(displaced), displaced], + [activeSessionKey(winner), winner], + ])); + + pinMessageMock.mockImplementation(async (_appId: string, messageId: string) => { + if (messageId === 'om_first') throw new Error('pin failed'); + return true; + }); + + reconcileBotStreamingCardPins('app-pin', true); + await Promise.resolve(); + await Promise.resolve(); + + expect(pinMessageMock.mock.calls.map(c => [c[0], c[1]])).toEqual([ + ['app-pin', 'om_first'], + ['app-pin', 'om_second'], + ['app-pin', 'om_winner'], + ]); + expect(pinMessageMock).not.toHaveBeenCalledWith('app-pin', 'om_inactive'); + expect(pinMessageMock).not.toHaveBeenCalledWith('app-pin', 'om_displaced'); + expect(unpinMessageMock).not.toHaveBeenCalledWith('app-other', 'om_other'); + }); + + it('serializes bot-wide disable then enable and reruns the latest desired state after deferred unpin completes', async () => { + const first = makeDs( + 'om_current', + new Map([['frozen', { messageId: 'om_frozen', content: '', title: '', displayMode: 'hidden' }]]), + 'pin-session-1', + 'om_root_1', + ); + const second = makeDs('om_second', undefined, 'pin-session-2', 'om_root_2'); + activate(first); + await reconcileStreamingCardPins(first, true); + pinMessageMock.mockClear(); + unpinMessageMock.mockClear(); + let resolveCurrentUnpin!: (value: boolean) => void; + unpinMessageMock.mockImplementation((appId: string, messageId: string) => { + if (appId === 'app-pin' && messageId === 'om_current') { + return new Promise(resolve => { resolveCurrentUnpin = resolve; }); + } + return Promise.resolve(true); + }); + + setActiveSessionsRegistry(new Map([[activeSessionKey(first), first]])); + reconcileBotStreamingCardPins('app-pin', false); + await drainMicrotasks(); + + expect(unpinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + expect(pinMessageMock).not.toHaveBeenCalled(); + + setActiveSessionsRegistry(new Map([ + [activeSessionKey(first), first], + [activeSessionKey(second), second], + ])); + reconcileBotStreamingCardPins('app-pin', true); + await drainMicrotasks(1); + + expect(pinMessageMock).not.toHaveBeenCalled(); + + resolveCurrentUnpin(true); + await __testOnly_waitForPinStreamingCardIdle(); + + expect(pinMessageMock.mock.calls.map(c => [c[0], c[1]])).toEqual([ + ['app-pin', 'om_current'], + ['app-pin', 'om_second'], + ]); + }); + + it('serializes bot-wide enable then disable and ends at the latest off state after deferred pin completes', async () => { + const ds = makeDs( + 'om_current', + new Map([['frozen', { messageId: 'om_frozen', content: '', title: '', displayMode: 'hidden' }]]), + ); + activate(ds); + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementation((appId: string, messageId: string) => { + if (appId === 'app-pin' && messageId === 'om_current') { + return new Promise(resolve => { resolvePin = resolve; }); + } + return Promise.resolve(true); + }); + + getBotMock.mockReturnValue({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: true } } as any); + reconcileBotStreamingCardPins('app-pin', true); + await drainMicrotasks(); + + expect(pinMessageMock).toHaveBeenCalledWith('app-pin', 'om_current'); + expect(unpinMessageMock).not.toHaveBeenCalled(); + + getBotMock.mockReturnValue({ config: { larkAppId: 'app-pin', cliId: 'claude-code', pinStreamingCard: false } } as any); + reconcileBotStreamingCardPins('app-pin', false); + await drainMicrotasks(1); + + expect(unpinMessageMock).not.toHaveBeenCalled(); + + resolvePin(true); + await __testOnly_waitForPinStreamingCardIdle(); + + expect(pinMessageMock).toHaveBeenCalledTimes(1); + expect(unpinMessageMock.mock.calls.map(c => [c[0], c[1]])).toEqual([ + ['app-pin', 'om_current'], + ['app-pin', 'om_current'], + ['app-pin', 'om_frozen'], + ]); + }); +}); diff --git a/test/transfer-session.test.ts b/test/transfer-session.test.ts index 1fd98b6b6..3a33a5792 100644 --- a/test/transfer-session.test.ts +++ b/test/transfer-session.test.ts @@ -27,11 +27,12 @@ vi.mock('../src/services/session-store.js', () => ({ closeSession: vi.fn(), })); -vi.mock('../src/bot-registry.js', () => ({ - getBot: vi.fn(() => ({ - config: { cliId: 'claude-code', larkAppId: 'cli_app_test' }, +const getBotMock = vi.hoisted(() => vi.fn(() => ({ + config: { cliId: 'claude-code', larkAppId: 'cli_app_test', pinStreamingCard: false }, botName: 'TestBot', - })), + }))); +vi.mock('../src/bot-registry.js', () => ({ + getBot: (...args: any[]) => getBotMock(...args), getAllBots: vi.fn(() => []), getBotBrand: vi.fn(() => 'feishu'), })); @@ -44,8 +45,12 @@ vi.mock('../src/core/dashboard-events.js', () => ({ // (replace the live streaming card with an inert "已搬迁" snapshot before // clearing streamCardId). Mock it so tests don't try real Lark API calls. const updateMessageMock = vi.fn(async () => undefined); +const pinMessageMock = vi.fn(async () => true); +const unpinMessageMock = vi.fn(async () => true); vi.mock('../src/im/lark/client.js', () => ({ updateMessage: (...a: any[]) => updateMessageMock(...a), + pinMessage: (...a: any[]) => pinMessageMock(...a), + unpinMessage: (...a: any[]) => unpinMessageMock(...a), deleteMessage: vi.fn(), MessageWithdrawnError: class extends Error {}, })); @@ -83,6 +88,8 @@ import { suspendWorker, transferSession, __testOnly_setupWorkerHandlers, + __testOnly_waitForPinStreamingCardIdle, + pinStreamingCardIfEnabled, setActiveSessionsRegistry, setActiveSessionIfActive, setActiveSessionSafe, @@ -170,6 +177,10 @@ describe('transferSession', () => { beforeEach(() => { vi.clearAllMocks(); + getBotMock.mockReturnValue({ + config: { cliId: 'claude-code', larkAppId: 'cli_app_test', pinStreamingCard: false }, + botName: 'TestBot', + }); __testOnly_resetBotTurnMutationGates(); vi.mocked(sessionStore.listSessions).mockReturnValue([]); resetDeviceIsolationActivationForTest(); @@ -1503,6 +1514,80 @@ describe('transferSession', () => { expect(body).toMatch(/"img_key":\s*"old_image_key"/); }); + it('leaves manual source Pins untouched when streaming-card Pin was never enabled', async () => { + const ds = makeDs({ frozenCards: new Map([ + ['prior', { messageId: 'om_frozen_card', content: '', title: '', displayMode: 'hidden' }], + ]) }); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + + const r = await callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target'); + expect(r.ok).toBe(true); + expect(registry.get(sessionKey('oc_target', 'cli_app_test'))).toBe(ds); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + + it('makes zero Pin or Unpin calls for an enabled transfer on an apiOnly transport', async () => { + const ds = makeDs({ frozenCards: new Map([ + ['prior', { messageId: 'om_frozen_card', content: '', title: '', displayMode: 'hidden' }], + ]) }); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + getBotMock.mockReturnValue({ + config: { cliId: 'claude-code', larkAppId: 'cli_app_test', pinStreamingCard: true, apiOnly: true }, + botName: 'TestBot', + }); + + await expect(callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target')).resolves.toEqual({ ok: true }); + await __testOnly_waitForPinStreamingCardIdle(); + + expect(pinMessageMock).not.toHaveBeenCalled(); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + + it('returns committed transfer success before a slow feature-owned source Unpin settles', async () => { + const ds = makeDs({ frozenCards: new Map([ + ['prior', { messageId: 'om_frozen_card', content: '', title: '', displayMode: 'hidden' }], + ]) }); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + getBotMock.mockReturnValue({ + config: { cliId: 'claude-code', larkAppId: 'cli_app_test', pinStreamingCard: true }, + botName: 'TestBot', + }); + await expect(pinStreamingCardIfEnabled(ds, 'om_old_card')).resolves.toBe(true); + + const unpinStarted = deferred(); + const releaseUnpin = deferred(); + unpinMessageMock.mockImplementationOnce(() => { + unpinStarted.resolve(); + return releaseUnpin.promise; + }); + + await expect(callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target')).resolves.toEqual({ ok: true }); + await unpinStarted.promise; + expect(unpinMessageMock).toHaveBeenCalledWith('cli_app_test', 'om_old_card'); + + releaseUnpin.resolve(false); + await __testOnly_waitForPinStreamingCardIdle(); + }); + + it('does not start source Pin cleanup when an enabled transfer is refused', async () => { + const ds = makeDs(); + registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); + registry.set(sessionKey('oc_target', 'cli_app_test'), makeDs({ + session: { ...ds.session, sessionId: 'target-existing', chatId: 'oc_target', rootMessageId: 'om_M1_target', scope: 'chat' }, + chatId: 'oc_target', + scope: 'chat', + })); + getBotMock.mockReturnValue({ + config: { cliId: 'claude-code', larkAppId: 'cli_app_test', pinStreamingCard: true }, + botName: 'TestBot', + }); + + await expect(callTransfer(ds.session.sessionId, 'oc_target', 'om_M1_target')).resolves.toEqual({ + ok: false, error: 'target_chat_has_session', + }); + expect(unpinMessageMock).not.toHaveBeenCalled(); + }); + it('reattaches at the routing commit before awaiting the source-card patch', async () => { const ds = makeDs(); registry.set(sessionKey('om_source_root', 'cli_app_test'), ds); diff --git a/test/worker-ready-display-mode.test.ts b/test/worker-ready-display-mode.test.ts index e6587a673..d316a51ef 100644 --- a/test/worker-ready-display-mode.test.ts +++ b/test/worker-ready-display-mode.test.ts @@ -19,6 +19,9 @@ import { EventEmitter } from 'node:events'; // ─── Mocks ───────────────────────────────────────────────────────────────── const updateMessageMock = vi.fn(async () => {}); +const deleteMessageMock = vi.fn(async () => {}); +const pinMessageMock = vi.fn(async () => true); +const unpinMessageMock = vi.fn(async () => true); const { loggerInfoMock } = vi.hoisted(() => ({ loggerInfoMock: vi.fn() })); vi.mock('../src/im/lark/client.js', () => { @@ -27,7 +30,9 @@ vi.mock('../src/im/lark/client.js', () => { } return { updateMessage: (...args: any[]) => updateMessageMock(...args), - deleteMessage: vi.fn(async () => {}), + deleteMessage: (...args: any[]) => deleteMessageMock(...args), + pinMessage: (...args: any[]) => pinMessageMock(...args), + unpinMessage: (...args: any[]) => unpinMessageMock(...args), MessageWithdrawnError, }; }); @@ -134,12 +139,21 @@ vi.mock('@larksuiteoapi/node-sdk', () => ({ // ─── Imports under test ──────────────────────────────────────────────────── -import { CARD_POSTING_SENTINEL, initWorkerPool, __testOnly_setupWorkerHandlers } from '../src/core/worker-pool.js'; +import { + CARD_POSTING_SENTINEL, + initWorkerPool, + postTurnStartingCard, + __testOnly_setupWorkerHandlers, + __testOnly_waitForPinStreamingCardIdle, + setActiveSessionsRegistry, +} from '../src/core/worker-pool.js'; import { MessageWithdrawnError } from '../src/im/lark/client.js'; -import type { DaemonSession } from '../src/core/types.js'; +import { activeSessionKey, type DaemonSession } from '../src/core/types.js'; import { getBot } from '../src/bot-registry.js'; import * as sessionStore from '../src/services/session-store.js'; +const getBotMock = getBot as ReturnType; + // ─── Helpers ─────────────────────────────────────────────────────────────── function makeFakeWorker() { @@ -189,6 +203,24 @@ function flush(): Promise { return new Promise(resolve => setTimeout(resolve, 0)); } +async function primaryEffectsBarrier(): Promise { + await flush(); +} + +async function deferredAndIdleBarrier(): Promise { + await __testOnly_waitForPinStreamingCardIdle(); + await flush(); +} + +function activate(ds: DaemonSession): void { + setActiveSessionsRegistry(new Map([[activeSessionKey(ds), ds]])); +} + +function setupActiveWorkerHandlers(ds: DaemonSession, worker: any): void { + activate(ds); + __testOnly_setupWorkerHandlers(ds, worker); +} + // ─── Tests ───────────────────────────────────────────────────────────────── describe('Worker ready: set_display_mode re-sync', () => { @@ -197,6 +229,14 @@ describe('Worker ready: set_display_mode re-sync', () => { beforeEach(() => { vi.clearAllMocks(); + pinMessageMock.mockResolvedValue(true); + unpinMessageMock.mockResolvedValue(true); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code' }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + } as any); sessionReplyMock = vi.fn(async () => 'om_new_card'); closeSessionMock = vi.fn(); initWorkerPool({ @@ -205,13 +245,165 @@ describe('Worker ready: set_display_mode re-sync', () => { getActiveCount: () => 1, closeSession: closeSessionMock, }); + setActiveSessionsRegistry(new Map()); + }); + + it('does not let a stale ready Pin continuation recall the successor frozen cards', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + streamCardPending: true, + streamCardPendingTurnId: 'om_turn_1', + streamCardId: undefined, + frozenCards: new Map([[ + 'old', { messageId: 'om_frozen_predecessor', content: '', title: '', displayMode: 'hidden', replyTargetKey: 'thread:om_root' }, + ]]), + worker: fakeWorker, + }); + activate(ds); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'om_turn_1' }); + await primaryEffectsBarrier(); + expect(ds.streamCardId).toBe('om_new_card'); + expect(pinMessageMock).toHaveBeenCalledWith('app_test', 'om_new_card'); + expect(deleteMessageMock).toHaveBeenCalledWith('app_test', 'om_frozen_predecessor'); + + // A successor wins while the older Pin is still in flight. Primary recall + // already happened; the old continuation may only compensate its own Pin. + ds.streamCardId = 'om_successor'; + resolvePin(true); + await deferredAndIdleBarrier(); + + expect(deleteMessageMock).toHaveBeenCalledTimes(1); + expect(ds.frozenCards?.has('old')).toBe(false); + expect(unpinMessageMock).toHaveBeenCalledWith('app_test', 'om_new_card'); + }); + + it('does not let a stale persisted-card reuse recall or overwrite the successor', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + worker: fakeWorker, + streamCardId: 'om_restored_card', + streamCardPending: false, + frozenCards: new Map([['old', { + messageId: 'om_frozen_predecessor', content: '', title: '', displayMode: 'hidden', replyTargetKey: 'thread:om_root', + }]]), + }); + activate(ds); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); + await primaryEffectsBarrier(); + expect(updateMessageMock).toHaveBeenCalledWith('app_test', 'om_restored_card', expect.any(String)); + expect(pinMessageMock).toHaveBeenCalledWith('app_test', 'om_restored_card'); + expect(deleteMessageMock).toHaveBeenCalledWith('app_test', 'om_frozen_predecessor'); + + ds.streamCardId = 'om_successor'; + resolvePin(true); + await deferredAndIdleBarrier(); + + expect(ds.streamCardId).toBe('om_successor'); + expect(ds.frozenCards?.has('old')).toBe(false); + expect(unpinMessageMock).toHaveBeenCalledWith('app_test', 'om_restored_card'); + }); + + it('leaves a successor intact when a stale persisted-card restore PATCH rejects', async () => { + let rejectRestore!: (error: Error) => void; + updateMessageMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRestore = reject; + })); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + worker: fakeWorker, + streamCardId: 'om_restored_card', + streamCardPending: false, + }); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); + await flush(); + expect(updateMessageMock).toHaveBeenCalledWith('app_test', 'om_restored_card', expect.any(String)); + + ds.streamCardId = 'om_successor'; + rejectRestore(new Error('restored card rejected')); + await flush(); + await flush(); + + expect(ds.streamCardId).toBe('om_successor'); + expect(sessionReplyMock).not.toHaveBeenCalled(); + }); + + it('schedules the successor after a turn-start Pin loses ownership', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); + const ds = makeDs({ worker: makeFakeWorker(), workerReady: true, streamCardPending: true, streamCardPendingTurnId: 'om_turn_old', streamCardId: undefined }); + activate(ds); + const oldPost = postTurnStartingCard(ds, sessionReplyMock, 'om_turn_old'); + await flush(); + expect(sessionReplyMock).toHaveBeenCalledTimes(1); + await expect(oldPost).resolves.toBe(true); + + ds.streamCardTurnGeneration = 1; + ds.streamCardPending = true; + ds.streamCardPendingTurnId = 'om_turn_successor'; + ds.streamCardId = undefined; + await expect(postTurnStartingCard(ds, sessionReplyMock, 'om_turn_successor')).resolves.toBe(true); + + expect(sessionReplyMock).toHaveBeenCalledTimes(2); + expect(ds.streamCardPendingTurnId).toBeUndefined(); + resolvePin(true); + await __testOnly_waitForPinStreamingCardIdle(); + }); + + it('schedules the successor after a worker-ready Pin loses ownership', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ worker: fakeWorker, streamCardPending: true, streamCardPendingTurnId: 'om_turn_old', streamCardId: undefined }); + activate(ds); + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'om_turn_old' }); + await flush(); + expect(sessionReplyMock).toHaveBeenCalledTimes(1); + expect(ds.streamCardId).toBe('om_new_card'); + + ds.streamCardTurnGeneration = 1; + ds.streamCardPending = true; + ds.streamCardPendingTurnId = 'om_turn_successor'; + ds.streamCardId = undefined; + await expect(postTurnStartingCard(ds, sessionReplyMock, 'om_turn_successor')).resolves.toBe(true); + + expect(sessionReplyMock).toHaveBeenCalledTimes(2); + expect(ds.streamCardPendingTurnId).toBeUndefined(); + resolvePin(true); + await __testOnly_waitForPinStreamingCardIdle(); }); it('persists the exact shared Herdr target reported by the worker', () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ worker: fakeWorker }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'persistent_backend_target', target: { @@ -239,7 +431,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }); ds.session.cliId = 'codex'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'codex_service_tier', snapshot: { @@ -274,7 +466,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }); ds.session.cliId = 'claude-code'; - __testOnly_setupWorkerHandlers(ds, staleWorker); + setupActiveWorkerHandlers(ds, staleWorker); expect(ds.codexServiceTier).toBeUndefined(); ds.worker = replacement; staleWorker.emit('message', { @@ -300,7 +492,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }); ds.session.cliId = 'codex'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); updateMessageMock.mockClear(); fakeWorker.emit('message', { type: 'codex_service_tier', snapshot: null }); await flush(); @@ -314,7 +506,7 @@ describe('Worker ready: set_display_mode re-sync', () => { const ds = makeDs({ worker: fakeWorker, workerPort: null, streamCardId: undefined }); ds.session.cliId = 'codex'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); ds.pendingCodexTierCardRefresh = true; fakeWorker.emit('message', { type: 'codex_service_tier', @@ -332,7 +524,7 @@ describe('Worker ready: set_display_mode re-sync', () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ streamCardPending: true, streamCardId: undefined, worker: fakeWorker }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, @@ -373,8 +565,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -410,8 +603,9 @@ describe('Worker ready: set_display_mode re-sync', () => { workerReady: true, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'screen_update', content: 'working in topic A', @@ -428,6 +622,89 @@ describe('Worker ready: set_display_mode re-sync', () => { expect(ds.streamCardReplyTargetKey).toBe('thread:om_topic_a'); }); + it('screen_update POST discards stale results once remote retirement starts waiting', async () => { + let resolvePost!: (messageId: string) => void; + sessionReplyMock.mockImplementationOnce(() => new Promise((resolve) => { + resolvePost = resolve; + })); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + streamCardPending: true, + streamCardId: undefined, + workerReady: true, + worker: fakeWorker, + }); + activate(ds); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'screen_update', content: 'working', status: 'working' }); + await flush(); + expect(ds.streamCardId).toBe(CARD_POSTING_SENTINEL); + + ds.remoteCloseState = { phase: 'preparing', requestId: 'close-screen-update' } as any; + resolvePost('om_retired_screen_card'); + await flush(); + await flush(); + + expect(ds.streamCardId).toBeUndefined(); + expect(deleteMessageMock).toHaveBeenCalledWith('app_test', 'om_retired_screen_card'); + expect(pinMessageMock).not.toHaveBeenCalledWith('app_test', 'om_retired_screen_card'); + }); + + it('leaves a successor card intact when a stale screen-update POST rejects', async () => { + let rejectPost!: (error: Error) => void; + sessionReplyMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectPost = reject; + })); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + streamCardPending: true, + streamCardId: undefined, + workerReady: true, + worker: fakeWorker, + }); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'screen_update', content: 'first', status: 'working' }); + await flush(); + expect(ds.streamCardId).toBe(CARD_POSTING_SENTINEL); + + ds.streamCardId = 'om_successor'; + rejectPost(new Error('stale post rejected')); + await flush(); + await flush(); + + expect(ds.streamCardId).toBe('om_successor'); + }); + + it('schedules the successor after a screen-update Pin loses ownership', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ worker: fakeWorker, workerReady: true, streamCardPending: true, streamCardPendingTurnId: 'om_turn_old', streamCardId: undefined }); + activate(ds); + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'screen_update', content: 'old', status: 'working', turnId: 'om_turn_old' }); + await flush(); + expect(sessionReplyMock).toHaveBeenCalledTimes(1); + expect(ds.streamCardId).toBe('om_new_card'); + + ds.streamCardTurnGeneration = 1; + ds.streamCardPending = true; + ds.streamCardPendingTurnId = 'om_turn_successor'; + ds.streamCardId = undefined; + await expect(postTurnStartingCard(ds, sessionReplyMock, 'om_turn_successor')).resolves.toBe(true); + + expect(sessionReplyMock).toHaveBeenCalledTimes(2); + expect(ds.streamCardPendingTurnId).toBeUndefined(); + resolvePin(true); + await __testOnly_waitForPinStreamingCardIdle(); + }); + it('treats port=0 as ready without Web Terminal and keeps screen/screenshot state flowing', async () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ @@ -437,8 +714,9 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, displayMode: 'screenshot', }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 0, token: 'unused', viewToken: 'unused-view' }); await flush(); @@ -477,8 +755,9 @@ describe('Worker ready: set_display_mode re-sync', () => { lastScreenContent: 'current generation', lastScreenStatus: 'working', }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, staleWorker); + setupActiveWorkerHandlers(ds, staleWorker); staleWorker.emit('message', { type: 'ready', port: 9999, token: 'stale-token' }); staleWorker.emit('message', { type: 'screen_update', @@ -509,8 +788,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_doc' }); await flush(); @@ -530,8 +810,9 @@ describe('Worker ready: set_display_mode re-sync', () => { }, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'tui_prompt', description: 'Approve command?', @@ -554,8 +835,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -567,6 +849,33 @@ describe('Worker ready: set_display_mode re-sync', () => { ); }); + it('ready POST discards stale results once remote retirement starts waiting', async () => { + let resolvePost!: (messageId: string) => void; + sessionReplyMock.mockImplementationOnce(() => new Promise((resolve) => { + resolvePost = resolve; + })); + const fakeWorker = makeFakeWorker(); + const ds = makeDs({ + streamCardPending: true, + streamCardId: undefined, + worker: fakeWorker, + }); + + setupActiveWorkerHandlers(ds, fakeWorker); + fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'om_turn_ready' }); + await flush(); + expect(ds.streamCardId).toBe(CARD_POSTING_SENTINEL); + + ds.remoteCloseState = { phase: 'preparing', requestId: 'close-ready' } as any; + resolvePost('om_retired_ready_card'); + await flush(); + await flush(); + + expect(ds.streamCardId).toBeUndefined(); + expect(deleteMessageMock).toHaveBeenCalledWith('app_test', 'om_retired_ready_card'); + expect(pinMessageMock).not.toHaveBeenCalledWith('app_test', 'om_retired_ready_card'); + }); + it('POST path does NOT send set_display_mode when displayMode is hidden', async () => { const fakeWorker = makeFakeWorker(); const ds = makeDs({ @@ -576,7 +885,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -604,7 +913,7 @@ describe('Worker ready: set_display_mode re-sync', () => { content: 'pending', }]; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'turn-pending', }); @@ -627,7 +936,7 @@ describe('Worker ready: set_display_mode re-sync', () => { updateMessageMock.mockResolvedValueOnce(undefined); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -643,6 +952,12 @@ describe('Worker ready: set_display_mode re-sync', () => { }); it('silent recovery restores screenshot mode without touching the streaming card', async () => { + let resolvePin!: (value: boolean) => void; + pinMessageMock.mockImplementationOnce(() => new Promise((resolve) => { resolvePin = resolve; })); + getBotMock.mockReturnValue({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', pinStreamingCard: true }, + resolvedAllowedUsers: [], botOpenId: 'ou_bot', botName: 'TestBot', + } as any); const fakeWorker = makeFakeWorker(); const ds = makeDs({ displayMode: 'screenshot', @@ -652,16 +967,20 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); - await flush(); + await primaryEffectsBarrier(); expect(updateMessageMock).not.toHaveBeenCalled(); expect(sessionReplyMock).not.toHaveBeenCalled(); + expect(pinMessageMock).toHaveBeenCalledWith('app_test', 'om_existing_card'); expect(fakeWorker.send).toHaveBeenCalledWith({ type: 'set_display_mode', mode: 'screenshot', }); + + resolvePin(true); + await deferredAndIdleBarrier(); }); it('sentinel early-break (in-flight card POST) still sends set_display_mode', async () => { @@ -680,7 +999,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -696,7 +1015,6 @@ describe('Worker ready: set_display_mode re-sync', () => { // card will ever be posted" shape. The mode re-sync must not be tied to // card delivery. Implementation is restored in finally: clearAllMocks() // does not undo a leaked mockImplementation for later tests. - const getBotMock = vi.mocked(getBot); const originalGetBot = getBotMock.getMockImplementation(); getBotMock.mockImplementation((() => ({ config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', apiOnly: true }, @@ -713,7 +1031,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -727,7 +1045,6 @@ describe('Worker ready: set_display_mode re-sync', () => { }); it('streamingCardDisabled ready still sends set_display_mode', async () => { - const getBotMock = vi.mocked(getBot); const originalGetBot = getBotMock.getMockImplementation(); getBotMock.mockImplementation((() => ({ config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', disableStreamingCard: true }, @@ -744,7 +1061,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -760,7 +1077,6 @@ describe('Worker ready: set_display_mode re-sync', () => { it('suppressed managed turn screenshot never lands in currentImageKey', async () => { // With the early re-sync, a worker can be uploading during a managed/silent // turn — that frame must not become the next visible card's image. - const getBotMock = vi.mocked(getBot); const originalGetBot = getBotMock.getMockImplementation(); getBotMock.mockImplementation((() => ({ config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', apiOnly: true }, @@ -777,7 +1093,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'screenshot_uploaded', imageKey: 'img_managed_frame', @@ -809,7 +1125,7 @@ describe('Worker ready: set_display_mode re-sync', () => { ds.session.cliSessionId = undefined; ds.session.workingDir = '/tmp'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); expect(updateMessageMock).toHaveBeenCalledTimes(1); @@ -841,7 +1157,7 @@ describe('Worker ready: set_display_mode re-sync', () => { ds.session.cliSessionId = undefined; ds.session.workingDir = '/tmp'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -865,7 +1181,7 @@ describe('Worker ready: set_display_mode re-sync', () => { worker: fakeWorker, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -889,8 +1205,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'om_turn_1', }); @@ -927,8 +1244,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); @@ -950,8 +1268,9 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardPendingTurnId: 'om_turn_1', worker: fakeWorker, }); + activate(ds); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc', turnId: 'om_turn_1', }); @@ -997,13 +1316,14 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: 'om_existing_card', workingDir: '/tmp', }); + activate(ds); ds.session.cliId = 'traex'; ds.session.cliSessionId = undefined; ds.session.workingDir = '/tmp'; ds.adoptedFrom = { source: 'tmux', tmuxTarget: 'dev:1.2', cliId: 'traex', cwd: '/tmp' }; ds.session.adoptedFrom = { source: 'tmux', tmuxTarget: 'dev:1.2', cliId: 'traex', cwd: '/tmp' }; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'cli_session_id', cliSessionId: 'trae-native-ready' }); await flush(); @@ -1027,11 +1347,12 @@ describe('Worker ready: set_display_mode re-sync', () => { streamCardId: undefined, workingDir: '/tmp', }); + activate(ds); ds.session.cliId = 'traex'; ds.session.cliSessionId = undefined; ds.session.workingDir = '/tmp'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); expect(ds.streamCardId).toBe(CARD_POSTING_SENTINEL); @@ -1062,11 +1383,12 @@ describe('Worker ready: set_display_mode re-sync', () => { updateMessageMock.mockRejectedValueOnce(new Error('fallback PATCH failed')); const fakeWorker = makeFakeWorker(); const ds = makeDs({ worker: fakeWorker, workingDir: '/tmp' }); + activate(ds); ds.session.cliId = 'traex'; ds.session.cliSessionId = undefined; ds.session.workingDir = '/tmp'; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'ready', port: 9999, token: 'tok_abc' }); await flush(); expect(sessionReplyMock).toHaveBeenCalledTimes(2); @@ -1092,7 +1414,7 @@ describe('Worker ready: set_display_mode re-sync', () => { pendingRawInput: '/goal ship the onboarding flow', } as Partial); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); @@ -1135,7 +1457,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }, }); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); @@ -1173,7 +1495,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }, } as Partial); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); @@ -1210,7 +1532,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }, } as Partial); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); @@ -1223,7 +1545,7 @@ describe('Worker ready: set_display_mode re-sync', () => { }); it('prompt_ready keeps a staged-off follow-up legacy even if config is now on', async () => { - vi.mocked(getBot).mockReturnValue({ + getBotMock.mockReturnValue({ config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'codex-app', codexAppCleanInput: true }, resolvedAllowedUsers: [], botOpenId: 'ou_bot', @@ -1241,7 +1563,7 @@ describe('Worker ready: set_display_mode re-sync', () => { } as Partial); ds.session.cliId = 'codex-app' as any; - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); @@ -1261,7 +1583,7 @@ describe('Worker ready: set_display_mode re-sync', () => { pendingFollowUpInput: { userPrompt: 'x', cliInput: 'x' }, } as Partial); - __testOnly_setupWorkerHandlers(ds, fakeWorker); + setupActiveWorkerHandlers(ds, fakeWorker); fakeWorker.emit('message', { type: 'prompt_ready' }); await flush(); expect(fakeWorker.send).not.toHaveBeenCalled();